归并排序的妙用——不止于排序

排序是算法中一个绕不开的话题。基础算法中一共有十种排序算法,就是我们常说的十大排序,其中归并排序、快排、堆排这是三种 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N l o g N ) O(NlogN) </math>O(NlogN)的排序,它们是面试算法中的重点,在实际工程中也非常的常用。这篇文章为大家分享归并排序的相关实现和相关题目,希望能给大家带来帮助。

归并排序的递归实现

归并排序是一种典型的基于分治思想的排序算法,它将待排序的数组分成两部分,再对这两部分分别递归调用函数进行排序。当数组被拆分成只有一个元素时,子数组天然有序,这是递归的出口。最后将两个有序部分合并成一个有序数组。从这段描述中我们可以看到,归并排序一共分成三个步骤,即拆分、排序和合并。

我们举一个实际的例子,比如对于这样的一个数组[5, 2, 3, 6, 4, 1],首先将数组拆分为两个子数组[5, 2, 3][6, 4, 1],然后调用递归算法分别对这两个子数组进行排序。这里我们先不对这个递归的细节做分析,先认为调用完递归算法之后,两个子数组分别排好序变成了[2, 3, 5][1, 4, 6],这样流程中"拆分"和"排序"就完成了。

下一步是将这两个有序的子数组合并成一个有序数组,我们设置两个指针p1p2,分别指向两个数组中的第一个元素,再设置一个辅助数组temp

我们将p1p2指向的元素做对比,哪个小就将这个元素拷贝到temp数组中,并将指针向右移动一位。在图中的这个示例中,p2指针指向的元素比较小,将元素拷贝,并将指针右移。

此时,p1指针指向2p2指针指向4p1指针指向的元素比较小,所以拷贝到temp数组中,并将指针向右移动1位。

p1指针指向3p2指针指向4,依然需要拷贝p1指向的元素,并移动p1指针。

p2指向4p1指向5,所以拷贝p2指向的元素,并移动p2指针。

p1指向5p2指向6,拷贝p1指向的元素,并移动p1指针。

此时需要注意,p1指针已经越界,意味着前半部分的数组已经遍历完了, 后半部分的数组还没结束,所以将后半部分数组的剩余部分全部拷贝到temp数组中,然后再将temp数组刷回原数组,这次的合并流程就此结束了。

merge过程的代码实现如下所示。

java 复制代码
/**
 * 数组的前半部分:nums[left...mid]
 * 数组的后半部分:nums[mid+1...right]
 *
 * 数组的两部分分别有序,将数组的这两部分合并成一个有序数组
 */
public static void merge(int[] nums, int left, int mid, int right) {
    int p1 = left;
    int p2 = mid + 1;

    int[] temp = new int[right - left + 1];
    int index = 0;

    while (p1 <= mid && p2 <= right) {
         if (nums[p1] <= nums[p2]) {
             temp[index++] = nums[p1++];
         } else {
             temp[index++] = nums[p2++];
         }
    }

    while (p1 <= mid) {
        temp[index++] = nums[p1++];
    }

    while (p2 <= right) {
        temp[index++] = nums[p2++];
    }

    for (int i=0; i<temp.length; i++) {
        nums[left + i] = temp[i];
    }
}

明白了merge的实现过程之后我们就可以串一下整个递归的过程,还是以[5, 2, 3, 6, 4, 1]这个数组为例,要对整个数组进行归并排序,就要首先对[5, 2, 3][6, 4, 1]这两个子数组做归并,要对[5, 2, 3]数组做归并,就要先对[5, 2][3]这两个子数组做归并,要对[5, 2]做归并,就要先分别对[5][2]这两个子数组做归并,当子数组中只有一个元素时候,数组显然已经是有序的,所以这就是递归的出口,然后再对这两个数组做merge得到[2, 5]这个子数组,再对右侧的[3]先递归,再merge,得到子数组[2, 3, 5]。同理,要对[6, 4, 1]做归并排序,就要先递归地对[6, 4]数组和[1]数组做归并,要对[6, 4]做归并就要先对[6][4]做归并,这两次递归都命中了出口,所以可以直接merge得到子数组[4, 6],再和右侧的[1]merge就得到了[1, 4, 6],最后再将子数组[2, 3, 5][1, 4, 6]merge,整个递归调用就结束了。所以我们得到了这样的一棵递归调用数

递归的代码实现如下

java 复制代码
public static void mergeSort(int[] nums) {
    if (nums == null || nums.length <= 1) {
        return;
    }
    process(nums, 0, nums.length - 1);
}

public static void process(int[] nums, int left, int right) {
    if (left == right) {
        return;
    }

    int mid = left + ((right - left) >> 1);

    process(nums, left, mid);
    process(nums, mid + 1, right);

    merge(nums, left, mid, right);
}

要分析这段代码的时间复杂度,首先需要知道递归调用的master公式。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> T ( N ) = a × T ( N b ) + O ( N d ) T(N) = a \times T(\frac{N}{b}) + O(N^d) </math>T(N)=a×T(bN)+O(Nd)

  • 当 <math xmlns="http://www.w3.org/1998/Math/MathML"> d < l o g b a d < log_b a </math>d<logba时,递归调用的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N l o g b a ) O(N^{log_b a}) </math>O(Nlogba)
  • 当 <math xmlns="http://www.w3.org/1998/Math/MathML"> d = l o g b a d = log_b a </math>d=logba 时,递归调用的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N d × l o g N ) O(N^d \times logN) </math>O(Nd×logN)
  • 当 <math xmlns="http://www.w3.org/1998/Math/MathML"> d < l o g b a d < log_b a </math>d<logba时,递归调用的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N d ) O(N^d) </math>O(Nd)

分析一下整个递归的调用过程,我们将整个数组均分成了两部分,分别调用递归,所以代入这个公式中就是
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> T ( N ) = 2 × T ( N 2 ) + m e r g e 过程的时间复杂度 T(N) = 2 \times T(\frac{N}{2}) + {merge过程的时间复杂度} </math>T(N)=2×T(2N)+merge过程的时间复杂度

在整个merge过程中,我们设置了两个指针p1p2,虽然merge方法中有三个while循环,但可以看到,每执行一次循环,p1p2指针一定会向后走一步,直到走到子数组的尽头,所以三个while循环最多能执行N次,N是数组的长度。所以整个merge过程的时间复杂度就是O(N),这样,整个递归过程的master公式就变成了
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> T ( N ) = 2 × T ( N 2 ) + O ( N ) T(N) = 2 \times T(\frac{N}{2}) + O(N) </math>T(N)=2×T(2N)+O(N)

a = 2, b = 2, d = 1, <math xmlns="http://www.w3.org/1998/Math/MathML"> l o g b a = d log_b a = d </math>logba=d,所以整个递归过程的时间复杂度就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N × l o g N ) O(N \times logN) </math>O(N×logN)

归并排序的非递归实现

递归版本的归并排序是通过将一个数组不断对半切分实现的,我们还可以使用非递归的方法实现。还是以上面的数组为例[5, 2, 3, 6, 4, 1],设置一个变量gap,初始值为1,然后遍历整个数组。首先遍历到的是0位置的51位置的2,我们把它分别看作是一个子数组,然后执行merge操作,这样这两个子数组就合并成了[2, 5],然后继续遍历,再将子数组[3][6]merge变成[3,6][4][1]merge变成[1, 4],这样经过第一次归并排序,数组就变成了[2, 5, 3, 6, 1, 4]

然后再将gap变量 <math xmlns="http://www.w3.org/1998/Math/MathML"> × 2 \times 2 </math>×2,此时gap = 2,再次遍历数组,首先将[2, 5][3, 6]分别看成一个子数组,执行merge操作变成[2, 3, 5, 6]。诶看出来了吗?这个gap变量的含义其实就是我们找的这个子数组的大小。继续遍历遇到[1, 4],此时数组中剩余变量不够,找不到可以merge的右侧部分,所以这次循环直接结束,这次循环之后,数组变成了[2, 3, 5, 6, 1, 4]

再将gap <math xmlns="http://www.w3.org/1998/Math/MathML"> × 2 \times 2 </math>×2 得出gap = 4,这次数组被分成了两部分,左侧是[2, 3, 5, 6],右侧是[1, 4],将这两部分merge之后得到[1, 2, 3, 4, 5, 6],循环结束。

此时再将gap <math xmlns="http://www.w3.org/1998/Math/MathML"> × 2 \times 2 </math>×2 得到gap = 8,此时的gap已经 <math xmlns="http://www.w3.org/1998/Math/MathML"> ≥ \ge </math>≥整个数组的长度了,说明整个数组都凑不够merge 的一部分,整个排序过程就可以结束了。

归并排序非递归版本的代码如下。

java 复制代码
public static void mergeSort2(int[] nums) {
    int n = nums.length;
    for (int gap=1; gap<n; gap*=2) {
        int left = 0;
        while (left < n) {
            int mid = Math.min(left + gap - 1, n-1);
            if (mid == n-1) {
                break;
            }
            int right = Math.min(mid + gap, n-1);

            merge(nums, left, mid, right);

            left = right + 1;
        }
    }
}

这个代码的时间复杂度也比较好估算,gap变量每次是以 * 2的速度接近n的,所以外层for循环会执行 <math xmlns="http://www.w3.org/1998/Math/MathML"> l o g N logN </math>logN次,内层循环的每次开销都在merge方法,while循环结束之后会将整个数组中的所有变量merge一遍,所以整个while循环的时间复杂度是O(N),整个算法的时间复杂度就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N × l o g N ) O(N \times logN) </math>O(N×logN)

leetcode LCR170 逆序对问题

归并排序是一种典型的基于分治的算法思想,利用归并排序的算法流程可以解决很多经典的问题,例如leetcode LCR170 交易逆序对的总数

在股票交易中,如果前一天的股价高于后一天的股价,则可以认为存在一个「交易逆序对」。请设计一个程序,输入一段时间内的股票交易记录 record,返回其中存在的「交易逆序对」总数。

示例 1:

输入:record = [9, 7, 5, 4, 6]

输出:8

解释:交易中的逆序对为 (9, 7), (9, 5), (9, 4), (9, 6), (7, 5), (7, 4), (7, 6), (5, 4)。

要求出数组中的逆序对,根本上是要将数组中的值做一个比较。在归并排序的整个流程中,merge其实就是一个比较的过程。

以题目中给的示例为例,record = [9, 7, 5, 4, 6],我们对这个数组做归并排序,首先left = 0, right = 4,求出mid = 2,这样原数组就被拆分为[9, 7, 5][4, 6]两个子数组,分别对这两个子数组调用递归使之变成[5, 7, 9][4, 6],按照归并排序的算法流程,下一步就是merge

设置两个指针,p1指向前半部分的5p2指向后半部分的4。当p1指向的元素比p2指向的元素大的时候,直接按照merge的流程拷贝元素,p2指针偏移,不产生逆序对。

p1指向的元素比p2指向的元素小的时候,按照merge的流程,把p1指向的元素拷贝到temp数组,同时p1指向的元素和数组后半部分中p2指针前面的所有元素都会组成逆序对,这个逆序对的数量等于的是p2 - (mid + 1)。针对图中的这个case,p1指向的5和右侧子数组p2前面的4就组成了一个逆序对。

继续merge的流程,p2指向的元素小于p1指向的元素,p2指针偏移,不产生逆序对。

此时,p2指针已经超出范围了,所以将p1指针的剩余元素全都拷贝到temp数组,同时产生逆序对。首先7和右侧的46产生两个逆序对。

p1指针继续右移,9和右侧的46也产生两个逆序对。

至此,本次merge流程全部结束,对应本次的逆序对的计算也就结束了,至于左侧[5, 7, 9]和右侧[4, 6]内部的逆序对,则在这两个子数组内部递归之后的merge流程去统计。这道题目的AC代码贴在下面,供大家参考。

java 复制代码
public int reversePairs(int[] record) {
    int n = record.length;
    if (n <= 1) {
        return 0;
    }
    return process(record, 0, n-1);
}

public int process(int[] record, int left, int right) {
    if (left == right) {
        return 0;
    }

    int mid = left + ((right - left) >> 1);
    return process(record, left, mid) + process(record, mid + 1, right) + merge(record, left, mid, right);
}

public int merge(int[] record, int left, int mid, int right) {
    int[] temp = new int[right - left + 1];

    int p1 = left;
    int p2 = mid + 1;

    int index = 0;

    int res = 0;
    while (p1 <= mid && p2 <= right) {
        if (record[p1] <= record[p2]) {
            temp[index++] = record[p1++];
            res += p2 - mid - 1;
        } else {
            temp[index++] = record[p2++];
        }
    }

    while (p1 <= mid) {
        temp[index++] = record[p1++];
        res += p2 - mid - 1;
    }

    while (p2 <= right) {
        temp[index++] = record[p2++];
    }

    for (int i=0; i<temp.length; i++) {
        record[left + i] = temp[i];
    }

    return res;
}

当然,看问题的角度不同,也会带来题目不同的解法。我们上面介绍的解法是聚焦于左侧数组。如果我们关注的点是右侧数组也是可以的。还是针对上面的这次merge流程,首先p2指向的4小于p1指向的5,拷贝右侧的4p2指针偏移这肯定是不变的,如果认为此时产生了逆序对,那么这个逆序对的数量就是左侧数组中p1p1右侧的所有元素,即此时,右侧的4和左侧的579都会组成逆序对。

下一步左侧指针在偏移时,不再产生逆序对,否则(5, 4)这个逆序对会被重复算入,以此类推,也能得到最终的结果。这种解法的代码也贴在下面。

java 复制代码
class Solution {
    public int reversePairs(int[] record) {
        int n = record.length;
        if (n <= 1) {
            return 0;
        }
        return process(record, 0, n-1);
    }

    public int process(int[] record, int left, int right) {
        if (left == right) {
            return 0;
        }

        int mid = left + ((right - left) >> 1);
        return process(record, left, mid) + process(record, mid + 1, right) + merge(record, left, mid, right);
    }

    public int merge(int[] record, int left, int mid, int right) {
        int[] temp = new int[right - left + 1];

        int p1 = left;
        int p2 = mid + 1;

        int index = 0;

        int res = 0;
        while (p1 <= mid && p2 <= right) {
            if (record[p1] <= record[p2]) {
                temp[index++] = record[p1++];
            } else {
                res += mid - p1 + 1;
                temp[index++] = record[p2++];
            }
        }

        while (p1 <= mid) {
            temp[index++] = record[p1++];
        }

        while (p2 <= right) {
            temp[index++] = record[p2++];
        }

        for (int i=0; i<temp.length; i++) {
            record[left + i] = temp[i];
        }

        return res;
    }
}

归并排序的其他应用

以上就是归并排序的相关内容,除了逆序对问题,我们还可以使用归并排序的算法思想,解决经典的"小和问题"。在一个数组中,每一个数左边比当前数小的数累加起来,就叫做这个数组的小和。那么如何通过归并排序来计算一个数组的小和呢?大家快一起来试一试吧。

【问题描述】 在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和,求一个数组的小和

【示例1】

输入: [1, 3, 4, 2, 5]

输出: 16

解释

  • 1左侧没有比1小的数;
  • 3左侧比3小的数字:1;
  • 4左侧比4小的数字:1, 3;
  • 2左侧比2小的数字:1;
  • 5左侧比5小的数字:1, 3, 4, 2;

所以该数组的小和为:1 + 1 + 3 + 1 + 1 + 3 + 4 + 2 = 16

如果觉得这篇文章对你有帮助的话,请帮我点一个免费的赞吧,这对我非常重要,谢谢!

欢迎关注微信公众号《程序员冻豆腐》,里面有我所有的首发文章

相关推荐
向宇it10 分钟前
【unity小技巧】Unity 四叉树算法实现空间分割、物体存储并进行查询和碰撞检测
开发语言·算法·游戏·unity·游戏引擎
无限大.10 分钟前
冒泡排序(结合动画进行可视化分析)
算法·排序算法
走向自由30 分钟前
Leetcode 最长回文子串
数据结构·算法·leetcode·回文·最长回文
nuo53420244 分钟前
The 2024 ICPC Kunming Invitational Contest
c语言·数据结构·c++·算法
luckilyil1 小时前
Leetcode 每日一题 11. 盛最多水的容器
算法·leetcode
A.A呐1 小时前
LeetCode 1658.将x减到0的最小操作数
算法·leetcode
hn小菜鸡1 小时前
LeetCode 144.二叉树的前序遍历
算法·leetcode·职场和发展
rubyw1 小时前
如何选择聚类算法、回归算法、分类算法?
算法·机器学习·分类·数据挖掘·回归·聚类
编程探索者小陈1 小时前
【优先算法】专题——双指针
数据结构·算法·leetcode
Sunyanhui12 小时前
力扣 三数之和-15
数据结构·算法·leetcode