1. 策略
来学习一种非常别致的排序算法,也就是希尔排序。
希尔排序算法既有着悠久的历史,同时也仍然不失活力。该算法的别致之处在于,它不再是将输入视作为一个一维的序列,而是将其视作为一个二维的矩阵,并且试图对矩阵的每一列分别进行排序。
如果矩阵当前的宽度为 w,那么我就将所有这 w 列各自的排序总称为 w-sorting。实际上每一次希尔排序的过程都是由若干个宽度不同的 w-sorting 构成的。
如果矩阵的每一列都已经过排序,我们就称之为 w-ordered。实际上矩阵最开始比较宽,w 比较大,此后 Shellsort 会逐步地压缩矩阵,使之越来越高,越来越窄。每压缩一次都随机执行一趟对应的 w-sorting,从而使之变成 w-ordered。
也可以过这样一组图来说明这一过程。比如这可能就是最初的那个矩阵,相应的比较宽、比较矮。那么在执行完对应的 w-sorting 之后,Shellsort 会对这个矩阵进行重组。使之成为一个相对更窄但同时更高的矩阵。接下来对应于新的这个宽度,wk -1也会做一趟逐列的排序,而在此之后,Shellsort 又会对它进行重组,使之变成这样一个更加的窄,更加高的矩阵。
这个过程将持续地进行下去。总之矩阵会变得越来越高、越来越窄,直到最终变成只有一列。同样的,对于最后这个矩阵我们也需要来做一次对应的 w-sorting,只不过此时的 w = 1,所以我们也称之为 one-sorting。
可以看到整个 Shellsort 过程使用了一系列的宽度,也就是 wk、wk-1 以及一直到 w3、w2和 w1。这些宽度合在一起构成了所谓的步长序列 step sequence。
当然,这些矩阵宽度被使用的次序恰好与它们在序列中的次序相反。然而,无论如何,它们都必须是逐个单调递减的。没错,在算法的执行过程中,我们所采用的矩阵宽度会逐步地递减,所以希尔排序算法也称作为递减增量法。
请注意我们这里的步长序列 h, 实际上除了我们刚才所说的单调性,以及它的首相必须为1,我们对它暂时还没有更多的要求。是的,这种序列有很多种可能的选择,采用不同步长序列, Shellsort 的性能也会有所不同。
实际上 Shellsort 只是一个框架,你采用什么样的步长序列就会得到什么样的算法。从这个意义上讲,Shellsort 就像一个播放机,往里头放入什么样的 CD,它就会播放什么样的音乐。因此宁愿说 Shellsort 是一个算法,不如说它是一类算法。
我们注意到,既然任何步长序列都要求首项 w1 = 1,所以任何Shellsort 都是以 one-sorting 结束的,而任何一次这样的 one-sorting 其实也就相当于全局的排序。因此最终的输出结果也必然是正确的排序列。因此这个算法的正确性是毫无疑问的。
当然,至此你可能会有一个疑问,既然无论如何,最终都要做一次 one-sorting,那么此前的这些排序又有什么意义呢?是的,这正是 Shellsort 的奥妙所在。不过现在回答这个问题还维持尚早。接下来我们通过一个具体的实例,首先来切实地感受一下希尔排序的执行过程。
2.实例
考察这个由13个整数所构成的待排序序列。
采用希尔排序算法,我们首先将矩阵的宽度取做8,于是按照不超过8个元素为准则,将整个序列分为两段,而每一段都对应于矩阵的一行,这样我们就得到一个宽度为8的矩阵。
接下来我们对这个矩阵逐列排序,很容易验证第1列的排序结果是这样,第2列是这样,第3列是这样,第4列是这样,以及第五列是这样。最后3列是退化的情况,直接得到排序的结果。
至此我们已经完成了对应于宽度 8 的一趟 sorting。在转换为新的矩阵之前,我们需要将它重新复原为一个线性的序列。具体来说,与刚才构成矩阵的操作完全相反,我们这时候需要将矩阵的每一行逐个取出并依次串接,从而构成一个线性序列。
我们接下来采用的矩阵宽度为5,因此我们接下来要以5为单位,将子序列切分为若干段,而每一段都将作为新矩阵的一行。接下来,我们依然需要逐列排序,不难验证这是第1列的排序结果,这是第2列的排序结果,以及第3列、第4列、第5列的排序结果。
一旦完成了这样的逐列排序,我们又需要在逻辑上将这个矩阵重新恢复为一个序列。具体的方法依然与刚才构造矩阵的过程相反,也就说我们需要将矩阵中的每一行逐个取出并依次串接,并且还原为一个线性的序列。
此时我们不妨稍作停留,来观察一下这两个中间结果。虽然我们现在还不能精确地度量,但是我们依然能够隐约而且切实的感觉到整个序列的有序性是在不断地改善。那么接下来还会继续改善吗?不妨继续下去。
再接下来的一步,我们将矩阵的宽度取做3,也就说我们将以每三个元素为单位,将整个序列分割成若干段。这些段也就分别构成了新矩阵的各行。这个新矩阵共有三列,我们接下来依然需要对这个新矩阵中的3列分别排序,同样不难验证,各列分别排序的结果应该是这样。
接下来我们依然需要逐行取出所有的元素,并将它们依次串接起来,恢复为一个线性序列。至此,也不妨再次的大致体会一下,经过刚才的这样一趟逐列排序,整个序列的有序性又向前有所改进。
为了再进一步的提高整个序列的有序性,我们接下来将要采用的矩阵宽度取作2,也就是说我们要以两个元素为单位,将整个序列切分成若干段,而且同样的,每一段都构成新矩阵的一行。
此后我们又可以分别针对这两列各做一次排序,不难验证排序的结果分别是这样。接下来同样的,我们又需要将这个矩阵中的元素逐行取出,并将它们依次串接起来,恢复成为一个线性序列。
请再次的体会一下,经过这样的一趟逐列排序,整个序列的有序性又有所改善。
与所有的步长序列一样,我们最终也是终止于 w1 = 1。也就说我们要将此时的线性序列完整的视作为一列,并且对它进行排序,在经过了以上的各步之后,不出意外,我们的确得到了原序列的一个排序结果。
3.循秩访问
通过刚才的实例,相信你对希尔排序的整个过程已经有了足够的了解,那么这样一个计算过程又当如何实现呢?实际上,如果需要真正的实现这样一个完整的计算过程,我们还有一些技术细节需要讨论。
比如我们首先碰到了一个技术问题,就是矩阵的重排。难道为此我们需要真的去引入一个二维的向量结构吗?其实大可不必,如果输入序列本来就是以一维向量形式给出的,那么我们只需在这样一个一维向量上进行操作就足够了。
我们注意到,在每一步具体的迭代中,矩阵当前的宽度都可以视作为一个常数w。因此按照这幅图所示的方式,只需从逻辑上而不是物理上完成矩阵的重排就够了,因为对于任何一个特定的矩阵宽度,在这个矩阵中的任何一个元素在原序列中所对应的秩都是可以直接换算出来。
准确地讲,在这个矩阵中位于第 K 行第 I 列的元素,在原序列中所对应的秩无非就是 i + kw。因此,尽管刚才以及接下来我们在讲解时都倾向于使用一个二维矩阵,但请你务必牢牢记住,在物理上,它始终都无非就是那个输入的一维向量而已。
而我们之所以能够做到这样,需要归功于向量循秩访问的特性。
4. 插入排序
接下来的一个技术要点是在每一趟迭代中,逐列的排序又当如何具体实现?非常有趣的是,在这里我们并不需要去追求非常高效地排序算法。事实上,我们这里在底层所采用的排序算法只需要具有输入敏感性就可以了,也就是说随着算法的不断推进,在序列的有序性不断改善的同时,这类算法单次运行的成本将会逐渐地递减,从而能够保证总体计算成本的足够低廉。
是的,我们早先介绍的某些初等排序算法的确就具有这种输入敏感性,你还记得吗?没错,插入排序 insertionsort,你应该记得我们曾经通过逆序对的数目来度量序列的无序性,而插入排序的计算成本恰恰就取决于输入序列的这一指标。
当然影响希尔排序总体效率的最大因素莫过于其中具体采用的步长序列 h。接下来我们将会具体的剖析其中的几个实例。需要指出的是,在评判这些序列的相互优劣时,我们主要需要考察这样几个方面:
- 包括在整个算法的过程中,我们需要执行的关键码比较以及数据项移动操作的次数。
- 另外我们也十分关注整个过程中所涉及的迭代次数,也就是矩阵的重组次数,因为每一次矩阵的重组都对应于整个序列的一趟遍历,而在数据量非常大时,每一次遍历都有可能会引发相应的 IO,因此在这种情况下,迭代的次数也就自然成为了一个非常敏感而重要的因素。
以下我们就来考察步长序列的一个具体实例,这个序列提出得更早,因此也难免有更多的缺点。
5.Shell序列
我们这里结识的第一个步长序列就出自于希尔排序的发明者希尔本人之手,接下来就会看到这个序列存在很多缺点,尽管从某种意义上来看,它非常优美,因为我们注意到每一项都整齐划一的是2的 k 次方的形式。也就说每一项都是前一项的两倍,那么这序列的缺点就集中体现在它在最坏情况下可能会导致 n 平方量级的运行时间。 为此我们可以构造这样一个具体的实例:
我们首先来考察两个整数区间,也就是0~ 2 n − 1 2^{n-1} 2n−1,以及 2 n − 1 2^{n-1} 2n−1~ 2 n 2^{n} 2n,其中包含的整数都是 2 n − 1 2^{n-1} 2n−1个,只不过在数值上前者更小,而后者更大。
~
接下来我们将这两组整数分别的打乱次序,并相应地构成两个子序列,A 以及 B。然后我按照 ABAB 交错的形式,将它们汇合为一个完整的序列。比如对于 n = 4而言,这就是一个可能的生成序列。可以看到它是由两个规模都为 8 的子序列交错构成的,子序列 A 中的元素都被安置在秩为奇数的位置,而对称的子序列B中元素都被安放在秩为偶数的位置。
~
现在我们假设就采用希尔序列来对它进行排序。我们考察算法的倒数第二步,也就是以 2 为间隔的那轮排序刚刚结束的时候,我们可以断言,此时序列的组成必然是这样。也就是说原先来自于子序列 A 中的那些元素依然占据着秩为奇数的位置,而且这8个元素的相对次序已经是完全有序的。
~
同时对称的,原先来自于子序列 B 中的那些元素也必然仍旧占据着秩为偶数的那些位置,而且仅就这 8 个元素而言,它们之间的相对次序也已经是有序的。这两个子序列在这个时刻的有序性并不难理解,在刚刚过去的这一轮排序中,这两个子序列恰好各自就是独立成为一列,因此所谓的2-sorting, 其实就是对这两列分别进行排序,所以它们的结果自然应该是各自有序的。
当然,刚才我们所指出的另一个现象更会引发我们的好奇,也就是说,无论我们此前经历过多少趟的排序交换,来自于子序列 A 和子序列 B 中的元素始终都是分别占据着秩为奇数和偶数的位置。二者泾渭分明,没有任何的元素互换。
为此我们需要反观希尔序列,我们注意到在这个序列中,除了第一项,其余各项都是偶数。没错,偶数。这就意味着在这些项所对应的每一个重组的二维矩阵中同属一列的元素或者都来自集合 A 或者都来自于 B,自然不会发生 A 与 B 之间的元素互换了。因此直到执行完 2-sorting 之后,这两个序列必然都是井水不犯河水,互不相扰。然而这恰恰就是问题所在。
对于这样的序列,在接下来的最后一趟排序,也就是 one-sorting 中,我们必然需要付出高昂的代价,因为在这个序列中依然包含着大量的逆序对。我不妨只统计 B 中的元素所参与构成的逆序对。首先是全局最大的15,它与其后的7就构成了一个逆序对。接下来在考察次大的14,我们来看出它与6和7构成了两个逆序对。再接下来是13,它与 5 6 7总共构成了三个逆序对。以下类推,元素12将与 4 5 6 7 总共构成 4 个逆序对。
我想你已经看出其中的规律了。没错,B 中的各元素所参与构成的逆序对数恰好构成一个算数级数。没错,算数级数,我想经过这门课的学习,你现在应该有了一个直觉的反馈。是的,这样一个算数基数对应的将是平方量级的运行成本,也就是说算法的效率已经退化为与起泡排序相当了。
当然在这里我们并不满足于仅仅指出希尔序列的缺点,而更重要的是,我们需要探究导致这种缺陷的根源。让我们将目光再次投回到希尔序列,我们会发现,与其说其中大量的元素都是偶数,不如更一般的说其中的各项并非互素,因此每一轮的排序都有大量的精力浪费于对前一轮排序工作的重复之上。是的,相邻项要尽可能的互素。这样我们也就拿到了打开新方法大门的钥匙。