原文地址:https://program-park.github.io/2021/11/24/algorithm_24/
前言
部分内容摘自程杰的《大话数据结构》
1. 归并排序
上一篇我们讲了堆排序,因为它用到了完全二叉树,充分利用了完全二叉树的深度是
⌊
l
o
g
2
n
⌋
+
1
\lfloor log_2n\rfloor +1
⌊log2n⌋+1 的特性,所以效率比较高。不过堆结构的设计本身是比较复杂的,老实说,能想出这样的结构就挺不容易,有没有更直接简单的办法利用完全二叉树来排序呢?当然有。
先来举一个例子。你们知道高考一本、二本、专科分数线是如何划分出来的吗?
简单地说,如果各高校本科专业在某省高三理科学生中计划招收 1 万名,那么将全省参加高考的理科学生分数倒排序,第 1 万名的总分数就是当年本科生的分数线(现实可能会比这复杂,这里简化之)。也就是说,即使你是你们班级第一、甚至年级第一名,如果你没有上分数线,则说明你的成绩排不到全省前 1 万名,你也就基本失去了当年上本科的机会了。
换句话说,所谓的全省排名,其实也就是每个市、每个县、每个学校、每个班级的排名合并后再排名得到的。注意我这里用到了合并一词。
我们要比较两个学生的成绩高低是很容易的,比如甲比乙分数低,丙比丁分数低。那么我们也就可以很容易得到甲乙丙丁合并后的成绩排名,同样的,戊已庚辛的排名也容易得到,由于他们两组分别有序了,把他们八个学生成绩合并有序也是很容易做到的了,继续下······最终完成全省学生的成绩排名,此时高考状元也就诞生了。
为了更清晰地说清楚这里的思想,大家来看下图所示,我们将本是无序的数组序列 {16,7,13,10,9,15,3,2,5,8,12,1,12,4,6,14},通过两两合并排序后再合并,最终获得了一个有序的数组。注意仔细观察它的形状,你会发现,它像极了一棵倒置的完全二叉树,通常涉及到完全二叉树结构的排序算法,效率一般都不低的——这就是我们要讲的归并排序法。
1.1 归并排序算法
“归并” 一词的中文含义就是合并、并入的意思,而在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。
归并排序(Merging Sort)就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到
⌈
n
/
2
⌉
\lceil n/2\rceil
⌈n/2⌉(
⌈
x
⌉
\lceil x\rceil
⌈x⌉ 表示不小于
x
x
x 的最小整数)个长度为2或1的有序子序列;再两两归并,······,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
好了,有了对归并排序的初步认识后,我们来看代码。
/* 对顺序表L作归并排序 */
void MergeSort(SqList *L)
{
MSort(L->r,L->r,1,L->length);
}
一句代码,别奇怪,它只是调用了另一个函数而已。为了与前面的排序算法统,我们用了同样的参数定义SqList *L
,由于我们要讲解的归并排序实现需要用到递归调用 30,因此我们外封装了一个函数。假设现在要对数组 {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
{
m=(s+t)/2; /* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
MSort(SR,TR2,S,m); /* 递归将SR[s..m]归并为有序的TR2[s..m] */
MSort(SR,TR2,m+1,t); /* 递归将SR[m+1..t]归并为有序TR2[m+1..t] */
Merge(TR2,TR1,s,m,t); /* 将TR2[s..m]和TR2[m+1..t] */
/* 归并到TR1[s..t] */
}
}
MSort
被调用时,SR
与TR1
都是 {50,10,90,30,70,40,80,60,20},s=1
,t=9
,最终我们的目的就是要将TR1
中的数组排好顺序。- 第 5 行,显然
s
不等于t
,执行第 8~13 行语句块。 - 第 9 行,
m=(1+9)/2=5
。m就是序列的正中间下标。 - 此时第 10 行,调用 “MSort (SR,TR2,1,5);” 的目标就是将数组
SR
中的第 1~5 的关键字归并到有序的TR2
(调用前TR2
为空数组),第 11 行,调用 “MSort(SR,TR2,6,9);” 的目标就是将数组SR
中的第 6~9 的关键字归并到有序的TR2
。也就是说,在调用这两句代码之前,代码已经准备将数组分成了两组了,如下图所示。 - 第 12 行,函数
Merge
的代码细节一会再讲,调用 “Merge(TR2,TR1,1,5,9) ;” 的目标其实就是将第 10 和 11 行代码获得的数组TR2
(注意它是下标为 1~5 和 6~9 的关键字分别有序)归并为TR1
,此时相当于整个排序就已经完成了,如下图所示。
- 再来看第 10 行递归调用进去后,
s=1
,t=5
,m=(1+5)/2=3
。 此时相当于将 5 个记录拆分为三个和两个。继续递归进去,直到细分为一个记录填入TR2
,此时s
与t
相等,递归返回,如下图的左图所示。每次递归返回后都会执行当前递归函数的第 12 行,将TR2
归并到TR1
中,如下图的右图所示,最终使得当前序列有序。
- 同样的第 11 行也是类似方式,如下图所示。
- 此时也就是刚才所讲的最后一次执行第 12 行代码,将 {10,30,50,70,90} 与 {20,40,60,80} 归并为最终有序的序列。
可以说,如果对递归函数的运行方式理解比较透的话,MSort
函数还是很好理解的。我们来看看整个数据变换示意图,如下图所示。
现在我们来看看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.i;
for(j=m+1,k=i;i<=m && j<=n;k++) /* 将SR中记录由小到大归并入TR */
{
if (SR[i]<SR[j] )
TR[k]=SR[i++];
else
TR[k]=SR[j++];
}
if (i<=m)
{
for (i=0;i<=m-i;i++)
TR[k+1]-SR[i+1]; /* 将剩余的SR[i..m]复制到TR */
}
if (j<=n)
{
for (i=-0;i<n-j;i++)
TR[k+1]=SR[j+1]; /* 将剩余的SR[j..n]复制到TR */
}
}
- 假设我们此时调用的
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
。 - 第 4 行,
for
循环,j
由m+1=6
开始到 9,i
由 1 开始到 5,k
由 1 开始每次加 1,k
值用于目标数组TR
的下标。 - 第 6 行,
SR[i]=SR[1]=10
,SR[j]= SR[6]=20
,SR[i]<SR[j]
,执行第 7 行,TR[k]=TR[1]=10
,并且i++
。如下图所示。
- 再次循环,
k++
得到k=2
,SR[i]=SR[2]=30
,SR[j]= SR[6]=20
,SR[i]>SR[j]
,执行第 9 行,TR[k]=TR[2]=20
,并且j++
,如下图所示。
- 再次循环,
k++
得到k=3
,SR[i]=SR[2]=30
,SR[j]= SR[7]=40
,SR[i]<SR[j]
,执行第 7 行,TR[k]=TR[3]=30
,并且i++
,如下图所示。
- 接下来完全相同的操作,一直到
j++
后,j=10
,大于 9 退出循环,如下图所示。
- 第 11~20 行的代码,其实就将归并剩下的数组数据,移动到
TR
的后面。当前k=9
,i=m=5
,执行第 13~20 行代码,for
循环i=0
,TR[k+1]=SR[i+1]=90
,大功告成。
就这样,我们的归并排序就算是完成了一次排序工作,怎么样,和堆排序比,是不是要简单一些呢?
1.2 归并排序复杂度分析
我们来分析一下归并 排序的时间复杂度,一趟归并需要将 SR[1] ~ SR[n]
中相邻的长度为h的有序序列进行两两归并。并将结果放到TR1[1]~TR1[m]
中,这需要将待排序序列中的所有记录扫描一遍,因此耗费
O
(
n
)
O(n)
O(n) 时间,而由完全二叉树的深度可知,整个归并排序需要进行
⌈
l
o
g
2
n
⌉
\lceil log_2n\rceil
⌈log2n⌉ 次,因此,总的时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn), 而且这是归并排序算法中最好、最坏、平均的时间性能。
由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为
l
o
g
2
n
log_2n
log2n 的栈空间,因此空间复杂度为
O
(
n
+
l
o
g
n
)
O(n+logn)
O(n+logn)。
另外,对代码进行仔细研究,发现Merge
函数中有if(SR[i]<SR[j])
语句, 这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。
也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。
1.3 非递归实现归并排序
我们常说,“没有最好,只有更好。” 归并排序大量引用了递归,尽管在代码上比较清晰,容易理解,但这会造成时间和空间上的性能损耗。我们排序追求的就是效率,有没有可能将递归转化成迭代呢?结论当然是可以的,而且改动之后,性能上进一步提高了,来看代码。
/* 对顺序表工作归并非递归排序 */
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; /* 子序列长度加倍 */
}
}
- 程序开始执行,数组
L
为 {50,10,90,30,70,40,80,60,20},L.length=9
。 - 第 3 行,我们事先申请了额外的数组内存空间,用来存放归并结果。
- 第 5~11 行,是一个
while
循环,目的是不断地归并有序序列。注意k
值的变化,第 8 行与第 10 行,在不断循环中,它将由 1→2→4→8→16,跳出循环。 - 第 7 行,此时
k=1
,MergePass
函数将原来的无序数组两两归并入TR
(此函数代码稍后再讲),如下图所示。
- 第 8 行,
k=2
。 - 第 9 行,
MergePass
函数将TR
中已经两两归并的有序序列再次归并回数组L.r
中,如下图所示。
- 第 10 行,
k=4
,因为k<9
,所以继续循环,再次归并,最终执行完第 7~10 行,k=16
,结束循环,完成排序工作,如下图所示。;
从代码中,我们能够感受到,非递归的迭代做法更加直截了当,从最小的序列开始归并直至完成。不需要像归并的递归算法一样,需要先拆分递归,再归并退出递归。
现在我们来看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];
}
- 程序执行。我们第一次调用 “MergePass(L.r,TR,k,L.length);”,此时
L.r
是初始无序状态,TR
为新申请的空数组,k=1
,L.length=9
。 - 第 5~9 行,循环的目的就两两归并,因
s=1
,n-2×s+1=8
,为什么循环i
从 1 到 8,而不是 9 呢?就是因为两两归并,最终 9 条记录定会剩下来,无法归并。 - 第 7 行,
Merge
函数我们前面已经详细讲过,此时i=1
,i+s-1=1
,i+2×s-1=2
。也就是说,我们将SR
(即L.r
)中的第一个和第二个记录归并到TR
中,然后第 8 行,i=i+2×s=3
,再循环,我们就是将第三个和第四个记录归并到TR
中,一直到第七和第八个记录完成归并,如下图所示。
- 第 10~14 行,主要是处理最后的尾数,第 11 行是说将最后剩下的多个记录归并到
TR
中。不过由于i=9
,n-s+1=9
,因此执行第 13~14 行,将 20 放入到TR
数组的最后,如下图所示。
- 再次调用
MergePass
时,s=2
,第 5~9 行的循环,由第 8 行的i=i+2×s
可知,此时i
就是以 4 为增量进行循环了,也就是说,是将两个有两个记录的有序序列进行归并为四个记录的有序序列。最终再将最后剩下的第九条记录 “20” 插入TR
,如下图所示。
- 后面的类似,略。
非递归的迭代方法,避免了递归时深度为
l
o
g
2
n
log_2n
log2n 的栈空间,空间只是用到申请归并临时用的TR
数组,因此空间复杂度为
O
(
n
)
O(n)
O(n),并且避免递归也在时间性能上有一定的提升,应该说,使用归并排序时,尽量考虑用非递归方法。
2. 总结
排序算法的总结在下一篇讲完快速排序后再写。