十一. 排序

排序就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。

排序算法评价指标:

排序算法分类:

  1. 内部排序:数据都在内存中。更关注算法时间和空间复杂度
  2. 外部排序:数据太多,无法全部放入内存。除了要关注算法时间和空间复杂度,还要关注如何使读/写磁盘次数更少。

排序的分类

1. 插入排序

主要分为:直接插入排序、折半插入排序、希尔排序

1.1 插入排序

算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。

动画演示:

插入排序演示

算法实现及分析

排序从第二个元素开始,默认当前排序元素左边已经有序。只要比当前元素大的都往后移动,直到碰到比当前元素小(相等)的。

代码实现:

带哨兵实现方法:

插入排序(哨兵法)

代码实现:

带哨兵排序步骤:

  1. 实际存放元素从1开始。会将当前排序元素复制到A[0]位置。
  2. 接着对比左边元素,比当前元素大的都会后移,当左边某个元素大于或等于当前排序元素时,内层循环结束,将当前排序元素放在j+1的位置。

这样做的优点是不用没轮循环都判断j0

算法效率分析:

算法优化

思路:之前都是用顺序查找方式移动元素,实际可用折半查找找到应该插入的位置,再移动元素

假设下面55之前元素已经有序,则优化插入排序步骤:

插入排序优化

代码实现:

算法效率:

比起"直接插入排序",比较关键字的次数减少了,但是移动元素的次数没变,整体来看时间复杂度依然是O(n2)

直接插入排序可以用于链表的排序。对于链表来说移动元素的次数变少了,但是关键字对比的次数依然是O(n2)数量级,整体来看时间复杂度依然是O(n2)

1.2 希尔排序

希尔排序是对插入排序的优化。对于插入排序来说,如果要排序的元素基本有序,那么排序效率会高很多。而希尔排序核心思想是:先追求表中的元素部分有序,再逐渐逼近全局有序。

实现大致步骤:

  1. 先将待排序表分割成若干形如L[i,i+d,i+2d,,i+kd]的特殊子表。
  2. 再对各个子表分别进行直接插入排序
  3. 每次都缩小增量d,重复上述过程,直到d=1为止。

动画演示:

希尔排序演示

例如:将下列表中元素进行升序排列

希尔排序

步骤:

上面例子中,每次将增量d缩小一半。这要是建议的做法。

代码实现:

上面内层第一个for循环中的++i,会让以d为间隔的子表轮流切换排序。

算法性能分析:

2. 交换排序

主要有:冒泡排序和快速排序

2.1 冒泡排序

冒泡排序和快速排序一样属于交换排序的一种。

交换排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。

冒泡排序

代码实现:

注意:如果某一趟排序过程中未发生"交换"则算法可提前结束。

算法效率分析:

2.2 快速排序

算法实现思路:

在待排序表中任取一个元素作为枢轴(通常取首元素)。通过一趟排序,将待排序表划分为独立的两个部分。这两部分中,左半部分所有元素都小于枢轴元素;右半部分都大于枢轴元素,则枢轴元素确定其最终元素位置。这个过程称为一次划分。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在最终位置上,完成排序。

动画演示如下:

快速排序演示

代码如下:

效率分析:

快速排序效率分析

通过上图可以看出每一层只需要处理剩下蓝色部分待排序元素,所以时间复杂度不超过O(n)。即时间复杂度O(n)

由于需要用到递归,层数越多用到空间越多,所以每一层空间复杂度是O()

可以看出对于快速排序时间与空间复杂度分析必须要研究递归层数:

快速排序效率分析1

可以得出结论:快速排序就是把n个元素组织成二叉树,二叉树的层数就是递归调用的层数。所以可以将问题转换为二叉树高度求法。

n个结点二叉树最小高度=log2n+1;最大高度=n

所以对于快速排序其最小递归层数=log2n+1,最大递归层数=n。即

快速排序是不稳定地算法

注意:若每一次选中的枢轴元素将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高。若初始序列有序或逆序,则快速排序的性能最差(因为每次选择的都是最靠边的元素)。

基于上面分析,快速排序算法优化思路:尽量选择可以把数据中分的枢轴元素。如:

  1. 选头、中、尾三个位置的元素,取中间值作为枢轴元素
  2. 随机选一个元素作为枢轴元素

在实际应用当中快速排序是所有内部排序算法中平均性能最优的排序算法。

3. 选择排序

主要有:简单选择排序和堆排序

3.1 简单选择排序

选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列

选择排序分类:简单选择排序和堆排序。

算法思路:每一趟在待排序元素中选取关键字最小的元素加入有序子序列

动画演示:

简单选择排序

实现代码:

算法效率分析:

适用性:即可用于顺序表,也可以用于链表。

3.2 堆排序

堆排序的实现需要用到堆这种数据结构。堆这种数据结构又可以进一步划分为大根堆小根堆

堆结构和二叉树的顺序存储类似:

完全二叉树

二叉树顺序表中存储结构:

二叉树顺序存储回忆

几个重要操作:

若完全二叉树中共有n个结点,则:

堆排序实现

堆结构也可以看作是一颗完全二叉树在顺序表存储结构:

大根堆

上图左边是存储结构,右边是逻辑结构。可以观察到大根堆就是在完全二叉树中,所有的根结点左右子树

所以堆排序步骤:

  1. 首先将给定的随机序列表按照根结点左右子树特点,将表转换为大根堆结构

    具体做法是检查二叉树中所有根结点是否满足根结点左右子树这一特性,若不满足,与当前结点更大的孩子交换。

  2. 由于大根堆表中第一个元素一定是最大元素,所以将其移动至表尾

  3. 接着对剩下的表中元素再次进行大根堆结构恢复操作。

  4. 依次循环上面三个步骤,直到所有最后表中只剩一个元素,排序结束

例子:给定以下序列表进行升序排序

大根堆排序

排序步骤:

注意:基于大根堆的堆排序,得到是递增序列,而基于小根堆得到的是递减序列

实现代码:

算法效率分析:

由于所有操作都是基于HeadAdjust()这个函数的,所以要分析该函数效率:

由于一个结点每下坠一层,最多只需要对比关键字两次。若树高为h,某结点在第i层,则将这个结点向下调整最多只需要下坠hi层,关键字对比次数不超过2(hi)n个结点的完全二叉树树高h=log2n|+1

i层最多有2i1个结点,而只有第1(h1)层的结点才有可能需要下坠调整。将整棵树调整为大根堆,关键字对比次数不超过:

i=h112i12(hi)=i=h112i(hi)=j=1h12hjj2nj=1h1j2j4n

所以,建堆的过程,关键字对比次数不超过4n,建堆时间复杂度=O(n)

HeapSort()for循环排序过程中总共需要n1趟调整,每趟调整都要将根结点下坠。而每次下坠最多只需要对比关键字2次,因此每一趟排序复杂度不超过O(h)=O(log2n),所以总的时间复杂度是O(nlog2n)

堆的插入

假设以小根堆为例,对于小根堆,新元素放到表尾,与父节点对比,若新元素比父节点更小,则将二者互换。新元素就这样一路上升,直到无法继续上升(根结点比新元素小)为止。

例:将下列小根堆中插入新元素13

堆的插入

插入总共对比关键字3次。

堆的删除

首先从序列表中将被删除元素删除,接着将表尾元素替换到被删除元素位置,再进行小根堆恢复:被删除的元素用堆底元素替代,然后让该元素不断下坠,直到无法下坠为止。

例:将下列小根堆中删除13元素

堆的删除

删除总共对比关键字4次。

4. 归并排序

归并:把两个或多个已经有序的序列合并成一个。

归并排序

首先开辟一个能放入上面两个数组的更大的数组。

归并排序2

对比上面待排序两个数组,将ij所指元素,选择更小的一个放入k所指位置。放入后选中元素的指针(i/j)k指针往后移动。当待排序数组的ij指针中有一个超出数组下标,则说明一个子表已经排序完毕,还剩一个子表未合并,可以将该表中剩余元素全部加到总表。

归并排序3

上面的归并称为二路归并,即每次选出一个子表中最小的元素插入新的排序序列中,这种每次选出一个元素需要对比1次关键字

同样还有四路归并:

四路归并排序

对比p1p2p3p4所指元素,选择更小的一个放入k所指位置。4路归并:每选出一个小元素注需对比关键字3次。

结论:m路归并,每选出一个元素需要对比关键字m1

归并排序模拟:给定以下序列,每个序列都是独立集合。

归并排序模拟

通过上面步骤可以得到归并排序核心操作:把数组内的两个有序序列归并为一个。

代码实现:

算法效率分析:

可以看到上面MergeSort()函数使用了递归进行排序。所有也可以认为二路归并是一棵倒立的二叉树。可以利用二叉树特性分析二路归并排序的算法效率。分治法递归树如下:

分治法递归树

更详细图解:

归并排序例子

二路归并总共需要h1趟。二叉树第h层,最多有2h1个结点,所以若树高为h,则应满足n2h1n表示结点数,即h1=log2n

对上面算法例子进行归并分析:

归并分析

时间复杂度:上面第二趟排序中,将两个序列归并时,所需要关键字对比次数n1,因为每次对比都能挑出两个序列中关键字最小的元素,所以每次对比时间复杂度是O(n)。再看初始序列,每次进行两两对比,每两个运算之间比较次数为一次,所以总共比较次数就是n2,也是O(n)这个量级,所以不管哪一趟归并,每次对比次数都需要O(n)这样的数量级。

空间复杂度为O(n),主要来自于辅助数组B。由于递归深度不会超过log2n这个数量级,所以递归栈的空间复杂度忽略不计。

结论:n个元素进行二路归并排序,归并趟数=log2n每趟归并时间复杂度为O(n),则算法时间复杂度为O(nlog2n)。并且归并排序是一个稳定的算法。其排序效率可以和堆排序、快速排序达到同样的优秀程度。

归并排序总结:

归并排序总结

5. 基数排序

假设长度为n的线性表中每个结点aj的关键字由d元组(kjd1,kjd2,kjd3,,kj1,kj0)组成,这里的kjd1表示最高位关键字,kj0表示最低位关键字。其中

0kjir1(0jn,0id1)

这里的r称为"基数",即代表最每一位可能出现数字个数,正常数字中每位数组可能为09,故基数为10

基数排序得到递减序列的过程如下:

  1. 初始化:设置r个空队列,Qr1,Qr2Q0

  2. 按照各个关键字位权重递增的次序(个>>百),对d个关键字位分别做"分配"和"收集"。

    分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入Qx队列的队尾。

    收集:把Qr1,Qr2Q0各个队列中的结点依次出队并链接。

基数排序模拟:假设给定以下序列,要求得到关键字递减的有序序列:

基数排序

上面数组中关键字基数为r=10,需要建立10个辅助队列。

基数排序2

由此可以看出基数排序不是基于"比较"的排序算法

基数排序实现:基数排序大都基于链式存储结构实现。

算法效率分析:

空间复杂度:需要r个辅助队列,所以空间复杂度=O(r)

时间复杂度:一趟分配O(n),一趟收集O(r),总共d趟分配、收集,总的时间复杂度=O(d(n+r))。而收集一个队列只需要O(1)时间复杂度。收集队列核心代码如下:

同时也可以看出基数排序具有稳定性

基数排序应用:某学校有10000学生,将学生信息按年龄递减排序。生日可拆分为三组关键字:年(19912005)、 月(112)、 日(131)。日期权重为<<,所以根据基数排序要按照权重递增次序进行分配收集。

基数排序应用

上面例子中基数排序的时间复杂度为:O(d(n+r))=O(3(10000+31)=O(30000),这里的d3,因为分为三轮(年、月、日)分配收集,n10000r=max{31,12,15}。若采用堆排序和快排O(nlog2n)≈=140000

基数排序擅长解决的问题:

  1. 数据元素的关键字可以方便地拆分为d组,且d较小。反例:给5个人身份证号排序(这里d=18,而n=5)
  2. 每组关键字的取值范围不大,即r较小。反例:给中文名排序(这里d=2/3/4/,但r=因为姓氏很多)
  3. 数据元素个数n较大。擅长例子:给十亿人身份证号排序。这里虽然d=18,但n=亿,此时相对来说仍能得到很高的效率。

基数排序总结:

基数排序总结

6. 外部排序

磁盘的读/写以"块"为单位,数据读入内存后才能被修改。修改完了还要写回磁盘。

外部排序是指数据元素太多,无法一次全部读入内存进行排序。可以使用归并排序的方法,最少只需在内存中分配3块大小的缓冲区即可对任意大的文在进行排序。这里三个缓冲区大小相等。

6.1 外部排序实现

假如在内存中开辟以下三个缓冲区:

外部排序

同时要排序以下磁盘中的没见,每块大小为1kb

外部排序2

磁盘中分为16块数据。每块中包含3个记录关键字。现在要对磁盘中的记录关键字进行递增排序。

注意:这里每趟归并会开辟一个与两个归并块大小之和的磁盘块,一趟排序结束后之前的磁盘块销毁。且每当输入缓冲区i空时,就立即将对应的归并段i的下一块记录关键字放入,接着继续进行归并排序。

外部排序时间开销分析如下:

外部排序时间开销分析

通过上图不难发现外部排序时间开销=读写外存的时间+内部排序所需时间+内部归并所需时间

上面读写外村时间与读写磁盘次数成正比,上面例子首先初始化归并段读写32次,之后需要3趟归并排序,每趟都要读写32次,总共是32+322=128次。可以看出读写外存时间占比很大。显然上面文件要排序块数是无法改变的,但是可以通过缩短归并趟数达到优化效果。

6.2 外部排序优化

优化方法是使用多路归并

这里以四路归并举例,如果是四路归并,就需要在内存中开辟四个输入缓冲区。之后将四个归并段中的内容读入缓冲区。

外部排序优化

将四个归并段中第一块记录关键字块放入对应的输入缓冲区。

外部排序优化2

之后归并方法与上面一致。同样需要注意的是当一个输入缓冲区i空时,就需要将对应归并段i的下一块记录关键字块补全。

这样在一趟归并完成后就得到两个归并段

外部排序优化3

之后再需要一趟二路归并即可。

效率分析:

外部排序优化4

这样采用4路归并,只需进行两趟归并即可。读、写磁盘次数=32+322=96次。

重要结论:采用多路归并可以减少归并趙数,从而减少磁盘I/O(读写)次数。对r个初始归并段,做k路归并,则归并树可用k叉树表示。若树高为h,则归并趟数=h1=logkr

推导:k叉树第h层最多有kh1个结点,则rkh1(h1)=logkr。所以说k越大,r越小,归并趟数越少,读写,磁盘次数越少。

当然也不并不是k路归并的k越大越好,k路归并带来的负面影响:

  1. k路归并时,需要开辟k个输入缓冲区,内存开销增加。
  2. 每挑选一个关键字需要对比关键字(k1)次,内部归并所需时间增加。

除了增加k,还可以减少r初始段数量。所以若能增加初始归并段的长度(输入缓冲区个数),则可减少初始归并段数量。即k越大,r越小,归并趟数越少,读写,磁盘次数越少。

外部排序总结:

外部排序总结

注:按照本节介绍的方法生成的初始归并段,若共N个记录,内存工作区可以容纳L个记录,则初始归并段数量r=N/L。可以通过败者树,减少关键字对比次数,并减少k路归并时k太大造成影响。另外可以通过置换选择排序进一步减少初始归并段的数量。

这里补充k路平衡归并,满足k路平衡归并条件如下:

  1. 最多只能有k个段归并为一个
  2. 每一趟归并中,若有m个归并段参与归并,则经过这一趟处理得到m/k个新的归并段

四路平衡归并

如上图仅仅是四路归并树,而不是四路平衡归并树。因为第一趟8个归并段经过一趟处理后得到3个新的归并段,而对于四路平衡归并树来说如果一趟有8个归并段参与的话就是8/4=23。四路平衡归并树如下:

外部排序优化4

6.3 败者树

上面外部排序优化可以得出增加k,可以提高外部排序效率,但是会增加对比次数,所以本节的败者树可以从k个归并段中挑出最小关键字对比次数更少。

败者树可以视为一棵完全二叉树(根节点上面又多了一个根节点),k个叶节点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的失败者,而让胜者往上继续进行比较,一直到根节点。

败者树在多路平衡归并中的应用:

败者树

有如上图所示的八个归并段。要从这八个归并段中每次选出最小的记录关键字。传统方式,每次都需要进行七次对比才能得到一个最小关键字,而采用败者树方式如下:

败者树2

将每个归并段的第一个元素放入叶子结点。接着就需要构造一棵败者树。构造方法是:每次底部两个叶子结点进行对比,较小的晋级到父结点,但是这里晋级到父结点后并不是记录这个关键字的值,而是记录更小关键字来自哪个归并段。

第一趟对比如下:

败者树3

结果是归并段3中的记录关键字1最小。

第二趟将归并段3中记录关键字6补齐叶子结点:

败者树4

之后6会和父结点进行对比,依次晋级。先和父结点4(表示归并段4中的记录关键字17)对比,显然更小晋级,和归并段2关键字12对比,胜出。最后和归并段5关键字2对比,失败,受的败者树如下:

败者树5

本轮关键字归并段5中的关键字2最小,所以胜出。本次对比进行三次。

归并段

之后归并段5关键字3补齐叶子结点,继续进行上述对比即可

结论:对于k路归并,第一次构造败者树需要对比关键字k1。有了败者树,选出最小元素,只需要对比关键字log2k次即可。

代码实现:

八路归并情况下只需要对应一个长度为8的数组,数组下标为1对应上面败者树传统根节点,0号数组下标对应冠军结点。这里要注意上面的叶子结点是虚拟结点,实际结构中不存在的。

总结:败者树解决的问题是使用多路平衡归并可减少归并趟数,但是用老土方法从k个归并段选出一个最小/最大元素需要对比关键字k1次,构造败者树可以使关键字对比次数减少到log2k

败者树可视为一棵完全二叉树(多了一个头头)。k个叶结点分别对应k个归并段中当前参加比较的元素,非叶子结点用来记忆左右子树中的"失败者",而让胜者往上继续进行比较,一直到根结点。

败者树6

上图归并段有5个,所以数组长度是ls[5]14对应的是失败者结点,而0号对应的是胜利者结点。而对于下面的归并段的编号是b0b1。上面败者树对应的ls数组如下:

败者树数组

上面败者树建立后,后序选出一个更小关键字最多只需要log2k=3次。也有可能是两次,因为可以新填补的元素是在b0b1b2三个结点上。

6.4 置换-选择排序

之前讲过进行k路归并时需要S趟,S=logkr,如果能让初始归并的段变少,即让r减少,则外部排序效率可以进一步提升。可以用置换-选择排序进一步减少初始归并段数量。

实现原理:在用于内部排序的内存工作区中,可以容纳l(之前的例子都是对应3个记录关键字)个记录,则每个初始归并段也只能包含l个记录,若文件共有n个记录,则初始归并段的数量r=n/l。可以用置换-选择排序扩大内存工作区。

设初始待排文件为FI,初始归并段输出文件为FO,内存工作区为WAFOWA的初始状态为空,WA可容纳w个记录。置换选择算法的步骤如下:

  1. FI输入w个记录到工作区WA
  2. WA中选出其中关键字取最小值的记录,记为MINIMAX记录。
  3. MINIMAX记录输出到FO中去。
  4. FI不空,则从FI输入下一个记录到WA中。
  5. WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的MINIMAX记录。
  6. 重复35,直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中去。
  7. 重复26,直至WA为空。由此得到全部初始归并段。

实现模拟:假设有以下待排序文件:

置换选择排序

这里假设用于内部排序的内存工作区只能容纳3个记录。按照递增顺序排序:

可以看到上个例子中得到的三个初始归并段可以超过内存工作区的限制。每个归并段所包含的关键字越多,r值越小,外部排序归并效率越高。

注意:这里的输出文件FO是存方在磁盘中的,真正的执行是内存工作区WAFO之间会有输出缓冲区,当输出缓冲区满了之后才会将置换的关键字全部放入FO中。同样的初始待排序文件FI输入到内存工作WA过程也是一样。

6.5 最佳归并树

上面的学习可以知道如果使用置换-选择排序构造初始归并段,这些初始的归并段长度可能各不相同。

下面有一个初始归并段:

最佳归并树

这里R1表示使用置换-选择排序构造初始归并段后需要2个磁盘块,对应的R2归并段需要5个磁盘块。可以看出各个初始归并段之间的差异很大。

上面初始归并段中,如果R2R3合并,需要读/写磁盘次数为5+1=6次。之后构造一棵二叉归并树如下:

构造二叉归并树

这颗归并树的读/写次数为6+8+14+16=44次。而WPL=2×1+(5+1+6+2)×3=44,也是44。这里WPL指叶子结点权值到根节点路径长度,上面5到根节点16长度是3,所以是5×3

可以得出一个重要结论:归并过程中的磁盘I/O次数=归并树的WPL2。可以想到如果想构造一棵I/O次数少,即WPL长度短的树,可以构造一棵哈夫曼树。上面初始归并段构造成哈夫曼树如下:

最佳归并树-哈夫曼树

最佳归并树WPLmin=(1+2)×4+2×3+5×2+6×1=34。故读磁盘次数=写磁盘次数=34次。总的磁盘I/O次数=68

上面是二路归并情况,以下是多路归并:

多路归并初始段

上面初始归并段含义同上。假设现在要采用3路归并策略,按照之前传统的方法归并结果如下:

三路归并

这个归并树的WPL=(9+30+12+18+3+17+2+6+24)×2=242,归并过程中磁盘I/O总次数=484次。

显然这不是最佳归并树,三路归并树构造和二路类似:每次选出三个权值最小的结点构造成树即可。上面出时段构造哈夫曼树如下:

多路最佳归并树

归并树WPLmin=(2+3+6)×3+(9+12+17+24+18)×2+30×1=223。归并过程中磁盘I/O总次数=446次。

但是如果减少一个归并段,即有8个归并段构造三路归并树,最后会进行一次二路归并,这显然违反哈夫曼树定义,所以是不正确的构造方法,解决方法是再添加一个权值为0的归并段即可。

构造哈夫曼树正确做法

构造成3路哈夫曼树如下:

构造哈夫曼树正确做法2

这棵树的WPLmin=(2+3+0)×3+(6+9+12+17+18)×2+24×1=163。归并过程中磁盘I/O总次数=326次。

结论:对于k(大部分情况下是k>2)叉归并,若初始归并段的数量无法构成严格的k叉归并树,则需要补充几个长度为0的"虚段",再进行k叉哈夫曼树的构造。

这里的虚段本质是,三个输入缓冲区中一个为NULL即代表虚段。

再接着深入分析:k叉的最佳归并树一定是一棵严格的k叉树,即树中只包含度为k、度为0的结点。设度为k的结点有nk个,度为0的结点有n0个,归并树总结点数=n,则+=n0

另外根据k叉树性质:n=n0+nk;k·nk=n1。这里k·nk是分分叉总数量,等于总结点树减去一个根结点。从而可知n0=(k1)nk+1nk=(n01)(k1),所以说如果是"严格k叉树",一定能除得尽。

所以,判断初始段序列是否需要添加虚段方式:

  1. (1)%(k1)=0,说明刚好可以构成严格k叉树,此时不需要添加虚段。
  2. (1)%(k1)=u0,则需要补充(k1)u个虚段即可。

:假设现在需要进行8路归并,如果初始归并段数量=19,需要添加几个虚段?

:(191)%(81)=40(81)4=3

最佳归并树总结:

最佳归并树总结