1. 分而治之
主题就是排序。实际上我们对于排序问题并不陌生。你应该记得在最开始的几章,我们就分别介绍过起泡排序、插入排序、选择排序以及归并排序,而在介绍散列技术时,我们也曾介绍过桶排序、计数排序以及基数排序。在讨论优先级队列时,也结合堆这种结构,介绍过堆排序以及更为通用的锦标赛排序。因此在本章中,我们将进而重点的学习若干种高级的排序算法,并讨论与之相关的几个衍生问题。在接下来的第一节,就让我们首先来学习快速排序算法。
快速排序 (quicksort) 是霍尔爵士在上世纪60年代发明的一种算法。这也是基于分治策略的又一典型算法。具体来说,对于任何一个待排序列,这里也需要将它们分为前后两个子序列,并对这两个规模更小的子序列递归的实施排序。
听到这个思路,你或许会想起归并排序。是的,quicksort 和 mergesort 都采用了分治策略,但二者又有很大的区别,比如对于快速排序来说,子问题之间的独立性更为鲜明。比如这里要求前一序列中的任何元素在数值上都不得超过后一序列中的任意元素,这是一个非常强的条件。如果这条件的确满足,那么在分别递归的对前一序列和后一序列进行排序之后,只要将二者简单地串接起来,也就自然得到了整体的有序序列,从而完成最初的排序任务。
当然,与归并排序一样,只含单个元素的序列自身就是有序的,因此也可以作为平凡的递归基。
由上可见,按照霍尔爵士的设想,只要能够完成这种左小右大式的子序列划分,那么剩余的工作可以完全地交给递归来完成。因此,对于快速排序来说,核心的任务与难点在于如何完成子任务或子序列的划分。
从这点来看,归并排序恰好相反。我们知道,对于归并排序算法而言,其计算量以及难点都在于如何将子任务的解进行合并。那么霍尔爵士所设想的这种划分,具体的又当如何实现呢?
2. 轴点
为了实现霍尔爵士所设想的划分,我们需要借助轴点。
所谓的轴点 pivot 是在序列中的某一类特殊元素。这类元素的特征是:凡是居于它左侧的元素都不比它更大。对称的居于它右侧的元素也不比它更小。因此如果用高度来表示元素的数值大小,那么相对于轴点所对应的这条水平线,左侧的元素都位于下方,而右侧元素都位于上方。
不难看出,以任何一个轴点为界,整个序列总是可以分为左小右大的两个子序列。而这正是霍尔爵士所设想的那种左小右大式的划分。
因此,只要我们能够在任何一个序列中快速地找到其中的轴点,那么借助二分式的递归,我们就自然可以导出快速排序的完整算法。由此我们也再一次更为清晰地看到,快速排序算法的核心就在于如何快速地确定轴点。
因此我们接下来需要实质讨论的重点也无非就是这样一个快速划分的算法。
然而在通往快速划分算法 partition 的道路上,我们首先就会遇到一个拦路虎。因为我们不能保证在任何一个待排序的序列中,轴点元素总是存在的。实际上既然相对于轴点,所有的元素都是按照前小后大的次序排列的,所以轴点自身必然是已经就位了,它在当前序列中所对应的秩,也就是它最终在有序列中所对应的秩。是的,轴点必然是就位的,这是一项非常强的必要条件。
实际上每一个元素都有可能天生不具备这个条件。任何元素都非就位的序列普遍存在,实际上它们也就是所谓的乱排序列 derangement。
比如任何一个有序序列,只要经过一次循环移位,就可得到一个这样的乱排序。
不难理解在完全有序的序列中,所有的元素自身都是一个轴点。而反过来,如果一个序列中的所有元素都是轴点,那么它也自然是有序的。
从这个角度来看,所谓的快速排序无非就是将原序列中的所有元素逐个地转换为轴点的过程。
尽管在任意序列中,轴点未必天然的存在,但好消息是,只要适当地交换元素的位置,我们总是可以将任何一个元素转化为一个轴点。
那么具体的又当如何交换呢?为此我们又需要付出多高的成本呢?
3. 构造轴点
霍尔爵士所设计的轴点构造算法,其原理和过程可以由这幅图来示意。
首先我们要选取一个轴点候选作为培养对象,通常我们都不妨取做这个序列的首元素,而在整个构造的过程中,我们都需要用到 lo 与 hi 两个指针,这两个指针将整个序列分为 L,U 和 G 三部分。
这里的 L 是一个前缀,其中的任何一个元素在数值上都不超过轴点的候选。对称的 G 是一个后缀,其中的任何一个元素在数值上也不会小于轴点候选,而居于二者之间的子序列 U, 则由大小仍然未知的元素构成。
在初始状态下,U 也就是整个序列,而 L 和 G 都是空的。在算法启动之后,我们会尝试着将 lo 与 hi 交替地向内侧移动,从而令它们彼此靠近。lo 每向后移动一步,L 也就会向后扩展一个单位。对称的, hi 每向前移动一步,G 也会向前拓展一个单元。
为了完成这种拓展,我们需要适当地将 U 中的某个元素加入到 L 或者 G 中,最终当 lo 与 hi 同时指向同一个位置时,我只需将此前选定的轴点候选者放到这个位置。那么这个候选者也就自然成为了一个名副其实的轴点。
4. 单调性 + 不变性
以下,就让我们通过这样一组插图,更为细致的考察和理解轴点构造算法的原理以及具体过程。
在这个过程中我们需要把握两条核心的不变性。首先正如我们此前所言,从数值上来看,子序列 L 中的元素都不超过轴点候选,同时子序列G中的元素也都不小于轴点候选。其次,对于子序列 U 而言,它的首元素和末元素总是交替的在逻辑上可以视作为空闲单元。
-
我们首先来验证初始状态,比如在初始状态下,无论 L 或 G 都是空的,所以第一条自然满足。同样在初始条件下,U 的首元素已经作为轴点的候选被取出备份。因此它的确可以认为是空闲的。
-
再来考察一般情况下的 U,它的首元素为 lo,而末元素为 hi。不失一般性,假设此时的 lo 是空闲的,于是我们就可以尝试着向左侧拓展子序列 G。具体来说,只要当前 U 的末元素,也就是 hi, 在数值上不小于候选轴点,我们就可以简明地,通过令 hi 递减一个单位,从而将元素 hi归入到子序列 G 中。
-
接下来,如果新的末元素依然满足这样的条件,我们就继续将它归入到G 中,直到某个时刻末元受 hi 不再满足这个条件,也就是说此时的元素 hi 在数值上会严格的小于候选轴点。
没错,严格小于候选轴点,这不正是此序列 L 所对应的入选条件吗?因此,在这种情况下,我们不妨将末元素 hi 转移至当前仍然空闲的单元 lo 中。尽管因此单元 lo 将不再是空前的,但相应的 hi 所腾出的那个单元又会随即变成空闲的。也就是说 U 所具有的不变性依然成立。
-
我们接下来的处理方向将与刚才恰好颠倒过来,也就说我们会进而去考察 U 的首元素,只要这个元素在数值上不超过候选轴点,我们就可以同样简明地令 lo 递增一个单位,从而将这个数元素归入到子序列 L 中。
子序列 L 也会因此向后端拓展一个单元。以下同理,只要首元素在数值上依然不超过候选轴点,我们都会同样地将它归入到子序列 L 中。这样的情况出现多少次,此序列 L 就会向后拓展多少个单元。
子序列 L 的这种拓展会在什么时候终止呢?没错,也就是接下来的首元素 lo 在数值上不再是继续地不超过候选轴点。而这意味着什么呢?没错,这意味着此时的首元素 lo 完全符合子序列 G 的入选条件。
-
因此我们不妨将它转移到当前仍是空闲的那个单元 hi 中。而此后,尽管单元 hi 不再是空闲的,但是随着刚才那个元素的移出,单元 lo 又随即变成是空闲的了。也就是说 U 的不变性依然成立。
当然在经过以上的拓展之后,无论是子序列 L 还是子序列 G,在数值上也依然保持不变性。至此,整个算法经历了一个完整的周期,经过这样的一个周期,不仅不变性依然保持,而且更重要的是,我们可以注意到这里的单调性,更准确的讲是子序列长度的单调性。
因为我们看到子序列 L 和 G 的长度都有所增加,同时相应的子序列 U 的长度却在无形中缩短了。因此,当最终子序列 U 退换为只有一个单元时,也就是霍尔爵士所设想的,算法终止之前的临界状态。
到那个时候,我们只需将候选轴点植入于唯一的这个空闲单元,它就会成为一个名副其实的轴点。同时我们也完成了对原序列的一次快速划分,整个 partition 算法也可顺利结束。
5. 实例
以下就来通过这个具体实例,体会 partition 算法的具体过程。
-
这里的待排序序列由10个元素构成。在初始状态下,子序列 U 也就是整个序列。 按照通常的习惯,我们将首元素 6 取做待培养的候选轴点,在将它取出备份之后,对应的单元在逻辑上可以视作为是空闲的。
-
因此接下来,我们首先要尝试着去拓展子序列 G,虽然此时它还是空,为此我们总是要考察 U 的末元素,也就是此时的7。我们发现这个元素的确大于候选的轴点6,因此它的确可以归入子序列 G, 子系列 G 拥有了第一个元素,而子系列 U 则相应地减少了一个元素。
-
然而接下来,子序列 G 的拓展却不得不止步于新的末元素,因为我们注意到,这个元素的数值为 1,要严格的小于候选轴点 6。还记得我们刚才为此设计的处理方法吗?没错,既然此时的这个末元素更小,我们也就自然地可以将它归入到子序列 L 中。为此,我们只需将它转移至当前仍为空闲的首单元。
-
接下来,在拥有了第一个元素之后,子序列 L 也会试图继续向右拓展。很幸运,我们发现接下来的首元素 3 也要小于候选轴点。这就意味着我们同样可以将它归入到子序列 L 中。
-
然而接下来,子序列 L 的拓展也会止步于新的首元素,因为我们发现新的这个首元素在数值上是要大于候选轴点。
解放心,对于这种情况,我们算法依然足以处理,难道不是吗?既然这个元素在数值上要超过候选轴点,所以它也自然可以归入子序列 G 中。而此时紧邻与子序列 G 的左侧恰好有一个空闲单元。因此接下来我们只需将这个更大的元素转移至这个空显的单元。如此,子序列 G 向前拓展一个单元,而子序列 U 也相应地减少了一个单元。同时在这个元素被转移之后,腾出来的首单元又继而被视作为一个空闲单元。也就是说算法不依然保持。
-
以下同理,子序列 G 的拓展会止步于元素 5b。于是我们不妨就将这个元素转移至当前空闲的首单元处,并转而去尝试拓展子序列 L。
-
随后在依次加入了元素 2 和 5a 之后,子序列 L 的拓展也会止步于元素9。再一次,我们可以将这个元素转移至当前为空的末单元,并在接下来转而去尝试拓展子序列 G。
-
很遗憾,我们尝试依然终止于更小的元素 4。因此我们必须将它转移至当前为空的首单元。至此整个子序列 U 的长度已经退化为 1。因此我们只需将候选的元素6 植入于其中,这个元素也就成为了一个名副其实的轴点。
作为验证,你可以逐个地检查一下,在这个元素之前的所有元素的确都不比它大,而在它之后的所有的元素也的确都不比它小。