声明:由于排序考察较多,且往往较难,所以篇幅很长
参考资料:《数据结构(C语言版)(第2版)》严蔚敏、李冬梅,王道咸鱼B站课程
目录
一、基本概念
1.1 排序的基本概念
【排序的稳定性】
当排序记录中的关键字都不相同时,则任何一个记录的无序序列经排序后得到的结果唯一;反之,当待排序的序列中存在两个或两个以上关键字相等的记录时,则排序所得的结果不唯一。
假设两个元素的关键字 Ki = Kj(1≤i≤n,1≤j≤n,i≠j),且在排序前的序列中元素 Ri 领先于元素 Rj(即i<j)。若在排序后的序列中 Ri 仍领先于 Rj ,则称所用的排序方法是稳定的;反之,若可能使排序后的序列中 Rj 领先于 Ri,则称所用的排序方法是不稳定的。
注意,排序算法的稳定性是针对所有记录而言的。也就是说,在所有的待排序记录中,只要有一组关键字的实例不满足稳定性要求,则该排序方法就是不稳定的。虽然稳定的排序方法和不稳定的排序方法排序结果不同,但不能说不稳定的排序方法就不好,各有各的适用场合。
【内部排序和外部排序】
根据在排序过程中记录所占用的存储设备,可将排序方法分为两大类:一类是内部排序,指的是待排序记录全部存放在计算机内存中进行排序的过程;另一类是外部排序,指的是待排序记录的数量很大,以致内存一次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程。
1.2 内部排序的方法分类
内部排序的过程是一个逐步扩大记录的有序序列长度的过程。在排序的过程中,可以将排序记录区分为两个区域:有序序列区和无序序列区。
使有序区中记录的数目增加一个或几个的操作称为一趟排序。
根据逐步扩大记录有序序列长度的原则不同,可以将内部排序分为以下几类。
- 插入类:将无序子序列中的一个或几个记录插入有序序列,从而增加记录的有序子序列的长度。主要包括直接插入排序、折半插入排序和希尔排序。
- 交换类:通过交换无序序列中的记录从而得到其中关键字最小或最大的记录,并将它加入有序子序列中,以此方法增加记录的有序子序列的长度。主要包括冒泡排序和快速排序。
- 选择类:从记录的无序子序列中选择关键字最小或最大的记录,并将它加入有序子序列中,以此方法增加记录的有序子序列的长度。主要包括简单选择排序、树形选择排序和堆排序。
- 归并类:通过归并两个或两个以上的记录有序子序列,逐步增加记录有序序列的长度。2-路归并排序是最为常见的归并排序方法。
- 分配类:是唯一一类不需要进行关键字比较的排序方法,排序时主要利用分配和收集两种基本操作来完成。基数排序是主要的分配排序方法。
二、内部排序
2.1 插入排序
插入排序的基本思想是:每一趟将一个待排序的记录,按其关键字的大小插入已经排好序的一组记录的适当位置,直到所有待排序记录全部插入为止。
【例子】 打扑克牌在抓牌时要保证抓过的牌有序排列,则每抓一张牌,就插入合适的位置,直到抓完牌为止,即可得到一个有序序列。
a) 直接插入排序
直接插入排序是一种最简单的排序方法,基本操作是将一条记录插入已排好序的表,从而得到一个新的、记录数量增1的有序表。如下所示:

从后向前顺序比较时,为了在查找插入位置的过程中避免数组下标越界,可以在 r[0] 处设置监视哨。在自 i−1 起往前查找插入位置的过程中,可以同时后移记录。
void InsertSort(SqList &L){
//对顺序表L进行直接插入排序
for(i=2;i<=L.length;++i)
if(L.r[i].key<L.r[i-1].key){ //“<”,需将r[i]插入有序子表
L.r[0]=L.r[i]; //将待插入的记录暂存到监视哨中
L.r[i]=L.r[i-1]; //r[i-1]后移
for(j=i-2; L.r[0].key<L.r[j].key; --j) //从后向前寻找插入位置
L.r[j+1]=L.r[j]; //记录逐个后移,直到找到插入位置
L.r[j+1]=L.r[0]; //将r[0]即原r[i],插入正确位置
} //if
}
【算法分析】
(1)时间复杂度
从时间来看,排序的基本操作为比较两个关键字的大小和移动记录。在平均情况下,直接插入排序关键字的比较次数和记录移动次数均约为
n
2
4
\frac{n^{2}}{4}
4n2。因此,直接插入排序的时间复杂度为
O
(
n
2
)
O(n^{2})
O(n2)。
(2)空间复杂度
直接插入排序只需要一个记录的辅助空间 r[0],所以空间复杂度为
O
(
1
)
O(1)
O(1) 。
【算法特点】
(1)稳定排序。
(2)算法简便,且容易实现。
(3)也适用于链式存储结构,只是在单链表上无须移动记录,只需修改相应的指针。
(4)更适合于初始记录基本有序(正序)的情况,当初始记录无序、n较大时,此算法时间复杂度较高,不宜采用。
b) 折半插入排序
直接插入排序采用顺序查找法查找当前记录在已排好序的序列中的插入位置,这个“查找”操作可利用“折半查找”来实现,由此进行的插入排序称之为折半插入排序。
void BInsertSort(SqList &L){
// 对顺序表L进行折半插入排序
int i,j,low,high,mid;
for(i=2,i<L.length;++i){
L.r[0]=L.r[i]; //将待插入的记录暂存到监视哨
low=1,high=i-1; //置查找区间初值
while(low<high){ //在r[low..high]中折半查找插入的位置
mid=(low+high)/2; //折半
if(L.r[0].key<L.r[m].key) //插入点在前一子表
high=mid-1;
else //插入点在后一子表
low=m+1;
}//while
for(j=i-1;j>=high+1;--j)
L.r[j+1]=L.r[j]; //记录后移
L.r[high+1]=L.r[0]; //将r[0]即原r[i],插入正确位
}//for
}
【算法分析】
(1)时间复杂度
从时间上比较,折半查找比顺序查找快,所以就平均性能来说,折半插入排序优于直接插入排序。
【折半插入排序比较次数】 与待排序序列的初始排列无关,仅依赖于记录的个数。不论初始序列情况如何,在插入第 i 个记录时,都需要经过 log 2 i + 1 \log_{2}i+1 log2i+1 次比较,才能确定它应插入的位置。
所以当记录的初始排列为正序或接近正序时,直接插入排序比折半插入排序执行的关键字比较次数要少。
【折半插入排序移动次数】 与直接插入排序相同,依赖于对象的初始排列。
在平均情况下,折半插入排序仅减少了关键字间的比较次数,而记录的移动次数不变。因此,折半插入排序的时间复杂度仍为 O ( n 2 ) O(n^{2}) O(n2)。
(2)空间复杂度
折半插入排序所需附加存储空间和直接插入排序相同,只需要一个记录的辅助空间 r[0],所以空间复杂度为
O
(
1
)
O(1)
O(1)。
【算法特点】
(1)稳定排序。
(2)因为要进行折半查找,所以只能用于顺序结构,不能用于链式结构。
(3)适合初始记录无序、n较大的情况。
c) 希尔插入排序
当待排序的记录个数较少且待排序序列的关键字基本有序时,直接插入排序效率较高。希尔排序基于以上两点,从“减少记录个数”和“序列基本有序”两个方面对直接插入排序进行了改进。
希尔排序实质上是采用分组插入的方法,先将整个待排序记录序列分割成几组,从而减少参与直接插入排序的数据量,对每组分别进行直接插入排序,然后增加每组的数据量,重新分组。这样当经过几次分组排序后,整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。
希尔排序对记录的分组,不是简单地“逐段分割”,而是将相隔某个“增量”的记录分成一组。分别取增量5、3、1,希尔排序如图2.1.2所示。

【算法分析】
(1)时间复杂度
当增量大于1时,关键字较小的记录就不是一步一步地挪动,而是跳跃式地移动,从而使得在进行最后一趟增量为1的插入排序时,序列已基本有序,只要对记录进行少量比较和移动即可完成排序,因此希尔排序的时间复杂度较直接插入排序的低。当n在某个特定范围内,希尔排序所需的比较和移动次数约为
n
1.3
n^{1.3}
n1.3,当n→∞时,比较和移动次数可减少到
n
(
log
2
n
)
2
n(\log_{2}n)^{2}
n(log2n)2,最坏时间复杂度为
O
(
n
2
)
O(n^{2})
O(n2)
(2)空间复杂度
从空间来看,希尔排序和前面两种排序方法一样,也只需要一个辅助空间r[0],空间复杂度为O(1)。
【算法特点】
(1)记录跳跃式地移动导致排序方法是不稳定的。
(2)只能用于顺序结构,不能用于链式结构。
(3)增量序列可以有各种取法,但应该使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须等于1。
(4)记录总的比较次数和移动次数都比直接插入排序的要少,n越大时,效果越明显。所以适合初始记录无序、n较大时的情况。
2.2 交换排序
交换排序的基本思想是:两两比较待排序记录的关键字,一旦发现两个记录不满足次序要求时则进行交换,直到整个序列全部满足要求为止。
a) 冒泡排序
冒泡排序是一种最简单的交换排序方法,它通过两两比较相邻记录的关键字,如果为逆序,则进行交换,从而使关键字小的记录如气泡一般逐渐往上“漂浮”(左移),或者使关键字大的记录如石块一样逐渐向下“坠落”(右移)。
已知待排序记录的关键字序列为{49,38,65,97,76,13,27,49},冒泡排序过程如图 2.2.1 所示。

void BubbleSort(SqList &L){
// 对顺序表冒泡排序
int m,j,t,flag;
m=L.length-1;flag=1; //flag用来标记某一趟排序是否发生交换
while((m>0)){
flag=0; //flag置为0,如果本趟排序没有发生交换,则不会执行下一趟排序
for(j=1;j<m)
if(L.r[j].key>L.r[j+1].key){
flag=1; //flag置为1,表示本趟排序发生了交换
t=L.r[j];L.r[j]=L.r[j+1];L.r[j+1]=t; //交换前后两个记录
} //if
--m;
} //while
}
【算法分析】
(1)时间复杂度
最好情况(初始序列为正序):只需进行一趟排序,在排序过程中进行n−1次关键字的比较,且不移动记录
最坏情况(初始序列为逆序):需进行n−1趟排序,总的关键字比较次数KCN和记录移动次数RMN(每次交换都要移动3次记录)分别为:
K
C
N
=
∑
i
=
n
2
(
i
−
1
)
=
n
(
n
−
1
)
/
2
≈
n
2
2
KCN=\sum_{i=n}^{2}(i-1)=n(n-1)/2\approx \frac{n^{2}}{2}
KCN=∑i=n2(i−1)=n(n−1)/2≈2n2
R
M
N
=
3
∑
i
=
n
2
(
i
−
1
)
=
3
n
(
n
−
1
)
/
2
≈
3
n
2
2
RMN=3\sum_{i=n}^{2}(i-1)=3n(n-1)/2\approx \frac{3n^{2}}{2}
RMN=3∑i=n2(i−1)=3n(n−1)/2≈23n2
所以,在平均情况下,冒泡排序关键字的比较次数和记录移动次数分别约为 n 2 4 \frac{n^{2}}{4} 4n2和 3 n 2 4 \frac{3n^{2}}{4} 43n2,时间复杂度为 O ( n 2 ) O(n^{2}) O(n2)。
(2)空间复杂度
冒泡排序只有在两个记录交换位置时需要一个辅助空间用于暂存记录,所以空间复杂度为 O ( 1 ) O(1) O(1)。
【算法特点】
(1)稳定排序。
(2)可用于链式存储结构。
(3)移动记录次数较多,算法的平均时间性能比直接插入排序的差。当初始记录无序、n较大时,此算法不宜采用。
冒泡排序一趟冒泡要比较很多次,每次比较都可能要换位,所以一趟冒泡往往要交换多次,交换次数一般多于直接插入排序和直接选择排序;另外,直接选择排序和直接插入排序虽然也要比较,但数据几乎不动,所以缓存命中率更高。
b) 快速排序(⭐重难点,代码高频考点)
快速排序是由冒泡排序改进而得的。在待排序的n个记录中任取一个记录(通常取第一个记录)作为枢轴(或支点),设其关键字为pivotkey。经过一趟排序后,把所有关键字小于pivotkey的记录交换到前面,把所有关键字大于pivotkey的记录交换到后面,结果将待排序记录分成两个子表,最后将枢轴放置在分界处的位置。然后,分别对左、右子表重复上述过程,直至每一子表只有一个记录时,排序完成。
在冒泡排序过程中,只对相邻的两个记录进行比较,因此每次交换两个相邻记录时只能消除一个逆序排列。如果能通过两个(不相邻)记录的一次交换,消除多个逆序排列,则会大大加快排序的速度。快速排序方法中的一次交换可能消除多个逆序排列。
【一趟快排的具体步骤】
① 选择待排序表中的第一个记录作为枢轴,将枢轴记录暂存在r[0]的位置上。附设两个指针low和high,初始时分别指向表的下界和上界(第一趟时,low = 1; high = L.length;)。
② 从表的最右侧位置依次向左搜索,找到第一个关键字小于枢轴关键字pivotkey的记录,将其移到 low处。具体操作是:当low<high时,若high所指记录的关键字大于等于pivotkey,则向左移动指针high(执行操作−−high),否则将high所指记录与枢轴记录交换。
③ 然后从表的最左侧位置,依次向右搜索找到第一个关键字大于pivotkey的记录和枢轴记录交换。具体操作是:当low<high时,若low所指记录的关键字小于等于pivotkey,则向右移动指针low(执行操作++low),否则将low所指记录与枢轴记录交换。
④ 重复步骤②和步骤③,直至low与high相等为止。此时low或high的位置即枢轴在此趟排序中的最终位置,原表被分成两个子表。
在上述过程中,记录的交换都是与枢轴之间发生的,每次交换都要移动3次记录,可以先将枢轴记录暂存在r[0]的位置上,排序过程中只移动要与枢轴交换的记录,即只进行r[low]或r[high]的单向移动,直至一趟排序结束后再将枢轴记录移至正确位置。
整个快速排序的过程可递归进行,快速排序的算法实现如下所示。其中,算法Partition完成一趟快速排序,返回枢轴的位置。若待排序序列长度大于1(low<high),算法QuickSort调用Partition获取枢轴位置,然后递归执行,分别对分割所得的两个子表进行排序。若待排序序列中只有一个记录,递归结束,排序完成。
int Partition(SqList &L, int low, int high){
//对顺序表L中的子表r[low..high]进行一趟排序,返回枢轴位
L.r[0]=L.r[low]; //用子表的第一个记录作为枢轴记录
pivotkey=L.r[low].key; //枢轴记录关键字保存在pivotkey中
while(low<high){ //从表的两端交替地向中间查找
while(low<high&&L.r[high].key>=pivotkey) --high;
L.r[low]=L.r[high]; //将比枢轴记录小的记录移到低端
while(low<high&&L.r[low].key<=pivotkey) ++low;
L.r[high]=L.r[low]; //将比枢轴记录大的记录移到高端
} //while
L.r[low]=L.r[0]; //枢轴记录到位
return low; //返回枢轴位置
}
void QSort(SqList &L,int low,int high){
//调用前置初值 :low=1; high=L.length;
//对顺序表L中的子表L.r[low..high]进行快速排序
if(low<high){ //长度大于1
pivotloc=Partition(L, low, high); //将L.r[low..high]一分为二,pivotloc是枢轴位置
QSort(L, low, pivotloc-1); //对左子表递归排序
QSort(L, pivotloc+1, high); //对右子表递归排序
} //if
}
void QuickSort(SqList &L){
//对顺序表L进行快速排序
QSort(L,1,L.length);
}
【算法分析】
(1)时间复杂度
从快速排序算法的递归树可知,快速排序的趟数取决于递归树的深度。平均情况下,快速排序的时间复杂度为 O ( n log 2 n ) O(n\log_{2}n) O(nlog2n)。
【最好情况】 每一趟排序后都能将记录序列均匀地分割成两个长度大致相等的子表,类似折半查找,总排序时间小于 O ( n log 2 n ) O(n\log_{2}n) O(nlog2n)
【最坏情况】 在待排序序列已经排好序的情况下,其递归树成为单支树,每次划分只得到一个比上一次少一个记录的子序列。这种情况下,快速排序的速度已经退化到简单排序的水平。总的关键字比较次数KCN为 2 n 2^{n} 2n
(2)空间复杂度
快速排序是递归的,执行时需要有一个栈来存放相应的数据。最大递归调用次数与递归树的深度一致。所以最好情况下的空间复杂度为 O ( log 2 n ) O(\log_{2}n) O(log2n),最坏情况下为 O ( n ) O(n) O(n)。
【算法特点】
(1)记录非顺次的移动导致排序方法是不稳定的。
(2)排序过程中需要定位表的下界和上界,所以适合用于顺序结构,很难用于链式结构。
(3)当n较大时,在平均情况下快速排序是所有内部排序方法中速度最快的一种,所以其适合初始记录无序、n较大的情况。
c) 题目小结
1.【判断快速排序最快最慢情况】
最快(好)情况:每次选取的基准值恰好是当前序列的中位数,能将当前的序列均匀地分割成大小大致相等的两个子序列。
例如 [4,2,6,1,3,5,7] 就是一个理想序列
第一次选 4:分出 [2,1,3] 和 [6,5,7]。
第二次在 [2,1,3] 中选 2:分出 [1] 和 [3]。
第二次在 [6,5,7] 中选 6:分出 [5] 和 [7]。
最坏情况:序列正序 or 逆序
2.【判断快速排序序列】
方法1:在快速排序中,在每趟排序结束之后,该趟排序的枢轴在序列中所处的位置,就是它在最终有序序列中所处的位置。
方法2(1的进阶版):对n个元素进行第一趟快速排序后,会确定一个基准元素,根据这个基准元素在数组中的位置,有两种情况:(如果觉得这段文字不清楚,建议直接看图片里的这个评论,感觉非常清晰透彻)
- ①基准元素在数组的首端或尾端,接下来对剩下的n一1个元素构成的子序列进行第二趟快速排序,再确定一个基准元素。这样,在两趟排序后就至少能确定 2 个元素的最终位置,其中至少有一个元素是在数组的首端或尾端。
- ②基准元素不在数组的首端或尾端,第二趟快快速排序对基准元素划分开的两个子序列分别进行一次划分,两个子序列各确定一个基准元素。这样,两趟排序后就至少能确定 3 个元素的最终位置。

【2023年统考真题】 使用快速排序算法对数据进行升序排序,若经过一次划分后得到的数据序列是:68,11,70,23,80,77,48,81,93,88,则该次划分的枢轴是______。
【解析】使用方法1判断即可,最终序列是11,23,48,68,70,77,80,81,88,93,其中77和81分别位于最终位置上,可能是枢轴。而题目序列中,77左边有比它大的80,因此77不是枢轴。81符合条件。
【2014年统考真题】 下列选项中,不可能是快速排序第二趟排序结果的是 ()
A. 2,3,5,4,6,7,9 B. 2,7,5,6,4,3,9 C. 3,2,5,4,7,6,9 D. 4,2,3,5,7,6,9
【解析】使用方法1判断即可,分别写出ABCD的最终序列,只有C不满足。
2.3 选择排序
选择排序的基本思想是:每一趟从待排序的记录中选出关键字最小的记录,按顺序将其放在已排好序的记录序列的最后,直到全部排完为止。
a) 简单/直接选择排序

void SelectSort(SqList &L){
//对顺序表L进行简单选择排序
int i,j,k,t;
for(i=1;i<L.length;++i){ //在L.r[i..L.length] 中选择关键字最小的记录
k=i;
for(j=i+1;j<=L.length;++j)
if(L.r[j].key<L.r[k].key) k=j; //k指向此趟排序中关键字最小的记录
if(k!=i){
t=L.r[i]; L.r[i]=L.r[k]; L.r[k]=t; //交换r[i]与r[k]
} //if
} //for
}
【算法分析】
(1)时间复杂度
简单选择排序过程中,所需进行记录移动的次数较少。最好情况(正序):不移动。最坏情况(逆序):移动3(n−1)次。然而,无论记录的初始排列如何,所需进行的关键字间的比较次数相同,均为
n
2
2
\frac{n^{2}}{2}
2n2。因此,简单选择排序的时间复杂度也是
O
(
n
2
)
O(n^{2})
O(n2)。
(2)空间复杂度
同冒泡排序一样,只有在两个记录交换时需要一个辅助空间,所以空间复杂度为O(1)。
【算法特点】
(1)就选择排序方法本身来讲,它是一种稳定的排序方法,但图2.3.1所表现出来的现象是不稳定的,这是因为上述实现选择排序的算法采用“交换记录”的策略所造成的,改变这个策略,可以写出不产生“不稳定现象”的选择排序算法。
(2)可用于链式存储结构。
(3)移动记录次数较少,当每一记录占用的空间较多时,此方法比直接插入排序快。
选择排序的主要操作是进行关键字间的比较,因此改进简单选择排序应从如何减少“比较”出发考虑。
b) 堆排序(⭐重难点)
将待排序的记录r[1…n]看成一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲节点和孩子节点之间的内在关系,在当前无序的序列中选择关键字最大(或最小)的记录。
【堆】 堆实质上可以看作满足如下性质的完全二叉树:树中所有非终端节点的值均不大于(或不小于)其左、右孩子节点的值。即,L(i) ≥ L(2i) 且 L(i) ≥ L(2i+1);或 L(i) ≤ L(2i) 且 L(i) ≤ L(2i)。

void HeadAdjust(SqList &L,int s,int m){
// 假设r[s+1..m]已经是堆,将其调整为以r[s]为根的大根堆
int rc,j;
rc=L.r[s]; // 把当前根结点暂存起来,准备“向下筛选”
for(j=2*s;j<=m;j*=2){
// 从当前结点的左孩子开始,一层层往下比较
// 对于顺序存储的堆:左孩子是 2*s,右孩子是 2*s + 1
if(j<m&&L.r[j].key<L.r[j+1].key) ++j; //j为key较大的记录的下标
if(rc.key>=L.r[j].key) break; //rc应插入在位置s上
L.r[s]=L.r[j];s=j;
} //for
L.r[s]=rc; //插入
}
void CreatHeap(SqList &L){
//把无序序列L.r[1..n]建成大根堆
int n,i;
n=L.length;
for(i=n/2;i>0;--i)
HeapAdjust(L,i,n); //反复调用HeapAdjust
}
void HeapSort(SqList &L){
//对顺序表L进行堆排序(升序)
int i,x;
CreatHeap(L); //先把无序序列L.r[1..L.length]建成大根堆
//然后不断取出堆顶元素(当前最大值),放到序列的末尾,并重新调整剩下的元素序列为大根堆
for(i=L.length;i>1;--i){
x=L.r[1]; //将堆顶记录和当前未经排序子序列L.r[1..i]中最后一个记录互换
L.r[1]=L.r[i];
L.r[i]=x;
HeapAdjust(L,1,i-1); //将L.r[1..i-1]重新调整为大根堆
} //for
}
【算法分析】
(1)时间复杂度
堆排序的运行时间主要耗费在建初堆和调整堆时进行的反复筛选上。初建堆时关键字总的比较次数小于4n,调整重建堆时关键字总的比较次数不超过
2
n
(
log
2
n
)
2n(\log_{2}n)
2n(log2n),因此在最坏情况下,时间复杂度也为
2
n
(
log
2
n
)
2n(\log_{2}n)
2n(log2n)。而实验研究表明,堆排序的平均性能接近于最坏性能。
(2)空间复杂度
仅需一个记录大小供交换用的辅助存储空间,所以空间复杂度为O(1)。
【算法特点】
(1)是不稳定排序,建堆和调整堆的过程中,可能会发生跳跃式的交换。
(2)只能用于顺序结构,不能用于链式结构。
(3)初始建堆所需的比较次数较多,因此记录数较少时不宜采用。堆排序在最坏情况下时间复杂度为
O
(
n
l
o
g
2
n
)
O(nlog_{2}n)
O(nlog2n),相对于快速排序最坏情况下的
O
(
n
2
)
O(n^{2})
O(n2)而言更有优势,当记录较多时较为高效。
c) 题目小结
1.【堆与二叉排序树的区别-链接】
| 二叉排序树 | 堆 | |
|---|---|---|
| 结构特点 | 每个结点的值均大于其左子树上所有结点的值 小于其右子树上所有结点的值 | 完全二叉树,并且每个结点的值都大于等于 (或小于等于)其左右孩子结点的值 |
| 有序序列 | 中序遍历可得到有序序列 | 根节点到任意叶节点为有序序列 |
| 深度 | 取决于初始序列 最好情况下为 l o g 2 n log_{2}n log2n,最坏情况下为 n n n | l o g 2 n log_{2}n log2n |
| 左右孩子 | 结点的右孩子一定大于该结点的左孩子 | 左右孩子结点之间的大小关系不确定 |
| 最值 | 最小值为最左下结点,其左指针为空; 最大值为最右下结点,其右指针为空 | 最值位于堆顶和某个叶子节点 |
| 应用 | 动态查找 | 排序 |
2.【堆排序的建堆调整、插入调整、删除调整】
参考文章:初始建堆调整-博客园
推荐使用这个网站自己操作一遍,可以更直观地感受初始建堆调整的过程:Data Structure Visualization
初始建堆:自下向上调整(从序列末尾开始)
插入调整:插入到堆末端(序列末尾),自下向上调整
删除调整:将堆的末端元素(序列末尾)与堆顶交换,逐步向下调整
2.4 归并排序(⭐重难点)
归并排序算法的思想是:假设初始序列含有n个记录,则可将其看成n个有序的子序列,每个子序列的长度为1,然后两两归并,得到 ⌈ n 2 ⌉ \left\lceil{\frac{n}{2}}\right\rceil ⌈2n⌉个长度为2或1的有序子序列;再两两归并,如此重复,直至得到一个长度为n的有序序列为止。

void Merge(RedType R[],RedType T[],int low,int mid,int high){
//将有序表R[low..mid]和R[mid+1..high]归并为有序表T[low..high]
int i=low,j=mid+1,k=low;
while(i<=mid && j<=high){ //将R中的记录从小到大并入T中
if(R[i].key<R[j].key) T[k++]=R[i++];
else T[k++]=R[j++];
} //while
while(i<=mid) T[k++]=R[i++]; //将剩余的R[i..mid]复制到T中
while(j<=high) T[k++]=R[j++]; //将剩余的R[j..high]复制到T中
}
void MSort(RedType R[],RedType T[],int low,int high){
//R[low..high]归并排序后放入T[low..high]中
int mid;
if(low==high) T[low]=R[low];
else{
mid=(low+high)/2; //将当前序列一分为二,mid为分裂点
MSort(R,S,low,mid); //对子序列R[low..mid]递归进行归并排序,结果放入S[low..mid]
MSort(R,S,mid+1,high); //对子序列R[mid+1..high]递归进行归并排序,结果放入S[mid+1..high]
Merge(S,T,low,high); //将S[low..mid]和S[mid+1..high]归并到T[low..high]
} //else
}
void MergeSort(SqList &L){
//对顺序表L进行归并排序
MSort(L.r,L.r,1,L.length);
}
【算法分析】
(1)时间复杂度
当有n个记录时,需进行
⌈
log
2
n
⌉
\left\lceil\log_{2}n\right\rceil
⌈log2n⌉趟归并排序,每一趟归并的关键字比较次数不超过n,元素移动次数都是n,因此,归并排序的时间复杂度为
O
(
n
log
2
n
)
O(n\log_{2}n)
O(nlog2n)。
(2)空间复杂度
用顺序表实现归并排序时,需要和待排序记录个数相等的辅助存储空间(生成数组的副本),所以空间复杂度为
O
(
n
)
O(n)
O(n)。
【算法特点】
(1)是稳定排序。
(2)可用于链式结构,且不需要附加存储空间,但递归实现时仍需要开辟相应的递归工作栈。
2.5 基数排序和计数排序
参考文章:菜鸟教程-基数排序、菜鸟教程-计数排序
PS:基数排序代码比较长,且实际用得比其他排序较少,考察概率小;计数排序思想考察较多
【算法分析】
(1)时间复杂度
对于n个记录(假设每个记录含d个关键字,每个关键字的取值范围为rd个值)进行链式基数排序时,每一趟分配的时间复杂度为O(n),每一趟收集的时间复杂度为O(rd),整个排序需进行d趟分配和收集,所以时间复杂度为
O
(
d
(
n
+
r
d
)
)
O(d(n + rd))
O(d(n+rd))。
(2)空间复杂度
所需辅助空间为2rd个队列指针,另外由于需用链表作为存储结构,则相对于其他以顺序结构存储记录的排序方法而言,链式基数排序还增加了n个指针域的空间,所以空间复杂度为
O
(
n
+
r
d
)
O(n + rd)
O(n+rd)。
【算法特点】
(1)是稳定排序。
(2)可用于链式结构,也可用于顺序结构。
(3)时间复杂度可以突破基于关键字比较一类方法的下界
O
(
n
log
2
n
)
O(n\log_{2}n)
O(nlog2n),达到
O
(
n
)
O(n)
O(n)。
(4)基数排序使用条件有严格的要求:需要知道各级关键字的主次关系和各级关键字的取值范围。
题目小结
N 个元素 k 路归并的归并趟数 s = ⌈ log k N ⌉ s = \lceil\log_{k}N\rceil s=⌈logkN⌉
【例题】设某文件经内排序后得到100个初始归并段(初始顺串),若使用多路归并排序算法,且要求三趟归并完成排序,问归并路数最少为(D)
A.8 B.7 C.6 D.5
2.6 各种排序算法对比

【与初始序列关联】 选择排序(简单选择和堆排序)、归并排序、基数排序等在最好情况、平均情况和最坏情况下的时间复杂度都相同的算法,与待排序序列的初始状态无关。无论输入序列是完全有序、完全逆序还是随机的,它们执行的比较次数和数据移动次数(或数据访问次数)都是基本固定的。
| 排序方法 | 稳定性 | 时间复杂度 | 空间复杂度 | 适用条件 |
|---|---|---|---|---|
| 直接插入排序 | 稳定 | O ( n 2 ) O(n^{2}) O(n2) | O ( 1 ) O(1) O(1) | 基本有序或规模较小的初始序列 链表 & 顺序表 |
| 折半插入排序 | 稳定 | O ( n 2 ) O(n^{2}) O(n2) | O ( 1 ) O(1) O(1) | 无序、n较大的初始序列 顺序表 |
| 希尔插入排序 | 不稳定 | O ( n 1.3 ) O(n^{1.3}) O(n1.3) | O ( 1 ) O(1) O(1) | 无序、n较大的初始序列 顺序表 |
| 冒泡排序 | 稳定 | O ( n 2 ) O(n^{2}) O(n2) | O ( 1 ) O(1) O(1) | 基本有序或规模较小的初始序列 链表 & 顺序表 平均性能比直接插入排序差 |
| 快速排序 | 不稳定 |
O
(
n
log
2
n
)
O(n\log_{2}n)
O(nlog2n) 最坏: O ( n 2 ) O(n^{2}) O(n2) | 由递归树深度决定 最好: O ( log 2 n ) O(\log_{2}n) O(log2n) 最坏: O ( n ) O(n) O(n) | 无序、n较大的初始序列 顺序表 内部排序中,平均情况下速度最快 |
| 简单选择排序 | 稳定/不稳定 | O ( n 2 ) O(n^{2}) O(n2) | O ( 1 ) O(1) O(1) | 规模较小的初始序列 链表 & 顺序表 与待排序序列的初始状态无关 |
| 堆排序 | 不稳定 |
O
(
n
log
2
n
)
O(n\log_{2}n)
O(nlog2n) 最坏: O ( n log 2 n ) O(n\log_{2}n) O(nlog2n) | O ( 1 ) O(1) O(1) | 记录较多的初始序列 顺序表 与待排序序列的初始状态无关 |
| 归并排序 | 稳定 |
O
(
n
log
2
n
)
O(n\log_{2}n)
O(nlog2n) 最好、坏: O ( n log 2 n ) O(n\log_{2}n) O(nlog2n) | 顺序表
O
(
n
)
O(n)
O(n) 链表 O ( 1 ) O(1) O(1) | 链表 & 顺序表 可用于外部排序 与待排序序列的初始状态无关 |
| 基数排序 | 稳定 | O ( d ( n + r d ) ) O(d(n + rd)) O(d(n+rd)) | O ( n + r d ) O(n + rd) O(n+rd) | 链表 & 顺序表 要知道各级关键字的主次关系取值范围 与待排序序列的初始状态无关 |
【说明】
- 简单选择排序的算法本身稳定,但实际稳定性取决于具体实现策略,一般来说不稳定(交换操作可能破坏原有顺序)。
- 归并排序的空间复杂度,用顺序表实现需要开辟 O ( n ) O(n) O(n)的空间;链表实现不需要额外附加空间,但递归实现仍需要开辟递归工作栈。链表归并的额外空间复杂度是 O ( 1 ) O(1) O(1)(常数级,仅用少数几个临时指针变量)。
- 虽然快速排序、堆排序、归并排序在时间复杂度上都是一个量级 O ( n log 2 n ) O(n\log_{2}n) O(nlog2n),但实际运行中,三者的速度为:快速排序>归并排序>堆排序。
【原因】快速排序的常数项往往更小;而且实际运行速度还受缓存效率、内存访问模式等因素影响。
- 快速排序(每次比较+交换)在数组中顺序访问、比较、交换,局部性好,Cache命中率高;
- 归并排序(每次比较+复制)则需要额外的辅助数组,每趟都要拷贝数据(耗时);
- 堆排序(每次建堆+调整)的父子节点在数组中存放不连续,访问跳跃;每取出一个数,还要频繁调整堆的结构,导致访问更加随机,局部性差,Cache命中率低;
三、外部排序
3.1 多路平衡归并和败者树
这部分内容考察代码概率不高,简略记了一些,详细可以听B站王道咸鱼的课。

k路平衡归并中,k值的选择并非越大越好。
对于 r 个初始归并段的 k 路归并,只需要
⌈
log
k
r
⌉
\left\lceil{\log_{k}r}\right\rceil
⌈logkr⌉ 次归并趟数,k 路归并的败者树深度为
⌈
log
k
r
⌉
+
1
\left\lceil{\log_{k}r}\right\rceil+1
⌈logkr⌉+1
从k个记录中选择最小关键字,仅需进行
⌈
log
2
k
⌉
\left\lceil{\log_{2}k}\right\rceil
⌈log2k⌉次比较,因此总的比较次数为
(
n
−
1
)
⌈
log
2
r
⌉
(n-1)\left\lceil{\log_{2}r}\right\rceil
(n−1)⌈log2r⌉(与k无关)
3.2 置换-选择排序
目的:让初始归并段的数量 r 尽可能少,从而减少平衡归并中的比较次数

3.3 最佳归并树
目的:组织长度不等的初始归并段的归并顺序
归并过程中,磁盘I/O次数 = 归并数的WPL×2(带权路径长度的2倍),如下所示。

对于 k 叉归并树,若初始归并段的数量无法构成严格的 k 叉归并树,则需要补充几个长度为0的虚段,再构造 k 叉哈夫曼树(如下)。

k叉的最佳归并树一定是一棵严格的k叉树,即树中只包含度为k、度为0 的结点。设度为k的结点有 n个,度为0的结点有 n0个,归并树总结点数=n 则:
初始归并段数量+虚段数量=n0
n = n0 + nk
knk=n-1
故得 n 0 = n ( k − 1 ) + 1 n_{0} = n_(k-1)+1 n0=n(k−1)+1, n k = ( n 0 − 1 ) k − 1 n_{k} = \frac{(n_{0}-1)}{k-1} nk=k−1(n0−1)。若 ( n 0 − 1 ) (n_{0}-1) (n0−1)% ( k − 1 ) = 0 (k-1)=0 (k−1)=0,则不需要补充虚段;若 ( n 0 − 1 ) (n_{0}-1) (n0−1)% ( k − 1 ) = u (k-1)=u (k−1)=u,说明多出来u个元素,需要补充 k-u-1 个虚段。
【例题】设外存上有 120 个初始归并段,进行 12 路归并时,为实现最佳归并,需要补充的虚段个数是 (B)。
A.1
B.2
C.3
D.4

1334

被折叠的 条评论
为什么被折叠?



