归并排序,也有“套路”?

129 阅读7分钟

归并排序 是一种常见的排序算法,它采用 分治法 ( Divide and Conquer ) 的思想,将待排序的数组分成两个相等的子数组,然后对每个子数组进行递归排序,最后将排好序的子数组合并成一个有序数组。

其基本流程如下:

分割 ( Divide ):将待排序的数组分成两个相等的子数组,直到子数组的长度为 1 (即无法再分割为止)。

排序 ( Conquer ):通过不断将子数组分割成更小的子数组,递归地对每个子数组进行排序。

合并 ( Merge ):将已排序的子数组合并成一个新的有序数组。在合并过程中,比较两个子数组的元素,将它们按顺序合并到一个新的数组中。

整个过程 递归 执行,直到整个数组排序完成。归并排序的关键在于 分割合并 的操作,使每个子问题都相对较小且容易解决。

相较于冒泡排序、插入排序、选择排序来说,归并排序的时间复杂度降低为 O(n log n)。其原因在于: O(n^2) 的三种排序每次仅有一个关键字得以确定,大大浪费了比较过程。

而在归并排序中,上一次的排序结果得到了充分的利用,使得下一次的排序能够在上次排序的基础上进行,减少了重新对比关键字排序的时间。


经典递归实现

先来看下最经典的 归并排序递归 版本代码:

public static void mergeSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    process(arr, 0, arr.length - 1);
}

public static void process(int[] arr, int l, int r) {
    if (l == r) {
        return;
    }
    int m = l + ((r - l) >> 1);
    process(arr, l, m);
    process(arr, m + 1, r);
    merge(arr, l, m, r);
}

public static void merge(int[] arr, int l, int m, int r) {
    int[] help = new int[r - l + 1];
    int p1 = l;
    int p2 = m + 1;
    int i = 0;
    while (p1 <= m && p2 <= r) {
        help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
    }
    while (p1 <= m) {
        help[i++] = arr[p1++];
    }
    while (p2 <= r) {
        help[i++] = arr[p2++];
    }
    for (i = 0; i < help.length; i++) {
        arr[l + i] = help[i];
    }
}

当数组为 null 或长度为 1 时不需要排序,直接返回。

在执行归并的 process 方法中,base case 为左边界 l == 右边界 r,说明数组只有一个元素,无需排序,直接返回。否则,计算中间位置 m,然后递归地对左右两半进行归并排序,最后调用 merge 方法合并两个有序部分。

merge 方法首先创建一个辅助数组 help,使用两个指针 p1、p2 分别指向左右两个有序部分的起始位置。通过比较指针位置上的元素,将较小的元素放入辅助数组中,指针向右移动。最后,将 help 数组中的元素复制回原数组中。

注意:while (p1 <= m)while (p2 <= r) 最多只会执行一个。


学会归并排序的 经典递归实现 后,我们通过几道题目对归并排序进行 改写,体会如何“套路”解题。

套路解题-1-小和问题

给定一个数组,若数组中一个数左边含有比它小的数,则称为小和,求数组中所有小和的累加结果。

例如:给定数组 arr=[4, 1, 2, 3, 5] 。

41 左侧没有比其小的数;

2 左侧有 1;

3 左侧有 2 和 1;

5 左侧有 3,2,1,4。

所以,小和 sum = 1+2+1+3+2+1+4=14。

public static int smallSum(int[] arr) {
    if (arr == null || arr.length < 2) {
        return 0;
    }
    return process(arr, 0, arr.length - 1);
}

public static int process(int[] arr, int l, int r) {
    if (l == r) {
        return 0;
    }
    int m = l + ((r - l) >> 1);
    return process(arr, l, m) + process(arr, m + 1, r) + merge(arr, l, m, r);
}

public static int merge(int[] arr, int l, int m, int r) {
    int[] help = new int[r - l + 1];
    int i = 0;
    int p1 = l;
    int p2 = m + 1;
    int sum = 0;
    while (p1 <= m && p2 <= r) {
        sum += arr[p1] < arr[p2] ? arr[p1] * (r - p2 + 1) : 0;
        help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
    }
    while (p1 <= m) {
        help[i++] = arr[p1++];
    }
    while (p2 <= r) {
        help[i++] = arr[p2++];
    }
    for (i = 0; i < help.length; i++) {
        arr[l + i] = help[i];
    }
    return sum;
}

思路:

转换思路,要想寻找 左侧比该数小 的数,即找 右侧比该数大 的数。

思考归并排序的特点发现,merge 函数是将两个 已经有序 的数组进行合并。此时,我们就可以利用两个子数组 已经有序 的特点,实现寻找右侧大的数。

代码解释:

与归并排序的代码相同,p1、p2 指针分别指向了两个子数组的最左侧。固定左侧的 p1 指针不动,与右侧的 p2 指针指向的数进行比较。(注意:两个子数组都是 升序 排列)

  • arr[p1] < arr[p2] 时,p2 所指及右侧的数一定均比 arr[p1] 大。因此,就会产生 (r - p2 + 1)arr[p1] 的小和。即:(r - p2 + 1)*arr[p1]
  • arr[p1] >= arr[p2] 时,不知道 p2 所指及右侧的数有多少个比 arr[p1] 大。因此,此时不会产生 arr[p1] 的小和。 sum 不进行累计。
  • 最终所有小和 = 左侧 子数组小和 + 右侧 子数组小和 + 二者合并 时产生的小和。即:process(arr, l, m) + process(arr, m + 1, r) + merge(arr, l, m, r);

套路解题-2-两倍数对

给定一个数组,若数组中每个数右边含有 2 倍后依然 小于 该数的组数。

例如:给定数组 arr=[3, 1, 7, 0, 2] 。

3 右侧:1 * 2 < 3 、0 * 2 < 3 。(2组)

1 右侧:0 * 2 < 1 。 (1组)

7 右侧:0 * 2 < 7 、2 * 2 < 7 。(2组)

02 右侧没有。

所以,二倍数对共有 2+1+2=5 组。

public static int biggerTwice(int[] arr) {
    if (arr == null || arr.length < 2) {
        return 0;
    }
    return process(arr, 0, arr.length - 1);
}
public static int process(int[] arr, int l, int r) {
    if (l == r) {
        return 0;
    }
    int mid = l + ((r - l) >> 1);
    return process(arr, l, mid) + process(arr, mid + 1, r) + merge(arr, l, mid, r);
}
public static int merge(int[] arr, int l, int m, int r) {
    int sum = 0;
    int winR = m + 1;
    for (int i = l; i <= m; i++) {
        while (winR <= r && arr[i] > arr[winR] * 2) {
            winR++;
        }
        sum += winR - m - 1;
    }

    int[] help = new int[r - l + 1];
    int i = 0;
    int p1 = l;
    int p2 = m + 1;
    while (p1 <= m && p2 <= r) {
        help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
    }
    while (p1 <= m) {
        help[i++] = arr[p1++];
    }
    while (p2 <= r) {
        help[i++] = arr[p2++];
    }
    for (i = 0; i < help.length; i++) {
        arr[l + i] = help[i];
    }
    return sum;
}

思路:

与上一道题类似,本题寻找右侧“小”的数。由于两个子数组均 升序有序,因此,若右侧的 winR 指针所指向的数字 2 倍小于左侧 i 指向的数值,指针 winR 继续向右移动,直到找到不符合题意的最左侧下标停止。

即,右子数组的左侧(蓝色部分)所有数字一定符合题意(蓝色部分所有数字 * 2 < 粉色数字)。算出每一个数量并求和即为所求。

代码解释:

本题代码的关键是求出右侧数组中有多少数符合当前 i 指针指向数字的题意。

winR 代表不符合题意的最左侧下标,因此,初始值设置为:winR = m + 1(右侧子数组的第一个下标) ,每一轮循环 sum 累加的是当前蓝色部分的长度,即: winR - m - 1

左侧所有数字遍历结束后,就求出了右侧有多少个符合左侧数字要求的数字。

因此,process 函数的返回值为,左侧 子数组符合要求的数字总和 + 右侧 子数组符合要求的数字总和 + 二者合并 时产生的符合要求的数字总和。即:process(arr, l, m) + process(arr, m + 1, r) + merge(arr, l, m, r);


通过这两道题的分析,相信大家对归并排序有了更深的理解。

只要题目可以归结为:寻找一个元素左侧或右侧 所有符合要求 的元素个数,都可以考虑使用 归并排序

下篇文章我们继续用这个思路解决两道 LeetCode 的题目。