前言
前面的两篇文章,给大家具体的讲述了快排和堆,堆排以及加强堆的实现过程。基于比较排序的排序算法中,最后再向大家介绍一下归并排序,之所以放到最后,并不是因为归并排序比快排、堆排复杂,恰恰相反,归并排序从理解层面上和前面介绍两种排序算法相比,应该是最简单的,归并排序重要的是背后的分治思想,解释过来就是分而治之。
当下流行的微服务、分布式以及各种技术架构,背后或多或少都有着分治的理念,也是希望大家重视这种思想,培养整体思维,遇到一个大问题,我们可以分解成一个个小问题去解决。好了,废话不多说,正式开始归并排序的旅途。
原理
前面说了归并排序的流程理解起来很简单,怎么个简单法呢?还是大家来个例子,给你一个数组 arr,给它排有序。怎么搞呢?整体流程很简单:先把数组对半砍分成左右两半,想办法让左半部分排有序,右半部分排有序,最后把左半部分和右半部分合并起来做到整体有序即可。怎么左半部分排有序呢?哎,是不是递归就行了。
我们先不看递归,不过这里既然提到了递归,也是想给大家一个提醒,对于递归函数,我们要具备黑盒思维,要先想明白递归的含义,这很重要!!!递归函数的含义一旦定了,递归的base case就很好想,剩下的就是子过程的调用流程,一个递归的要素也就是这几点。这里先假设有这么一个黑盒结构,把数组的左半部分和右半部分丢进去,他就会排好序。
我们先看排好序的左组和右组合并过程,其实要点也很简单,准备一个辅助数组,再准备两个指针,分别卡着左组和右组的起始位置,开始遍历,如果左组的数小于等于右组的数,copy左组的数,否则copy右组的数,具体代码如下:
// 合并左组和右组,做到整体有序
public static void merge(int[] arr, int L, int M, int R) {
// 准备一个辅助数组
int[] help = new int[R - L + 1];
int index = 0;
// 左组的起始位置
int p1 = L;
// 右组的起始位置
int p2 = M + 1;
// 左组 右组合并
while (p1 <= M && p2 <= R) {
// 规定左组的数小于等于右组的数,copy左组的数
help[index++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
}
// 下面两个循环只会发生一个!
// 从第一个while跳出的情况:p1先越界或p2先越界
// p2先越界,左组还有数
while (p1 <= M) {
help[index++] = arr[p1++];
}
// p1先越界,右组还有数
while (p2 <= R) {
help[index++] = arr[p2++];
}
// 把辅助数组copy回原数组
for (int i = 0; i < help.length; i++) {
arr[L + i] = help[i];
}
}
有了合并的过程,我们再来解决递归的问题,根据我们的需求,我们的递归得能让左部分排有序、右部分排有序。综和上述情况,不妨这么定义递归,给定一个数组,再给定L,R两个参数表示数组的范围,具体含义:数组L到R范围上的数排有序,具体代码如下:
// arr[L...R]上排有序
public static void process(int[] arr, int L, int R) {
// base case
if (L == R) {
return;
}
// 求中点位置
int mid = L + ((R - L) >> 1);
// 左部分排有序
process(arr, L, mid);
// 右部分排有序
process(arr, mid + 1, R);
// 左、右部分合并
merge(arr, L, mid, R);
}
public static void mergeSort1(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
process(arr, 0, arr.length - 1);
}
没错,就是这么简单,这里为了帮助大家理解,以arr[6,3,7,9,0,4,1,8]为例,体会一下整个递归的运行过程,详情如下图所示:
以图为例,我们这里讨论一下归并排序的时间复杂度吧,我们再看看上面递归的流程图:哎,像不像一颗二叉树?对于算法而言,当你面对新的题目,要具备一定的敏感度,这点很重要,它能很快给我们一种思维上的启发。回到正题,数据量为N,挂到树上,树高是不是为logN?每次分组之后是不是随后都会伴随一次左组右组合并的行为,这里注意:在合并的过程中,左组的指针是不会回退的,右组的指针也是不会回退的,所以合并的过程是不是O(N)?所有流程整合起来,归并排序的时间复杂度就是O(N * logN)。
最后我们再来看看归并排序迭代版本的实现:准备一个变量mergeSize,称之为步长,用来划分左组和右组,一个左组配一个右组,怎么理解呢?还是以数组arr[6,3,7,9,0,4,1,8]为例,当mergeSize=1,arr就可以划分为{6,3},{7,9},{0,4},{1,8}四个组,在{6,3}这个组中:{6}是左组,{3}是右组;在{7,9}这个组中{7}是左组,{9}是右组,就是说mergeSize的值就代表着当前左组和右组的长度,随后一个左组和一个右组会进行合并操作变成一个有序的整体,也就相当于例子中:mergeSize=1时,左组{6}和右组{3}合并后会变成{3,6}。具体怎么使用,代码如下:
// 非递归方式实现
public static void mergerSort2(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int N = arr.length;
// 步长
int mergeSize = 1;
while (mergeSize < N) {
// 当前左组的位置
int L = 0;
while (L < N) {
if (mergeSize > N - L) break;
// 当前左组的最后位置:中心点
int M = L + mergeSize - 1;
// 当前右组的最后位置
// 边界条件:右组不够步长时,取原数组最后位置
int R = M + Math.min(mergeSize, N - M - 1);
merge(arr, L, M, R);
// 当前组合并完成之后,来到下个左组的起始位置
L = R + 1;
}
// 1.防止mergeSize整型溢出的情况
// 2.mergeSize > N/2:意味着当前的左组和右组合起来的总体长度已经超过了原数组长度
if (mergeSize > N / 2) {
break;
}
// 不要忘记mergeSize左移更新
mergeSize <<= 1;
}
}