数据结构与算法分析读书笔记系列07-排序

Table of Contents

1 预备知识

我们描述的算法都将是可以互换的。每个算法都将接收一个含有元素的数组和一个包含元素个数的整数。

我们将假设N是传递到我们到排序例程中的元素个数,它已经被检查过,是合法的。按照C的约定,对于所有的排序,数据都将在位置0处开始。

我们还假设“<”和">"运算符存在,它们可以用于将相容的序放到输入中。除赋值运算符外,这两种运算是仅有的允许对输入数据进行的操作。在这些条件下的排序叫做基于比较的排序(comparison-based sorting)。

2 插入排序

2.1 算法

最简单的排序算法之一是插入排序(insertion sort)。插入排序由N-1趟(pass)排序组成。对于P=1趟到P=N-1趟,插入排序保证从位置0到位置P上饿元素为已排序状态。

sort01.png

2.2 分析

由于嵌套循环的每一个都花费N次迭代,因此插入排序为O(N2),而且这个界是精确的,因为以反序输入可以达到该界。

另一方面,如果输入数据已预先排序,那么运行时间为O(N),因为内层for循环的检测总是立即判断不成立二终止。

3 一些简单排序算法的下界

成员存数的数据的一个逆序(inversion)是指数组中具有性质i<j但A[i] > A[j]的序偶(A[i],A[j])。注意,这正好是需要由插入排序(非直接)执行的交换次数。情况总是这样,因为交换两个不按原序排列的相邻元素恰好消除一个逆序,而一个排过序的数组没有逆序。由于算法中还有O(N)项其他的工作,因此插入排序的运行时间是O(I+N),其中I为原始数组中逆序数。于是,若逆序数是O(N),则插入排序以线性时间运行。

我们可以通过计算排列中的平均逆序数而得出插入排序平均运行时间的精确的界。

定理1:N个互异数的数组的平均逆序数是N(N-1)/4。

定理2:通过交换相邻元素进行排序的任何算法平均需要Ω(N2)时间。

这个下界的例子,它不仅对非显式地实施相邻元素的交换的插入排序有效,而且对诸如冒泡排序和选择排序等其他一些简单算法也是有效的。同时这个下界告诉我们,为了使排序算法以亚二次(subquadratic)或o(N2)时间运行,必须执行一些比较,特别要对相距较远的元素进行交换。一个排序算法通过删除逆序得以向前进行,而为了有效地运行,它必须每次交换删除不止一个逆序。

4 希尔排序

希尔排序(Shellsort)的名称源于它的发明者Donald Shell,正如上节所提到的,它通过比较相距一定间隔的元素来工作各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止。由于这个原因,希尔排序有时也叫做缩小增量排序(diminishing increment sort)。

sort02.png

一趟hk 排序的作用就是对hk 个独立的子数组执行一次插入排序。

增量序列的一种流行(但是不好)的选择死使用Shell建议的序列:ht =N/2和hk = hk+1/2。

sort03.png

虽然希尔排序编程简单,但是,其运行时间的分析则完全是另外一回事。希尔排序的运行时间依赖于增量序列的选择,而证明可能相当复杂。

定理:使用希尔增量时希尔排序的最坏运行时间为Θ(N2)。

Hibbard提出一个稍微不同的的增量序列,它在实践中(并且理论上)给出更好的结果。它的增量形如1,3,7,…,2k-1。虽然这些增量几乎是相同的,但是关键的区别是相邻的增量没有公因子。

定理:使用Hibbard增量的希尔排序的最坏运行时间为Θ(N3/2)。

使用Hibbard增量的希尔排序平均情形运行时间基于模拟的结果被认为是O(N5/4),但是没有人能够证明该结果。

Sedgewick提出了几种增量序列,其最坏情形运行时间(也是可以达到的)为O(N4/3)。对于这些增量序列的平均运行时间猜测为O(N7/6)。

关于希尔排序还有几个其他结果,它们需要数论和组合数学中一些艰深的定理而且主要是在理论上有用。希尔排序是算法非常简单且又具有极其复杂的分析的一个好例子。

希尔排序的性能在实践中是完全可以接受的,即使是对于数以万计的N仍是如此。编程的简单特点使得它成为对适度地大量的输入数据经常选用的算法。

5 堆排序

正如优先队列那一节提到的,优先队列可以用于花费O(NlogN)时间的排序。基于该想法的算法叫做堆排序(heapsort)。虽然堆排序的大O运行时间优于前面介绍的排序。然而,在实践中它却慢于使用Sedgewick增量序列的希尔排序。

建立N个元素的二叉堆,此时的花费是O(N)时间。然后我执行N次DeleteMin操作。按照顺序,最小的元素先离开该堆。通过将这些元素记录到第二个数组然后再将数组拷贝回来,我们得到N个元素的排序。由于每个DeleteMin花费时间是O(logN),因此总的运行时间是O(NlogN)。

该算法的主要问题在于它使用了一个附加的数组。因此,存储需求增加了一倍。避免使用第二个数组的聪明的做法是利用这样的事实:在每次DeleteMIn之后,堆缩小了1。因此,位于堆中最后的单元可以用来存放刚刚删除的元素。使用这种策略,在最后一次DeleteMin后,该数组将以递减的顺序包含这些元素。

sort04.png

堆排序的分析

第一阶段构建堆最多用到2N次比较。在第二阶段,第 i次DeleteMax最多用到2logi次比较,总数最多为2NlogN-O(N)次比较(设N≥2)。因此,在最坏的情形下,堆排序最多使用2NlogN-O(N)次比较。

经验指出堆排序是一个非常稳定的算法:它平均使用的比较只比最坏情形界值指出的略少。

定理:对于N个互异项的随机排列进行堆排序,所用的比较平均次数为2NlogN - O(NloglogN)。

通过更复杂的论述,可以证明,堆排序总是至少使用NlogN-O(N)次比较,而且存在输入数据能够达到这个界。

6 归并排序

归并排序(mergesort)以O(NlogN) 最坏情形运行时间运行,而所使用的比较次数几乎是最优的。它是递归算法一个很好的实例。

这个算法中基本的操作是合并两个已排序的表。因为这两个表是已排序的,所以若将输入放到第三个表中时则该算法可以通过堆输入数据一趟排序来完成。合并两个已排序的表的时间显然是线性的,因为最多进行了N-1次比较,其中N是元素的总数。为了看清这一点,注意每次比较都是把一个元素添加到C中,但最后的比较除外,它至少添加添加两个元素。

归并排序算法描述:如果N = 1,那么只有一个元素需要排序,答案是显然的,否则,递归地将前半部分数据和后半部分数据各自归并排序,得到排序后的两部分数据,然后使用上面描述的合并算法再将这两部分合并到一起。

该算法是经典的分治(divide-and-conquer)策略,它将问题分成一些小问题然后递归求解,而治的阶段将分的阶段解得的各个答案修补到一起。

sort05.png

Merge例程是精妙的,如果堆Merge的每个递归调用均局部申明一个临时数组,那么在任一时刻就可能又logN个临时数组处于活动期,这对于小内存的机器则是致命的。另一方面,如果Merge例程动态分配并释放最小量临时内存,那么又malloc占用的时间会很多。由于Merge位于Msort的最后一行,因此在任一时刻只需要一个临时数组活动,而且可以使用该临时数组的任意部分;我们将使用与输入数组A相同的部分,这就达到本节末尾描述的改进。图7-10实现了这个Merge例程。

sort06.png

归并排序是用于分析递归例程的经典实例:我们必须给运行时间写出一个递归关系。下述方程给出准确的表示:

T(1) = 1
T(N) = 2T(N/2) +  N

虽然归并排序的运行时间是O(NlogN),但是它很难用于主存排序,主要问题在于合并两个排序的表需要线性附加内存,在整个算法中还要花费将数据拷贝到临时数据再拷贝回来这样一些附加的工作,其结果严重放慢了排序的速度。这种拷贝可以通过在递归交替层次是审慎地转换A和TmpArray的角色得到避免。归并排序的一种变形也可以非递归地实现,但即使这样,对于重要的内部排序应用而言,人们还是选择快速排序。

7 快速排序

正如它的名字标示的,快速排序(quicksort)是在实践中最快的已知排序算法,它的平均运行时间是O(NlogN)。该算法之所以特别快,主要是由于非常精炼和高度优化的内部循环。它的最坏情形的性能为O(N2),但稍加努力就可避免这种情形。像归并排序一样,快速排序也是一种分治的递归算法。

将数组S排序的基本算法又下列简单的四步组成:

  1. 如果S中元素个数是0或者1,则返回。
  2. 取S中任意元素v,称之为枢纽元(pivot)。
  3. 将S-{v}(S中其余元素)分成两个不相交的集合:S1 = {x ∈ S-{v} | x ≤ v }和S2 = {x ∈ S-{v} | x ≥ v}。
  4. 返回{quicksort(S1)}后,继随v,继而quicksort(S2)。

由于对那些等于枢纽元的元素的处理,第(3)步分割的描述不是惟一的,因此这就成了一个设计上的决策。一部分好的实现方法是将这种情形尽可能有效地处理。

sort07.png

应该知道该算法是成立的,但是不清楚的是,为什么它比归并排序快。如同归并排序那样,快速排序递归地解决两个子问题并需要线性的附加工作(第(3)步),不过,与归并排序不同,这两个子问题并不保证具有相等的大小,这是个潜在的隐患。快速排序更快的原因在于,第(3)步分割成两组实际上是在适当的位置进行并且非常有效,它的高效弥补了大小不等的递归调用的缺憾而且还有超出。

实现第(2)步和第(3)步有许多方法;这里介绍的方法是大量分析和经验研究的结果,它代表实现快速排序的非常有效的方法。

7.1 选取枢纽元

虽然上面描述的算法无论选择哪个元素作为枢纽元都能完成排序工作,但是有些选择显然更优。

一种错误的方法

通常的、没有经过充分考虑的选择第一个元素用作枢纽元。如果输入是随机的,那么这是可以接受的,但是如果输入是预排序的或是反序的那么这样的枢纽元就产生一个劣质的分割,因为所有的元素不是都被划入S1 就是都被划入S2。更有甚者,这种情况可能发生在所有的递归调用中。因此,使用第一个元素作为枢纽元是绝对糟糕的主意,应该立即放弃这种想法。另一种想法是选取前两个互异的关键字中的较大者作为枢纽元,不过这和只选择第一个元素作为枢纽元具有同样的害处。不要使用这两种选取枢纽元的策略。

一种安全的作法

一种安全的策略是随机的选取枢纽元。一般来说这种策略非常安全,除非非随机数生成器有问题(它不像你可能想象的那么罕见),因为随机的枢纽元不可能总在接连不断地产生劣质的分割。另一方面,随机数的生成一般是昂贵的,根本减少不了算法其余部分的平均运行时间。

三数中值分割法(Median-of-Three-Partitioning)

一组N个数的中值是第「N/2」个最大的数。枢纽元的最好的选择是数组的中值,不幸的是,这很难算出且明显减慢快速排序的速度。这样的中值的估计量可以通过随机选取三个元素并用它们的中值作为枢纽元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端,右端和中心位置「(Left+Right)/2」上的三个元素的中值作为枢纽元。显然使用三数中值分割法消除了预排序输入的坏情形(在这种情形下,这些分割都是一样的),并且减少了快速排序大约5%的运行时间。

7.2 分割策略

第一步是通过将枢纽元与最后的元素交换使得枢纽元离开要被分割的数据段。i从第一个元素开始而j从倒数第二个元素开始。

sort08.png

在分割阶段要做的就是把所有小元素移到数组的左边而把所有大元素移到数组的右边。当然,“小”和“大”是相对于枢纽元而言的。如果i和j遇到等于枢纽元的关键字,那么我们就让i和j都停止。

7.3 小数组

对于很小的数组(N≤20),快速排序不如插入排序好。不仅如此,因为快速排序是递归的,所以这样的情形还经常发生。通常的解决方法是对于小数组不递归地使用快速排序,而代之以诸如插入排序这样的对小数组有效的排序算法。一个好的截止范围(cutoff range)是N=10.

7.4 实际的快速排序例程

sort09.png

这种例程的一般形式将是传递数组以及被排序数据的范围left(左端)和right(右端)。要处理的第一个例程是枢纽元的选取。选取枢纽元最容易的方法是对A[left]、A[right]和A[center]适当的排序。这种方法还有额外的好处,即该三元素中的最小值被分在A[left],而这正是分割阶段应该将它放到的位置。三元素中的最大者被放到A[right],这也是正确的位置,因为它大于枢纽元。因此,我们可以把枢纽元放到A[right-1]并在分割阶段将i和j初始化到left+1和right-2。因为A[left]比枢纽元小,所以它用作j的警戒标记,这是另一个好处。因此,我们不必担心j越界。由于i将停在那些等于枢纽元的关键字处,故将枢纽元存储在A[right-1],将提供一个警戒标记。

sort10.png

图7-14的程序是快速排序真正的核心。它包括分割和递归调用。这里有几件事值得注意。第3行将i个j初始化为比它们的正确值超出1,使得不存在需要考虑特殊情况。此处的初始化依赖于三数中值分割法有一些附加作用的事实;如果按照简单的枢纽元策略使用该程序而不进行修正,那么这个程序是不能正确运行的,原因在于i和j开始于错误的位置而不再存在j的警戒标志。

第8行的swap为了速度上的考虑有时显示写出。最后,从第5行和第6行可看出为什么快速排序这么快。算法的内部循环由一个增1/减1运算(它很快)、一个测试,以及一个交换组成。该算法没有像在归并排序中那样的额外的技巧,不过,这个程序仍然出奇的复杂。令人感兴趣的是将第3行到第9行用图7-15中列出的语句代替,这是不能正确运行的,因为A[i]=A[j]=Pivot则会产生一个无限循环。

sort11.png

7.5 快速排序的分析

正如归并排序那样,快速排序也是递归的,因此,它的分析需要求解一个递推公式。

T(0) = T(1) = 1
T(N) = T(i) + T(N-i-1) + cN

最坏情况分析:枢纽元始终是最小元素。分析结果:T(N) = O(N2)

最好情况分析:枢纽元正好位于中间。分析结果:T(N) = O(NlogN)

平均情况分析:大小是等可能的。分析结果:T(N) = O(NlogN)

7.6 选择的线性期望时间算法

可以修改快速排序以解决选择问题(selection problem)。这种问题之前通过使用优先队列,我们能够给出以时间O(N + klogN)找到第k个最大(最小)元。对于查找中值的特殊情况,它给出一个O(NlogN)算法。

由于我们能够以O(NlogN)时间给数组排序,因此可以期望为选择问题得到一个更好的时间界。我们把这种算法叫做快速选择(quickselect)。令|Si|为Si 中元素的个数。快速选择的步骤如下:

  1. 如果|S| = 1,那么 k = 1,并将S中的元素作为答案返回。如果使用小数组的截止(cutoff)方法且|S|≤ CUTOFF,则将S排序并返回第k个最小元。
  2. 选取一个枢纽元v∈ S。
  3. 将集合S - {v} 分割成S1 和S2 ,就像我们在快速排序中所做的那样。
  4. 如果 k ≤ 1 + |S1|,那么第k个最小元必然在S1 中。在这种情况下,返回quickselect(S1 ,k)。如果k = 1 + |S1|,那么枢纽元就是第k个最小元,我们将它作为答案返回。否则,这第k个最小元就在S2 中,它是S2 中第(k - |S1| - 1)个最小元。我们进行一次递归调用并返回quickselect(S2 , k-|S1|-1)。

与快速排序对比,快速选择只做了一次递归调用而不是两次。快速选择的最坏情况和快速排序的相同,也是O(N2)。直观看来,这是因为快速排序的最坏情况发生在S1 和S2 有一个是空的时候;于是,快速选择也就不是真的节省一次递归调用。不过,平均运行时间是O(N)。

sort12.png

使用三数中值选取枢纽元的方法使得最坏情况发生的机会是微不足道的。然而,通过仔细选择枢纽元,我们可以消除二次的最坏情况而保证算法是O(N)。

8 大型结构的排序

关于排序的全部讨论,我们已经假设要被排序的元素是一些简单的整数。常常需要通过某个关键字对大型结构进行排序。在这种情况下,实际的解法是让输入数组包含指向结构的指针。我们通过比较指针指向的关键字,并在必要时交换指针来进行排序。这意味着,所有的数据运动基本上就像我们对整数排序那样进行。我们称之为间接排序(indirect sorting);可以使用这种方法处理我们已经描述过的大部分数据结构。这证明我们关于复杂数据结构处理时不必大量牺牲效率的假设是正确的。

9 排序的一般下界

即使在平均情况下,任何只用到比较的任意排序算法都需要进行Ω(NlogN)次比较。这意味着,快速排序在相差一个常数因子的范围内平均是最优的。

特别地,只用到比较的任何排序算法在最坏情况下都需要「log(N!)」次比较并平均需要log(N!)次比较。

决策树(decision tree)是用于证明下界的抽象概念。在我们这里,决策树是一棵二叉树。每个节点表示在元素之间一组可能的排序,它与已经进行的比较一致。比较的结果是树的边。

sort13.png

10 桶式排序

虽然我们证明了任何只使用比较的一般排序算法在最坏情况下需要运行时间Ω(NlogN),但是我们要记住在某些特殊情况下以线性时间进行排序仍然是可能的。

一个简单的例子是桶氏排序(bucket sort)。为使桶氏排序能够正常工作,必须要有一些额外的信息。输入数据A1 ,A2, …,AN 必须只由小于M的正整数组成。(显然还可以对其进行扩充。)如果是这种情况,那么算法很简单:使用一个大小为M称为Count的数组,它被初始化为全0。于是Count有M个单元(或称为桶),这些桶初始化为空。当读入Ai 时,Count[Ai]增1。在所有的输入数据读入后,扫描数组Count,打印出排序后的表。该算法用时O(M + N)。如果M = O(N),那么总量就是O(N)。

尽管桶式排序看似太平凡用处不大,但是实际行却存在许多其输入只是一些小的整数的情况,使用像快速排序这样的排序方法真的是小题大作了。

11 外部排序

迄今为止,我们考查过的所有算法都需要将输入数据装入内存。然而,存在一些应用程序,它们的输入数据量太大装不进内存。本节将讨论一些外部排序算法(external sort),它们是设计用来处理很大的输入的。

11.1 为什么需要新的算法

大部分内部排序算法都用到内存可直接寻址的事实。希尔排序用一个时间单位比较元素A[i]和A[i-hk]。堆排序用一个时间单位比较元素A[i]和A[i*2+1]。使用三数中值分割法的快速排序以常数个时间单位比价A[left]、A[center]和A[right]。如果输入数据在磁带上,那么所有这些操作就失去了它们的效率,因为磁带上的元素只能被顺序访问。即使数据在一张磁盘上,由于转动磁盘和移动磁头所需的延迟,仍然存在实际上的效率损失。

为了看到外部访问究竟有多慢,可建立一个大的随机文件但不能太大以致装不进内存。将该文件读入并用一种有效的算法对其排序。将该输入数据进行排序所花费的时间与将其读入所花费的时间相比必然是无足轻重的,尽管排序是O(NlogN)操作而读入数据只不过花费O(N)时间。

11.2 外部排序模型

各种各样的海量存储装置使得外部排序比内部排序对设备的依赖性要严重的多。

我们将假设至少有三个磁带驱动器进行排序工作。我们需要两个驱动器执行有效的排序,而第三个驱动器进行简化的工作。如果只有一个磁带驱动器可用,那么我们则不得不说;任何算法都将需要Ω(N2)次磁带访问。

11.3 简单算法

基本的外部排序算法使用归并排序中的Merge例程。 上一节讨论的k-路合并方法需要使用2k盘磁带,这对某些应用极为不便。只使用k+1盘磁带也有可能完成排序的工作。例如,只用三盘磁带完成2-路合并。

11.4 替换选择

用于顺串的构造。

12 总结

对于最一般的内部排序应用程序选用的方法不是插入排序、希尔排序,就是快速排序,它们的选用主要是根据输入的大小来决定。

高度优化的快速排序算法即使对于很少的输入数据也能和希尔排序一样快。快速排序的改进算法仍然有O(N2)的最坏情况,但是,这种最坏情形出现的机会是如此地微不足道,以至于不能成为影响算法的因素。如果需要对一些大型的文件排序,那么快速排序则是应该选用的方法。但是,永远都不要图省事而轻易把第一个元素用作枢纽元。对输入数据随机的假设是不安全的。如果你不想过多地考虑这个问题,那么你就使用希尔排序。希尔排序有些小缺陷,不过还是可以接受的,特别是需要简单明了的时候。希尔排序的最坏情况有只不过是O(N4/3);这种最坏情况发生的几率也是微不足道的。

堆排序要比希尔排序慢,尽管它是一个带有明显紧凑内循环的O(NlogN)算法。

插入排序只用在小的或是非常接近排好序的输入数据上。我们没有包括进来归并排序,因为它的性能对于主存排序不如快速排序那么好,而且它的编程也一点也不省事。同时我们也看到,合并是外部排序的中心思想。