核心思想
归并排序的构思朴实却亦深刻,作为一个算法既古老又仍不失生命力。在排序算法的发展历史上,归并排序具有特殊的低位,它是第一个可以在最坏环境情况下依然保持o(nlogn)运行时间的确定性排序算法。
归并算法的核心思想是分而治之,与快速排序的核心思想类似,但在它们的侧重点各不相同,快速排序算法重点在于分,而归并排序的重点在于合!!
关于快速排序算法的介绍可以参考往期文章:快速排序算法以int型数组为例
首先,我们从最底层的单个元素的序列开始思考,单元素序列本身就是有序的,再往上延伸一步,两个单个元素的序列如何组成一个含有两个元素的有序序列,答案是比较这两个元素大小,然后按先后排列即可,再往上,两条含有两元素的有序序列如何合并成一条含有四元素的有序序列??遍历两个待合并序列,并依次比较大小,将最后的排列结果存放。
我们发现需要合并的两个序列存在着一种性质,就是他们本身就是有序的,这使得将如此的两个序列合并的操作所付出的代价远小于无序的两个序列合并,合并的方式便是上述的这些步骤。
上述性质为我们提供了一个思路,将一个待排序列分割成两个待排子序列,循环往复,直到分割到单个元素的序列,此时正是天然的有序序列,然后从底至上,一步一步地合并,直到合并为原序列的规模,排序便完成。
代码分析
typedef int Rank;
void mergeSort(int *a , Rank lo = 0 , Rank hi = 10){ // 0 <= lo <= hi <= a.size
if(hi - lo < 2) return ; //如果是单元素区间
int mi = (lo + hi) >> 1; // 找中间值,用于分
mergeSort(a,lo,mi); mergeSort(a,mi,hi); //继续分为各个子序列
merge(a,lo,mi,hi); //分别对前后端序列排序,并合并
}
上述代码是算法主体部分,可以与快速排序的主体部分比较一下,快排的耗时最大部分是来自partion函数(分),而归并排序耗时最大部分是merge函数(合)。
由此可见,算法的最主要的就是关于merge函数的设计,使之耗费的额外空间尽量的小。
最简单的办法就是每次都开辟一个可以容纳两个子序列元素数量的空间,这是一个非常简明的方法,但是这种办法的空间效率我们是无法忍受的,稍加观察,我们可以发现完全可以利用未排序的序列本身来提升算法的空间效率。
比如我们需要归并这两个子序列,实际上我们仅需要拷贝前一个子序列的元素即可,下意识地我们会担心是否会出现未排到当前元素便覆盖的情况,然而稍加思考就会发现这种担心是不必的,上述两个子序列的位置是我们认为的最糟糕的情况了,因为排在最前面的均是后一子序列的元素。
可能这样的情况才是我们更担心的,实际上我们可以假定前一个子序列在拷贝过后成为了一连串的坑,这个坑中的任何值都是没有价值,随时可以被覆盖掉,于是我们拿拷贝过来的序列与后一个序列进行归并,这可以等效的看作一连串的填坑操作,当然,就如同拆西墙补东墙的道理,填上一个坑相应的就会又多出一个坑,不难发现,这些所谓的坑,应都是连在一起的,原因也很简单,子序列有序,拿来填坑的元素必定是从前到后的选取。
这是归并动作的第一步,算法找出了两个子序列中最小的那个元素 1 ,然后将它填到了相应位置,此时a[3]的值仍为1,但已经不重要了,它现在成为了一个坑。
void merge(int *t ,Rank lo , Rank mi , Rank hi){
int *A = t+lo ; //指针A指向我们需要操作的子序列的最左端
int lb = mi - lo; //计算前子序列元素个数
int B[lb];
for(Rank i = 0 ; i < lb ; i++)B[i] = A[i];//复制前子序列a[lo,mi)到B
int lc = hi - mi; //计算后子序列元素个数
int *C = t + mi; //指针C指向后序列,C[0,lc) = t[mi,hi)
for(Rank i = 0 , j = 0 , k = 0 ; (j < lb) || (k < lc) ; ){
//i遍历A ,j遍历B ,k遍历C
//在两个子序列都没有遍历完的情况下,选择二者最小的,一旦有一条子序列
//遍历完,就将未遍历完的那条子序列直接接上
if((j < lb) && (!(k < lc) || B[j] <= C[k])) A[i++] = B[j++];
if((k < lc) && (!(j < lb) || C[k] < B[j])) A[i++] = C[k++];
}
delete[] B ; //删除我们自己开辟的数组
}
和我们一开始的策略相比,这种方法就高明了很多,我们仅需要开辟前一个子序列长度的数组即可,而不是整个序列长度的空间。
更进一步,再来考虑一下,是否真的需要如上面代码一样,需要同时兼顾两个子序列的剩余元素吗?
我们来看下面的情况:
我们发现了一个有趣的现象,其实我们只要将拿走的还回去,便就没有漏洞了,原序列中留下坑,其实是我们擅自摘走了若干而留下的,拷贝的序列中还存有几个有效元素,原序列中就剩下几个坑,当拷贝序列中的有效元素被还光时,原序列的坑也就完全补上了。
于是我们其实仅需要关注前一个子序列的有效元素数即可,一旦拷贝序列中没有有效序列,后面的操作也只是无用的遍历了。
稍作优化,我们可以得到如下代码:
void merge(int *t ,Rank lo , Rank mi , Rank hi){
int *A = t+lo ; //指针A指向我们需要操作的子序列的最左端
int lb = mi - lo; //计算前子序列元素个数
int B[lb];
for(Rank i = 0 ; i < lb ; i++)B[i] = A[i];//复制前子序列a[lo,mi)到B
int lc = hi - mi; //计算后子序列元素个数
int *C = t + mi; //指针C指向后序列,C[0,lc) = t[mi,hi)
for(Rank i = 0 , j = 0 , k = 0 ; (j < lb) || (k < lc) ; ){
//i遍历A ,j遍历B ,k遍历C
//在两个子序列都没有遍历完的情况下,选择二者最小的,一旦有一条子序列
//遍历完,就将未遍历完的那条子序列直接接上
if((j < lb) && (!(k < lc) || B[j] <= C[k])) A[i++] = B[j++];
if(k < lc){
if(!(j < lb)) break;
else if(C[k] < B[j]) A[i++] = C[k++];
}
}
delete[] B ; //删除我们自己开辟的数组
}
当然,这个小转变其实优化效果微乎其微,反倒我们更倾向于上一个更简明统一的策略。
复杂度
二路归并算法(merge)的效率稳定在o(n) , 需要merge log(2)n层,所以算法时间复杂度就是我们提到的 o(nlogn)。
详细的证明请自行翻阅相关书籍!
完整代码及测试
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
typedef int Rank;
void merge(int *t ,Rank lo , Rank mi , Rank hi){
int *A = t+lo ; //指针A指向我们需要操作的子序列的最左端
int lb = mi - lo; //计算前子序列元素个数
int B[lb];
for(Rank i = 0 ; i < lb ; i++)B[i] = A[i];//复制前子序列a[lo,mi)到B
int lc = hi - mi; //计算后子序列元素个数
int *C = t + mi; //指针C指向后序列,C[0,lc) = t[mi,hi)
for(Rank i = 0 , j = 0 , k = 0 ; (j < lb) || (k < lc) ; ){
//i遍历A ,j遍历B ,k遍历C
//在两个子序列都没有遍历完的情况下,选择二者最小的,一旦有一条子序列
//遍历完,就将未遍历完的那条子序列直接接上
if((j < lb) && (!(k < lc) || B[j] <= C[k])) A[i++] = B[j++];
if((k < lc) && (!(j < lb) || C[k] < B[j])) A[i++] = C[k++];
// if(k < lc){
// if(!(j < lb)) break;
// else if(C[k] < B[j]) A[i++] = C[k++];
// }
}
delete[] B ; //删除我们自己开辟的数组
}
void mergeSort(int *a , Rank lo = 0 , Rank hi = 10){ // 0 <= lo <= hi <= a.size
if(hi - lo < 2) return ; //如果是单元素区间
int mi = (lo + hi) >> 1; // 找中间值,用于分
mergeSort(a,lo,mi); mergeSort(a,mi,hi); //继续分为各个子序列
merge(a,lo,mi,hi); //分别对前后端序列排序,并合并
}
void getArray(int *a){
srand((unsigned)time(NULL));
for(int i = 0 ; i < 10 ; i++){
a[i] = rand()%100 + 1;
}
}
void printArray(int *a){
int i = 0 ;
while(i < 10){
printf("%d ",a[i]);
i++;
}
printf("\n");
}
int main(int argc, char const *argv[])
{
int a[10];
getArray(a);
printf("未排序:\n");
printArray(a);
printf("已排序:\n");
mergeSort(a);
printArray(a);
return 0;
}