排序是算法中一个绕不开的话题。基础算法中一共有十种排序算法,就是我们常说的十大排序,其中归并排序、快排、堆排这是三种 <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]
,这样流程中"拆分"和"排序"就完成了。
下一步是将这两个有序的子数组合并成一个有序数组,我们设置两个指针p1
和p2
,分别指向两个数组中的第一个元素,再设置一个辅助数组temp
。
我们将p1
和p2
指向的元素做对比,哪个小就将这个元素拷贝到temp数组中,并将指针向右移动一位。在图中的这个示例中,p2
指针指向的元素比较小,将元素拷贝,并将指针右移。
此时,p1
指针指向2
,p2
指针指向4
,p1
指针指向的元素比较小,所以拷贝到temp数组中,并将指针向右移动1位。
p1
指针指向3
,p2
指针指向4
,依然需要拷贝p1
指向的元素,并移动p1
指针。
p2
指向4
,p1
指向5
,所以拷贝p2
指向的元素,并移动p2
指针。
p1
指向5
,p2
指向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过程中,我们设置了两个指针p1
和p2
,虽然merge方法中有三个while
循环,但可以看到,每执行一次循环,p1
或p2
指针一定会向后走一步,直到走到子数组的尽头,所以三个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
位置的5
和1
位置的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
指向前半部分的5
,p2
指向后半部分的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
和右侧的4
,6
产生两个逆序对。
p1
指针继续右移,9
和右侧的4
,6
也产生两个逆序对。
至此,本次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
,拷贝右侧的4
,p2
指针偏移这肯定是不变的,如果认为此时产生了逆序对,那么这个逆序对的数量就是左侧数组中p1
和p1
右侧的所有元素,即此时,右侧的4
和左侧的5
,7
,9
都会组成逆序对。
下一步左侧指针在偏移时,不再产生逆序对,否则(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
如果觉得这篇文章对你有帮助的话,请帮我点一个免费的赞吧,这对我非常重要,谢谢!
欢迎关注微信公众号《程序员冻豆腐》,里面有我所有的首发文章