归并排序 是一种常见的排序算法,它采用 分治法 ( 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] 。
4 和 1 左侧没有比其小的数;
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组)
0 和 2 右侧没有。
所以,二倍数对共有 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 的题目。