第八章 排序
排序作为各类数据结构的相应的运算的一种,在很多领域中都有广泛的应用。主要的排序方法有插入排序、交换排序、选择排序、二路归并排序、基数排序、外部排序等各类排序方法。堆排序、快速排序和归并排序是本章的重难点,应深入掌握各种排序算法的思想、排序过程(能动手模拟)和特征(初态的影响、复杂度、稳定性、适用性等)。
本章同样作为考察重点章节,通常以选择题的形式考查不同算法之间的对比。此外,对于一些常用排序算法的关键代码,要达到熟练编写的程度:看到某特定序列,读者应具有选择最优排序算法的能力。
【考点】①排序的基本概念; ②插入排序; ③交换排序法;
④选择排序法; ⑤归并排序; ⑥基数排序;
⑦外部排序; ⑧内部排序方法的性能对比。
【本章大纲】
【目录】
一、排序的相关概念
【排序】将一组杂乱无章的数据按一定规律(关键字排序)顺次排列起来,以便于查找。
【内部排序】若待排序记录都在内存中,称为内部排序。
【外部排序】若待排序记录一部分在内存,一部分在外存,则称为外部排序。外部排序时,要将数据分批调入内存来排序,中间结果还要及时放入外存,显然外部排序要复杂得多。
【算法性能衡量】
①时间效率:反映出排序速度(比较次数与移动次数);
②空间效率:反应出占内存辅助空间的大小;
③稳定性为A和B的关键字相等,排序后A、B的先后次序保持不变,则称这种排序算法是稳定的。
二、插入排序
插入排序的基本思想为每步将一个待排序的对象,按其关键码大小,插入到前面已经排好序的一组对象的适当位置上,直到对象全部插入为止。即边插入边排序,保证子序列中随时都是排好序的。
2.1 直接插入排序(基于顺序查找)
【算法思想】整个排序过程为n-1趟插入,即先将序列中第1个记录看成是一个有序子序列,然后从第2个记录开始,逐个进行插入,直至整个序列有序。
【例子】对序列(13,6,3,31,9,27,5,11)进行增序排列。
①将元素13作为第一个记录,将元素13与元素6比较,13>6,插入至元素13前;
②将元素3与元素6比较,3<6,插入至元素6前;
③以此循环,直至最后成为一个有序序列。
【算法描述】
void InsertSort(SqList &L)
{int i,j;
for(i=2;i<=L.length;++i)
if( L.r[i].key<L.r[i-1].key)//将L.r[i]插入有序子表
{ L.r[0]=L.r[i]; // 复制为哨兵
L.r[i]=L.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]; //插入到正确位置
}
}
【算法分析】设对象个数为n,则执行n-1趟,比较次数和移动次数与初始排列有关。
①最好情况下,每趟只需比较 1 次,不移动,总比较次数为 n-1;
②最坏情况下:第 i 趟比较i次,移动i+1次;
③若出现各种可能排列的概率相同,则可取最好情况和最坏情况的平均情况,时间复杂度为 O(n2),空间复杂度为O(1),是一种稳定的排序方法。
2.2 折半插入排序(基于折半查找)
【算法思想】假设待排序的记录存放在数组r[1 … n]中,r[1 ]是一个有序序列。
①循环n-1次,每次用折半查找法,查找r[i] (i=2, …,n)在已排好序的序列r[1..i-1]中的插入位置;
②然后将r[i]插入表长为i-1的有序序列r[1 …i-1], 直到将r[n]插入表长为n-1的有序序列r[1… n-1] , 最后得到一个表长为n的有序序列。
【算法描述】
void Binsert Sort(SqList &L)//对顺序表L做折半插入排序
{
for (i=2; i < =L. length; ++i)
L.r[O)=L.r[i); //将待插人的记录暂存到监视哨中
low=l;high=i-1;// 置查找区间初值
while(low<=high) //在r[low .. high]中折半查找插入的位置
m=(low+high)/2; //折半
if(L.r[0] .key<L.r[m) .key)
high=m-1; //插入点在前一子表
else low=m+l; //插入点在后一子表
}
for (j=i-1;j>=high+1; --j)
L.r[j+l1=L.r(j]; //记录后移
L.r[high+1]=L.r[0]; //将r[]即原r[i], 插入到正确位置
}
}
【算法分析】
①折半插入排序的时间复杂度仍为 O(n2);折半插入排序所需附加存储空间和直接插入排序相同,只需要一个记录的辅助空间,所以空间复杂度为O(1);
②折半插入算法是一种稳定的排序算法。
【算法特点】
①因为要进行折半查找, 所以只能用于顺序结构,不能用于链式结构;
②适合初始记录无序、n较大时的情况。
2.3 希尔排序(基于逐趟缩小增量)
【算法思想】先将整个待排记录序列分割成若干子序列,分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。
【算法技巧】子序列的构成不是简单地“逐段分割”,将相隔某个增量dk的记录组成一个子序列,让增量dk逐趟缩短(例如依次取5,3,1),直到dk=1为止。
【例子】对关键字序列 T=(49,38,65,97, 76, 13, 27, 49*,55, 04),按照增量d1=5、d2=3、d3=1,进行关键字递增排序。如下:
①在初试状态下,序列排序如下:
②当d1=5时,r[1]与r[6]所对应的元素比较、r[2]与r[7]所对应的元素比较、r[3]与r[8]所对应的元素比较、r[4]与r[9]所对应的元素比较、r[5]与r[10]所对应的元素比较,按照递增顺序排序,结果如下所示:
③当d2=3时,r[1]、r[4]与r[7]所对应的元素比较;r[2]、r[5]与r[8]所对应的元素比较、r[3]、r[6]与r[9]所对应的元素比较、最后r[10]不参与比较,按照递增顺序排序,结果如下所示:
④当d3=1时,r[1]与r[2]所对应的元素比较、r[2]与r[3]所对应的元素比较、r[3]与r[4]所对应的元素比较…,按照递增顺序排序,结果如下所示:
【算法描述】
void ShellSort(SqList &L,int dlta[ ],int t){
//按增量序列dlta[0…t-1]对顺序表L作Shell排序
for(k=0;k<t;++k)
ShellInsert(L,dlta[k]);// dk值依次装在dlta[t]中
//增量为dlta[k]的一趟插入排序
}
void ShellInsert(SqList &L,int dk) {
//对顺序表L进行一趟增量为dk的Shell排序,dk为步长因子
for(i=dk+1;i<=L.length; ++ i) //开始将r[i] 插入有序增量子表
if(r[i].key < r[i-dk].key) {
r[0]=r[i];//暂存在r[0]
for(j=i-dk; j>0 &&(r[0].key<r[j].key); j=j-dk)
r[j+dk]=r[j];//关键字较大的记录在子表中后移
r[j+dk]=r[0];//在本趟结束时将r[i]插入到正确位置
}
}
【算法分析】①时间复杂度是n和d的函数,按照经验而言,时间复杂度一般为
②空间复杂度为 o(1);
③希尔排序是一种不稳定的排序方法。
【算法特点】①dk 值较大,子序列中对象较少,速度较快;
②dk 值逐渐变小,子序列中对象变多,但大多数对象已基本有序,所以排序速度仍然很快;
③该算法中,最后一个增量值必须为1且不能在链式存储结构上实现。
【算法优点】①小元素跳跃式前移,且最后一趟增量为1时,序列已基本有序;
②平均性能优于直接插入排序。
三、交换排序
交换排序的基本思想为两两比较,如果发生逆序则交换,直到所有记录都排好序为止。
3.1 冒泡排序
【算法思想】每趟不断将记录两两比较,并按“前小后大” 规则交换
【例子】 对关键字序列 T=( 21,25,49, 25*,16, 08),进行关键字递增排序(25*代表第二次出现关键字25)。如下:
①第一趟,21与25比较:不需交换;25与49比较:不需交换;49与25*比较:进行交换;49与16进行比较:需要交换;49与08比较:需要交换。
即第一趟最终结果为{21,25,25*,16, 08 ,(49)}。
②第二趟,21与25比较:不需交换;25与25*比较:不需交换;25*与16比较:进行交换;25*与08进行比较:需要交换。
即第二趟最终结果为{21,25, 16, 08 ,( 25*,49)}。
③第三趟,21与25比较:不需交换;25与16比较:进行交换;25与08比较:进行交换。
即第三趟最终结果为{21,16, 08 ,( 25, 25*,49)}。
④第四趟,21与16比较:进行交换;21与08比较:进行交换。
即第四趟最终结果为{16,08 ,( 21, 25, 25*,49)}。
⑤第五趟,16与08比较:进行交换。
即第四趟最终结果为{08 ,(16,21, 25, 25*,49)}。此时未排序元素只剩1个,排序结束,因每次比较交换后,最大关键字都会被排序,过程像吐泡泡,由上至下从大到小,故称为冒泡排序。
【算法描述】
void bubble_sort(SqList &L)
{ int m,i,j,flag=1; RedType x;
m=n-1;
while((m>0)&&(flag==1))
{ flag=0;
for(j=1;j<=m;j++)
if(L.r[j].key>L.r[j+1].key)
{ flag=1;
x=L.r[j];L.r[j]=L.r[j+1];L.r[j+1]=x; //交换
}//endif
m--;
}//endwhile
}
【算法分析】设对象个数为n,比较次数和移动次数与初始排列有关。
①最好情况下:只需 1趟排序,比较次数为 n-1,不移动;
②最坏情况下:需 n-1趟排序,第i趟比较n-i次,移动3(n-i)次
③时间复杂度为 o(n2),空间复杂度为 o(1);
④冒泡排序是一种稳定的排序方法。
【算法优点】①每趟结束时,不仅能挤出一个最大值到最后面位置,还能同时部分理顺其他元素;
②一旦某趟没有交换,提前结束排序。
3.2 快速排序
【算法思想】快速排序需满足以下几点:
①任取一个元素 (如第一个) 为中心;
②所有比它小的元素一律前放,比它大的元素一律后放,形成左右两个子表;
③对各子表重新选择中心元素并依此规则调整,直到每个子表的元素只剩一个。
【例子】 对关键字序列 T=( 49,38,65,97,76,13,27,49),进行关键字递增排序,如下:
①初始,第一个关键字作为届点(中心),low指针指向位置1,high指针指向位置8;
②此时high对应的值与届点相同,high--;
③此时届点<high,low=high,high位置置空;
④此时届点>low,low++;
⑤此时届点>low,low++;
⑥此时届点<low,high=low,low位置置空;
⑦此时届点<high,high--;
⑧此时届点>high,low=high.high置空;
⑨此时届点>low,low++;
⑩此时届点<low,high=low,low置空;
⑪此时届点<low,high--;
⑫此时届点<low,high--,此时low与high所指位置相同,对比结束,将届点填入此位置;至此位置,第一趟比较结束,结果为{(27,38,13),49,(76,97,65,49)}。
第二趟,将位置1作为届点,按照以上方法进行比较,直到每个子表的元素只剩一个,排序结束。最终结果为:
【算法描述】
void QSort ( SqList &L,int low, int high )
{ if ( low < high )
{ pivotloc = Partition(L, low, high ) ;
Qsort (L, low, pivotloc-1) ;
Qsort (L, pivotloc+1, high )
}
}
int Partition ( SqList &L,int low, int high )
{ L.r[0] = L.r[low]; pivotkey = L.r[low].key;
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];
}
L.r[low]=L.r[0];
return low;
}
【算法分析】
①最好情况为划分后,左侧右侧子序列的长度相同;
②最坏情况为从小到大排好序,递归树成为单支树,每次划分只得到一个比上一次少一个对象的子序列,必须经过 n-1 趟才能把所有对象定位,而且第 i 趟需要经过 n-i 次关键码比较才能找到第 i 个对象的安放位置。
③时间效率: 因每趟确定的元素呈指数增加,因此时间复杂度为O(nlog2n);空间效率:因递归要用到栈空间,因此空间复杂度为O(log2n)。
④因可选任一元素为支点,所以快速排序算法为不稳定算法。
【算法特点】
①每一趟的子表的形成是采用从两头向中间交替式逼近法;
②由于每趟中对各子表的操作都相似,可采用递归算法;
③快速排序算法可以证明,平均计算时间是O(nlog2n)。平均计算时间而言,快速排序是我们所讨论的所有内排序方法中最好的一个;
④快速排序是递归的,需要有一个栈存放每层递归调用时参数(新的low和high);
⑤最大递归调用层次数与递归树的深度一致,因此,要求存储开销为 O(log2n) 。
四、选择排序
选择排序的基本思想为第i 趟中就是在后面 n-i +1个记录中选出关键码最小的对象, 作为有序序列的第 i 个记录。
4.1 简单选择排序
【算法思想】
①设待排序的记录存放在数组r[l… n]中。第一趟从r[I]开始,通过n-1次比较,从n个记录中选出关键字最小的记录,记为r[k], 交换r[l]和r[k]。
②第二趟从r[2]开始,通过n-2次比较,从n-1个记录中选出关键字最小的记录,记为r[k],
交换r[2]和r[k] 。
③依次类推,第l趟从r[i]开始,通过n-i次比较,从n-i+l个记录中选出关键字最小的记录,记为r[k], 交换r[i]和r[k]。
④经过n-1趟,排序完成。
【例子】 对关键字序列 T=( 21,25,49,25*,16,08),进行关键字递增排序,如下:
①第一趟,未排序序列中最小者为08,交换21与08 ,结果为{(08),25,49,25*,16,21};
②第二趟,未排序序列中最小者为16,交换25与16 ,结果为{(08,16),49,25*,25,21} ;
③第三趟,未排序序列中最小者为21,交换49与21 ,结果为{(08,16,21),25*,25,49};
④第四趟,未排序序列中最小者为25,无需交换,结果为{(08,16,21,25*),25,49};
⑤第五趟,未排序序列中最小者为25,无需交换 ,结果为{(08,16,21,25*,25),49};
⑥至此,排序结束,最终结果为{(08,16,21,25*,25,49)}。
【算法描述】
void SelectSort(SqList &K)
{
for (i=1; i<L.length; ++i)
{ //在L.r[i..L.length] 中选择key最小的记录
k=i;
for( j=i+1;j<=L.length ; j++)
if ( L.r[j].key <L.r[k].key) k=j;
if(k!=i)L.r[i]←→L.r[k];
}
}
【算法分析】 ①移动次数,在最好情况下为0次,最坏情况下为3(n-1)次;
② 比较次数为
③简单排序的时间复杂度:O(n²),空间复杂度:O(1);
④简单排序算法是稳定的排序算法。
4.2 堆排序
4.2.1 堆排序的相关概念
【堆】n个元素的序列{k1,k2,…,kn},当且仅当满足下列关系时,成为堆:
如果将序列看成一个完全二叉树,非终端结点的值均小于或大于左右子结点的值。
【大根堆】在堆中,堆顶元素(或完全二叉树的根)必为序列中 n个元素的最大值,称之为大根堆。
【小根堆】在堆中,堆顶元素(或完全二叉树的根)必为序列中 n个元素的最小值,称之为小根堆。
4.2.2 堆排序算法
对于堆排序算法最主要问题的如何建?如何调整?两大问题。
【算法思想】
①将无序序列建成一个堆,输出堆顶的最小(大)值,使剩余的n-1个元素又调整成一个堆,则可得到n个元素的次小值;
②重复执行,得到一个有序序列。
【建堆例子】 对关键字序列 T=( 30,60,8,40,70,12,10),进行大根堆的建立,如下:
①第一趟,按照排序序列顺序建立一个堆,如图所示。
②第二趟,从第⌊ n/2⌋=3个元素起,进行筛选(第⌊ n/2⌋个元素为最后一个非叶子结点的标号),12>8且12>10,因此交换12为该堆的根节点;
③第三趟,对第2个元素,进行筛选,70>40且70>60,因此交换70为该堆的根节点;
④第四趟,对第1个元素,进行筛选,70>30且70>12,因此交换70为该堆的根节点;
⑤第五趟,经过上次的交换排序,30所在的小堆不符合大根堆的性质,要对此小堆再次进行调整,60>40且60>30,因此60作为此小堆的根,最终建堆结果如下:
【堆的重新调整思想】
①输出堆顶元素后,以堆中最后一个元素替代之;
②将根结点与左、右子树根结点比较,并与小者交换;
③重复直至叶子结点,得到新的堆。
【堆的调整例子】对于上述所建立的堆,进行有序输出过程如下。
①第一次调整,将元素70与元素10交换,交换后(忽略70):
60>10且60>12,所以,将60作为新的根结点,
当60与10交换后,因以10为根的小堆不符合大根堆的定义,40>10且40>30,因此继续调整将40作为小堆的根节点,第一次调整的最终结果如下所示:
②第二次调整,将元素8与60交换,换后(忽略60、70):
40>8且40>12,所以,将40作为新的根结点,
当40与8交换后,因以8为根的小堆不符合大根堆的定义,30>10且30>8,因此继续调整将43作为小堆的根节点,第二次调整的最终结果如下所示:
③第三次调整,将元素8与40交换,换后(忽略40、60、70):
30>8且30>12,所以,将30作为新的根结点,
当30与8交换后,因以8为根的小堆不符合大根堆的定义,10>8,因此继续调整将10作为小堆的根节点,第二次调整的最终结果如下所示:
④按照以上步骤,进行不断调整,最后的结果如下所示:
最终按照层序输出,有序结果序列为{8,10,12,30,40,60,70}。
【算法分析】①堆排序的时间效率:O(nlog2n) ,空间效率:O(1);
②堆排序算法因对元素的不断交换,因此该算法是不稳定的;
③堆排序算法适用于n 较大的情况。
五、归并排序
5.1 归并的相关概念
【归并】将两个或两个以上的有序表组合成一个新有序表
5.2 2-路归并排序
【算法思想】初始序列看成n个有序子序列,每个子序列长度为1:
①两两合并,得到⌊n/2⌋个长度为2或1的有序子序列;
②再两两合并,重复直至得到一个长度为n的有序序列为止。
【排序例子】对关键字序列 T=( 49,38,65,97,76,13,27),按照递增顺序,进行归并排序,如下:
①第一趟,49与38、65与97、76与13、27进行比较,结果如下:
[38 49] [65 97] [13 76] [27]
②第二趟,将[38 49]与[65 97]、[13 76]与[27]进行比较,结果如下:
[38 49 65 97] 与 [13 27 76]
③第三趟,将 [38 49 65 97] 与 [13 27 76]比较,最终结果如下:
[13 27 38 49 65 76 97]
【算法分析】①二路归并排序短发的时间效率为O(nlog2n) ,空间效率为O(n);
②该算法在比较过程中,遇到相同元素是不进行交换的,因此该算法是稳定的。
六、基数排序
前面的排序方法主要通过关键字值之间的比较和移动,而基数排序不需要关键字之间的比较。
【多关键字排序】多关键字的排序主要分为两大类:最高位优先(MSD)和最低位优先(LSD);
【链式基数排序】用链表作存储结构的基数排序。
6.1 最高位优先的基数排序
【算法思想】①先对最高位关键字k1(如花色)排序,将序列分成若干子序列,每个子序列有相同的k1值;
②然后让每个子序列对次关键字k2(如面值)排序,又分成若干更小的子序列;
③依次重复,直至就每个子序列对最低位关键字kd排序,就可以得到一个有序序列。
【特点】十进制数比较可以看作是一个多关键字排序。
【例子】对关键字序列 T=( 278, 109,063,930,184,589 ,269,008,083),按照最高位优先,进行基数排序,如下:
①先按百位进行排序,结果为(008,063,083,109,184,269,278,589,930);
②按照上步的排序结果,按十位进行排序,结果为(008,063,083,109,184,269,278,589,930)。
6.2 最低位优先的基数排序
【算法思想】①首先依据最低位排序码Kd对所有对象进行一趟排序;
②再依据次低位排序码Kd-1对上一趟排序结果排序;
③依次重复,直到依据排序码K1最后一趟排序完成,就可以得到一个有序的序列。
【特点】这种方法不需要再分组,而是整个对象组都参加排序。
【例子】对关键字序列 T=( 278, 109,063,930,184,589 ,269,008,083),按照最低位优先,进行基数排序,如下:
①先按百位进行排序,结果为(930,063,083,184, 278, 008,109, 589,269);
②按照上步的排序结果,按十位进行排序,结果为(008,109,930, 063,169,278,083,184,589);
③按照②中排序结果,按照百位进行排序,结果为(008,063,083,109,169,184,278,589, 930)。
6.3 链式基数排序
【先决条件】①知道各级关键字的主次关系;
②知道各级关键字的取值范围。
【算法思想】
①首先对低位关键字排序,各个记录按照此位关键字的值‘分配’到相应的序列里;
②按照序列对应的值的大小,从各个序列中将记录‘收集’,收集后的序列按照此位关键字有序;
③在此基础上,对前一位关键字进行排序。
【算法步骤】设置10个队列,f[i]和e[i]分别头指针和尾指针
①第一趟分配对最低位关键字(个位)进行,改变记录的指针值,将链表中记录分配至10个链队列中,每个队列记录的关键字的个位相同;
②第一趟收集是改变所有非空队列的队尾记录的指针域,令其指向下一个非空队列的队头记录,重新将10个队列链成一个链表;
③重复上述两步,进行第二趟、第三趟分配和收集,分别对十位、百位进行,最后得到一个有序序列。
【例子】对关键字序列 T=( 278, 109,063,930, 589 , 184,505,269,008,083),按照链式基数排序,如下:
①第一趟,278尾数为8放在f[8]中,109尾数为9放在f[9]中….以此类推,第一趟分配结果如下:
②第二趟,930的第二位为3放在f[3]后,063的第二位为5放在f[6]…以此类推,第二趟分配的结果如下:
③第三趟,505的首位为5放在f[5]后,008的首位为0放在f[0]…以此类推,第三趟分配的结果如下:
【算法分析】该算法需要重复执行d趟“分配”与“收集”,每趟对 n 个记录进行“分配”,对rd个队列进行“收集”,需要增加n+2rd个附加链接指针。
①链式基数排序算法的时间效率为O(d( n+rd)) ,空间效率为O(n+rd);
②基数排序算法是稳定算法。
七、外部排序
【算法思想】外部排序由相对独立的两个步骤组成:
①按可用内存大小,利用内部排序方法,构造若干个记录的有序子序列写入外存,通常称这些记录的有序子序列为 “归并段”;
②通过“归并”,逐步扩大(记录的)有序子序列的长度,直至外存中整个记录序列按关键字有序为止。
【分析】
①外排总的时间还应包括内部排序所需时间和逐趟归并时进行内部归并的时间。
外部排序总时间=产生初始归并段的时间(m*tIS)+外存信息读写时间 (d*tIO)+内部归并所需时间(s*utmg);
②tIO值取决于外存,远远大于tIS和tmg。 外部排序的时间取决于读写外存的次数d。
八、各类算法比较
排序方法 | 平均时间 | 比较次数 | 移动次数 | 稳定性 | 附加存储(空间复杂度) | ||
最好 | 最差 | 最好 | 最差 | ||||
直接插入 | n2 | n | n2 | 0 | n2 | √ | 1 |
折半插入 | n2 | nlog2n | 0 | n2 | √ | 1 | |
希尔排序 | n1.3 | 0 | × | 1 | |||
冒泡排序 | n2 | n | n2 | 0 | n2 | √ | 1 |
快速排序 | nlog2n | nlog2n | n2 | nlog2n | n2 | × | log2n |
简单排序 | n2 | n2 | 0 | n | √ | 1 | |
堆排序 | nlog2n | nlog2n | nlog2n | × | 1 | ||
归并排序 | nlog2n | nlog2n | nlog2n | √ | n | ||
基数排序 | d(n+rd) | √ | n+rd |