归并排序算法(以int型数组为例)

核心思想

归并排序的构思朴实却亦深刻,作为一个算法既古老又仍不失生命力。在排序算法的发展历史上,归并排序具有特殊的低位,它是第一个可以在最坏环境情况下依然保持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函数的设计,使之耗费的额外空间尽量的小
  最简单的办法就是每次都开辟一个可以容纳两个子序列元素数量的空间,这是一个非常简明的方法,但是这种办法的空间效率我们是无法忍受的,稍加观察,我们可以发现完全可以利用未排序的序列本身来提升算法的空间效率。

示例

  比如我们需要归并这两个子序列,实际上我们仅需要拷贝前一个子序列的元素即可,下意识地我们会担心是否会出现未排到当前元素便覆盖的情况,然而稍加思考就会发现这种担心是不必的,上述两个子序列的位置是我们认为的最糟糕的情况了,因为排在最前面的均是后一子序列的元素。
示例

  可能这样的情况才是我们更担心的,实际上我们可以假定前一个子序列在拷贝过后成为了一连串的坑,这个坑中的任何值都是没有价值,随时可以被覆盖掉,于是我们拿拷贝过来的序列与后一个序列进行归并,这可以等效的看作一连串的填坑操作,当然,就如同拆西墙补东墙的道理,填上一个坑相应的就会又多出一个坑,不难发现,这些所谓的坑,应都是连在一起的,原因也很简单,子序列有序,拿来填坑的元素必定是从前到后的选取。

数组a

这是归并动作的第一步,算法找出了两个子序列中最小的那个元素 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 ; //删除我们自己开辟的数组 
}

和我们一开始的策略相比,这种方法就高明了很多,我们仅需要开辟前一个子序列长度的数组即可,而不是整个序列长度的空间。
  更进一步,再来考虑一下,是否真的需要如上面代码一样,需要同时兼顾两个子序列的剩余元素吗?
  我们来看下面的情况:

数组a
1
2
3
4

  我们发现了一个有趣的现象,其实我们只要将拿走的还回去,便就没有漏洞了,原序列中留下坑,其实是我们擅自摘走了若干而留下的,拷贝的序列中还存有几个有效元素,原序列中就剩下几个坑,当拷贝序列中的有效元素被还光时,原序列的坑也就完全补上了。
  于是我们其实仅需要关注前一个子序列的有效元素数即可,一旦拷贝序列中没有有效序列,后面的操作也只是无用的遍历了。
  稍作优化,我们可以得到如下代码:

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;
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,258评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,335评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,225评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,126评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,140评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,098评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,018评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,857评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,298评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,518评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,400评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,993评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,638评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,661评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容

  • 概述 排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部...
    蚁前阅读 5,183评论 0 52
  • 归并排序和快速排序都用到了分治思想,非常巧妙。我们可以借鉴这个思想,来解决非排序的问题。 归并排序 归并排序的核心...
    被吹落的风阅读 1,330评论 0 3
  • 一. 写在前面 要学习算法,“排序”是一个回避不了的重要话题,在分析完并查集算法和常用数据结构之后,今天我们终于可...
    Leesper阅读 2,531评论 0 40
  • 概述 排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部...
    闲云清烟阅读 757评论 0 6
  • 概述 排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部...
    printf200阅读 768评论 0 0