B队列:1 2 7 8 9
看上面的例子,AB两个序列都是已经有序的了。在给出数据已经有序的情况下,我们会发现很多神奇的事,比如,我们将要输出的第一个
数一定来自于这两个序列各自最前面的那个数。两个数都是1,那么我们随便取出一个(比如A队列的那个1)并输出:
A队列:1 3 5 7 9
B队列:1 2 7 8 9 输出:1
注意,我们取出了一个数,在原数列中删除这个数。删除操作是通过移动队首指针实现的,否则复杂度就高了。
现在,A队列打头的数变成3了,B队列的队首仍然是1。此时,我们再比较3和1哪个大并输出小的那个数:
A队列:1 3 5 7 9
B队列:1 2 7 8 9
输出:1 1
接下来的几步如下:
A队列:1 3 5 7 9 A队列:1 3 5 7 9 A队列:1 3 5 7 9 A队列:1 3 5 7 9
B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ==> B队列:1 2 7 8 9 ……
输出:1 1 2 输出:1 1 2 3 输出:1 1 2 3 5 输出:1 1 2 3 5 7
我希望你明白了这是怎么做的。这个做法显然是正确的,复杂度显然是线性。
归并排序(Merge Sort)将会用到上面所说的合并操作。给出一个数列,归并排序利用合并操作在O(nlogn)的时间内将数列从小到大排序。
归并排序用的是分治(Divide and Conquer)的思想。首先我们把给出的数列平分为左右两段,然后对两段数列分别进行排序,最后用刚才的合
并算法把这两段(已经排过序的)数列合并为一 个数列。有人会问“对左右两段数列分别排序时用的什么排序”么?答案是:用归并排序。也
就是说,我们递归地把每一段数列又分成两段进行上述操作。你不需要关心实际上是怎么操作的,我们的程序代码将递归调用该过程直到数列
不能再分(只有一个数)为止。
初看这个算法时有人会误以为时间复杂度相当高。我们下面给出的一个图将用非递归的眼光来看归并排序的实际操作过程,供大家参考。
我们可以借助这个图证明,归并排序算法的时间复杂度为O(nlogn)。
3] [1] [4] [1] [5] [9] [2] [7] \\/ \\ / \\ / \\ /
[1 3] [1 4] [5 9] [2 7] \\ / \\ /
[11 3 4] [2 5 7 9] \\ /
[1 1 2 3 4 5 7 9]
上图中的每一个“ \\ / ”表示的是上文所述的线性时间合并操作。上图用了4行来图解归并排序。如果有n个数,表示成上图显然需要
O(logn)行。每一行的合并操作复杂度总和都是O(n),那么logn行的总复杂度为O(nlogn)。这相当于用递归树的方法对归并排序的复杂度进行
了分析。假设,归并排序的复杂度为T(n),T (n)由两个T(n/2)和一个关于n的线性时间组成,那么T(n)=2*T(n/2)+O(n)。不断展开这个式子我
们可以同样可以得到T(n)=O(nlogn)的结论,你可以自己试试。如果你能在线性的时间里把分别计算出的两组不同数据的结果合并在一起,根
据T(n)=2*T(n/2)+O(n)=O(nlogn),那么我们就可以构造O(nlogn)的分治算法。这个结论后面经常用。我们将在计算几何部分举一大堆类似的 例子。
如果你第一次见到这么诡异的算法,你可能会对这个感 兴趣。分治是递归的一种应用。这是我们第一次接触递归运算。下面说的快速排
序也是用的递归的思想。递归程序的复杂度分析通常和上面一样,主定理 (Master Theory)可以简化这个分析过程。主定理和本文内容离得太
远,我们以后也不会用它,因此我们不介绍它,大家可以自己去查。有个名词在这里的话找学习资料将变得非常容易,我最怕的就是一个东西
不知道叫什么名字,半天找不到资料。
归并排序有一个有趣的副产品。利用归并排序能够在O (nlogn)的时间里计算出给定序列里逆序对的个数。你可以用任何一种平衡二叉树
来完成这个操作,但用归并排序统计逆序对更方便。我们讨论逆序对一般是说的一个排列中的逆序对,因此这里我们假设所有数不相同。假如
我们想要数1, 6, 3,2, 5, 4中有多少个逆序对,我们首先把这个数列分为左右两段。那么一个逆序对只可能有三种情况:两个数都在左边,
两个数都在右边,一个在左一个在右。在左右两段 分别处理完后,线性合并的过程中我们可以顺便算出所有第三种情况的逆序对有多少个。换
句话说,我们能在线性的时间里统计出A队列的某个数比B队列的某个数 大有多少种情况。
A队列:1 3 6 A队列:1 3 6 A队列:1 3 6 A队列:1 3 6 A队列:1 3 6
B队列:2 4 5 ==> B队列:2 4 5 ==> B队列:2 4 5 ==> B队列:2 4 5 ==> B队列:2 4 5 ……
输出: 输出:1 输出:1 2 输出:1 2 3 输出:1 2 3 4
每一次从B队列取出一个数时,我们就知道了在A队列中有多少个数比B队列的这个数大,它等于A队列现在还剩的数的个数。比如,当我们
从B队列中取出2时,我们同时知道了A队列的3和6两个数比2大。在合并操作中我们不断更新A队列中还剩几个数,在每次从B队列中取出一个数
时把当前A队列剩的数目加进最终答 案里。这样我们算出了所有“大的数在前一半,小的数在后一半”的情况,其余情况下的逆序对在这之前
已经被递归地算过了。
============================华丽的分割线============================
堆排序(Heap Sort)利用了堆(Heap)这种数据结构(什么是堆?)。 堆的插入操作是平均常数的,而删除一个根节点需要花费O(log n)的
时间。因此,完成堆排序需要线性时间建立堆(把所有元素依次插入一个堆),然后用总共O(nlogn)的时间不断取出最小的那个数。只要堆会
搞,堆 排序就会搞。堆在那篇日志里有详细的说明,因此这里不重复说了。
============================华丽的分割线============================
快速排序(Quick Sort)也应用了递归的思想。我们想要把给定序列分成两段,并对这两段分别进行排序。一种不错的想法是,选取一个数
作为“关键字”,并把其它数分割为两 部分,把所有小于关键字的数都放在关键字的左边,大于关键字的都放在右边,然后递归地对左边和右
边进行排序。把该区间内的所有数依次与关键字比较,我们就 可以在线性的时间里完成分割的操作。完成分割操作有很多有技巧性的实现方法
,比如最常用的一种是定义两个指针,一个从前往后找找到比关键字大的,一个从后往前找到比关键字小的,然后两个指针对应的元素交换位
置并继续移动指针重复刚才的过程。这只是大致的方法,具体的实现还有很多细节问题。
不像归并排序,快速排序的时间复杂度很难计算。我们可以看到,归并排序的复杂度最坏情况下也是O(nlogn)的,而快速排序的最坏情况
是O(n^2) 的。如果每一次选的关键字都是当前区间里最大(或最小)的数,那么这样将使得每一次的规模只减小一个数,这和插入排序、选择
排序等平方级排序没有区别。这 种情况不是不可能发生。如果你每次选择关键字都是选择的该区间的第一个数,而给你的数据恰好又是已经有
序的,那你的快速排序就完蛋了。显然,最好情况是每 一次选的数正好就是中位数,这将把该区间平分为两段,复杂度和前面讨论的归并排序
一模一样。根据这一点,快速排序有一些常用的优化。比如,我们经常从数列中随机取一个数当作是关键字(而不是每次总是取固定位置上的
数),从而尽可能避免某些特殊的数据所导致的低效。更好的做法是随机取三个数并选择这三个数的中位数作为关键字。而对三个数的随机取
值反而将花费更多的时间,因此我们的这三个数可以分别取数列的头一个数、末一个数和正中间那个数。另外,当递归到了一定深度发现当前
区间里的数只有几个或十几个时,继续递归下去反而费时,不如返回插入排序后的结果。这种方法同时避免了当数字太少时递归操作出错的可 能。
下面我们证明,快速排序算法的平均复杂度为O(nlogn)。不同的书上有不同的解释方法,这里我选用算法导论上的讲法。它更有技巧性一
些,更有趣一些,需要转几个弯才能想明白。