数据结构与算法-归并排序

先来举一个例子。你们知道高考一本、二本、专科分数线是如何划分出来的吗?

简单地说,如果各高校本科专业在某省高三理科学生中计划招收1万名,那么将全省参加高考的理科学生分数倒排序,第1万名的总分数就是当年本科生的分数线(现实可能会比这复杂,这里简化之)。也就是说,即使你是你们班级第一、甚至年级第一名,如果你没有上分数线,则说明你的成绩排不到全省前1万名,你也就基本失去了当年上本科的机会了。

换句话说,所谓的全省排名,其实也就是每个市、每个县、每个学校、每个班级的排名合并后再排名得到的。注意我这里用到了合并一词。

我们要比较两个学生的成绩高低是很容易的,比如甲比乙分数低,丙比丁分数低。那么我们也就可以很容易得到甲乙丙丁合并后的成绩排名,同样的,戊己庚辛的排名也容易得到,由于他们两组分别有序了,把他们八个学生成绩合并有序也是很容易做到的了,继续下去……最终完成全省学生的成绩排名,此时高考状元也就诞生了。

为了更清晰地说清楚这里的思想,大家来看下图所示,我们将本是无序的数组序列{16,7,13,10,9,15,3,2,5,8,12,1,11,4,6,14},通过两两合并排序后再合并,最终获得了一个有序的数组。注意仔细观察它的形状,你会发现,它像极了一棵倒置的完全二叉树,通常涉及到完全二叉树结构的排序算法,效率一般都不低的——这就是我们要讲的归并排序法。

image-20200521105721405

归并排序算法

“归并”一词的中文含义就是合并、并入的意思,而在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。

归并排序(Merging Sort)就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到|n/2|(|x|表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并,……,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

好了,有了对归并排序的初步认识后,我们来看代码。

/* 对顺序表L作归并排序 */
void MergeSort(SqList *L)
{
    MSort(L->r, L->r, 1, L->length);
}

一句代码,别奇怪,它只是调用了另一个函数而已。为了与前面的排序算法统一,我们用了同样的参数定义SqList *L,由于我们要讲解的归并排序实现需要用到递归调用,因此我们外封装了一个函数。假设现在要对数组{50,10,90,30,70,40,80,60,20}进行排序,L.length=9,我现来看看MSort的实现。

/* 将SR[s..t]归并排序为TR1[s..t] */
void MSort(int SR[], int TR1[], int s, int t)
{
    int m;
    int TR2[MAXSIZE + 1];
    if (s == t)
        TR1[s] = SR[s];
    else
    {
        /* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
        m = (s + t) / 2;             
        /* 递归将SR[s..m]归并为有序的TR2[s..m] */
        MSort(SR, TR2, s, m);        
        /* 递归将SR[m+1..t]归并为有序TR2[m+1..t] */
        MSort(SR, TR2, m + 1, t);   
        /* 将TR2[s..m]和TR2[m+1..t] */
        /* 归并到TR1[s..t] */
        Merge(TR2,TR1, s, m, t);     
    }
}

1.MSort被调用时,SR与TR1都是{50,10,90,30,70,40,80,60,20},s=1,t=9,最终我们的目的就是要将TR1中的数组排好顺序。

2.第5行,显然s不等于t,执行第8~13行语句块。

3.第9行,m=(1+9)/2=5。m就是序列的正中间下标。

4.此时第10行,调用“MSort(SR,TR2,1,5);”的目标就是将数组SR中的第1~5的关键字归并到有序的TR2(调用前TR2为空数组),第11行,调用“MSort(SR,TR2,6,9);”的目标就是将数组SR中的第6~9的关键字归并到有序的TR2。也就是说,在调用这两句代码之前,代码已经准备将数组分成了两组了,如下图所示。

image-20200521110117666

5.第12行,函数Merge的代码细节一会再讲,调用“Merge(TR2,TR1,1,5,9);”的目标其实就是将第10和11行代码获得的数组TR2(注意它是下标为1~5和6~9的关键字分别有序)归并为TR1,此时相当于整个排序就已经完成了,如下图所示。

image-20200521110146253

6.再来看第10行递归调用进去后,s=1,t=5,m=(1+5)/2=3。此时相当于将5个记录拆分为三个和两个。继续递归进去,直到细分为一个记录填入TR2,此时s与t相等,递归返回,如下图的左图所示。每次递归返回后都会执行当前递归函数的第12行,将TR2归并到TR1中,如下图的右图所示,最终使得当前序列有序。

image-20200521110228820

7.同样的第11行也是类似方式,如下图所示。

image-20200521110305941

8.此时也就是刚才所讲的最后一次执行第12行代码,将{10,30,50,70,90}与{20,40,60,80}归并为最终有序的序列。

可以说,如果对递归函数的运行方式理解比较透的话,MSort函数还是很好理解的。我们来看看整个数据变换示意图,如下图所示。

image-20200521110333586

现在我们来看看Merge函数的代码是如何实现的。

/* 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n] */
void Merge(int SR[], int TR[], int i, int m, int n)
{
    int j, k, l;
        /* 将SR中记录由小到大归并入TR */
    for (j = m + 1, k = i; i <= m && j <= n; k++)    
    {
        if (SR[i] < SR[j])
            TR[k] = SR[i++];
        else
            TR[k] = SR[j++];
    }
    if (i <= m)
    {
        for (l = 0; l <= m - i; l++)
            /* 将剩余的SR[i..m]复制到TR */
            TR[k + l]=SR[i + l];                     
    }
    if (j<=n)
    {
        for (l = 0; l <= n - j; l++)
            /* 将剩余的SR[j..n]复制到TR */
            TR[k + l] = SR[j + l];                   
    }
}

1.假设我们此时调用的Merge就是将{10,30,50,70,90}与{20,40,60,80}归并为最终有序的序列,因此数组SR为{10,30,50,70,90,20,40,60,80},i=1,m=5,n=9。

2.第4行,for循环,j由m+1=6开始到9,i由1开始到5,k由1开始每次加1,k值用于目标数组TR的下标。

3.第6行,SR[i]=SR[1]=10,SR[j]=SR[6]=20,SR[i]<SR[j],执行第7行,TR[k]=TR[1]=10,并且i++。如下图所示。

image-20200521110455030

4.再次循环,k++得到k=2,SR[i]=SR[2]=30,SR[j]=SR[6]=20,SR[i]>SR[j],执行第9行,TR[k]=TR[2]=20,并且j++,如下图所示。

image-20200521110520849

5.再次循环,k++得到k=3,SR[i]=SR[2]=30,SR[j]=SR[7]=40,SR[i]<SR[j],执行第7行,TR[k]=TR[3]=30,并且i++,如下图所示。

image-20200521110546962

6.接下来完全相同的操作,一直到j++后,j=10,大于9退出循环,如下图所示。

image-20200521110611419

7.第11~20行的代码,其实就将归并剩下的数组数据,移动到TR的后面。当前k=9,i=m=5,执行第13~20行代码,for循环l=0,TR[k+l]=SR[i+l]=90,大功告成。

归并排序复杂度分析

我们来分析一下归并排序的时间复杂度,一趟归并需要将SR[1]~SR[n]中相邻的长度为h的有序序列进行两两归并。并将结果放到TR1[1]~TR1[n]中,这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行次,因此,总的时间复杂度为O(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。

由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为log

2n的栈空间,因此空间复杂度为O(n+logn)。

另外,对代码进行仔细研究,发现Merge函数中有if(SR[i]<SR[j])语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。

也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。

非递归实现归并排序

我们常说,“没有最好,只有更好。”归并排序大量引用了递归,尽管在代码上比较清晰,容易理解,但这会造成时间和空间上的性能损耗。我们排序追求的就是效率,有没有可能将递归转化成迭代呢?结论当然是可以的,而且改动之后,性能上进一步提高了,来看代码。

/* 对顺序表L作归并非递归排序 */
void MergeSort2(SqList *L)
{
    /* 申请额外空间 */
    int * TR = (int *)malloc(L->length * sizeof(int));    
    int k = 1;
    while (k < L->length)
    {
        MergePass(L->r, TR, k, L->length);
        /*子序列长度加倍 */
        k = 2 * k;                                        
        MergePass(TR, L->r, k, L->length);
        /* 子序列长度加倍 */
        k = 2 * k;                                        
    }
}

1.程序开始执行,数组L为{50,10,90,30,70,40,80,60,20},L.length=9。

2.第3行,我们事先申请了额外的数组内存空间,用来存放归并结果。

3.第5~11行,是一个while循环,目的是不断地归并有序序列。注意k值的变化,第8行与第10行,在不断循环中,它将由1→2→4→8→16,跳出循环。

4.第7行,此时k=1,MergePass函数将原来的无序数组两两归并入TR(此函数代码稍后再讲),如下图所示。

image-20200521112314757

5.第8行,k=2。

6.第9行,MergePass函数将TR中已经两两归并的有序序列再次归并回数组L.r中,如下图所示。

image-20200521112341420

7.第10行,k=4,因为k<9,所以继续循环,再次归并,最终执行完第7~10行,k=16,结束循环,完成排序工作,如下图所示。

image-20200521112405792

从代码中,我们能够感受到,非递归的迭代做法更加直截了当,从最小的序列开始归并直至完成。不需要像归并的递归算法一样,需要先拆分递归,再归并退出递归。

现在我们来看MergePass代码是如何实现的。

/* 将SR[]中相邻长度为s的子序列两两归并到TR[] */
void MergePass(int SR[], int TR[], int s, int n)
{
    int i = 1;
    int j;
    while (i <= n - 2 * s + 1)
    {
        /* 两两归并 */
        Merge(SR, TR, i, i + s - 1, i + 2 * s - 1);    
        i = i + 2 * s;
    }
    /* 归并最后两个序列 */
    if (i < n - s + 1)                                 
        Merge(SR, TR, i, i + s - 1, n);
    /* 若最后只剩下单个子序列 */
    else                                               
        for (j = i; j <= n; j++)
            TR[j] = SR[j];
}

1.程序执行。我们第一次调用“MergePass(L.r,TR,k,L.length);”,此时L.r是初始无序状态,TR为新申请的空数组,k=1,L.length=9。

2.第5~9行,循环的目的就两两归并,因s=1,n-2×s+1=8,为什么循环i从1到8,而不是9呢?就是因为两两归并,最终9条记录定会剩下来,无法归并。

3.第7行,Merge函数我们前面已经详细讲过,此时i=1,i+s-1=1,i+2×s-1=2。也就是说,我们将SR(即L.r)中的第一个和第二个记录归并到TR中,然后第8行,i=i+2×s=3,再循环,我们就是将第三个和第四个记录归并到TR中,一直到第七和第八个记录完成归并,如下图所示。

image-20200521112526787

4.第10~14行,主要是处理最后的尾数,第11行是说将最后剩下的多个记录归并到TR中。不过由于i=9,n-s+1=9,因此执行第13~14行,将20放入到TR数组的最后,如下图所示。

image-20200521112548864

5.再次调用MergePass时,s=2,第5~9行的循环,由第8行的i=i+2×s可知,此时i就是以4为增量进行循环了,也就是说,是将两个有两个记录的有序序列进行归并为四个记录的有序序列。最终再将最后剩下的第九条记录“20”插入TR,如下图所示。

image-20200521112616824

6.后面的类似,略。

非递归的迭代方法,避免了递归时深度为log2n的栈空间,空间只是用到申请归并临时用的TR数组,因此空间复杂度为O(n),并且避免递归也在时间性能上有一定的提升,应该说,使用归并排序时,尽量考虑用非递归方法。

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