前言
之所以把归并排序和快速排序放在一起探讨,很明显两者有一些相似之处:这两种排序算法都采用了分治的思想。下面来逐个分析其实现思想。
归并排序
实现思想
归并的含义很明显就是将两个或者两个以上的有序表组合成一个新的有序表。归并排序中一般所用到的是2-路归并排序,即将含有n个元素的序列看成是n个有序的子序列,每个子序列的长度为1,而后两两合并,得到n/2个长度为2或1的有序子序列,再进行两两合并。。。直到最后由两个有序的子序列合并成为一个长度为n的有序序列。2-路归并的核心操作是将一维数组中前后相邻的两个有序序列归并为一个有序序列。
下面一系列图展示了2-路归并排序的过程:
原始无序序列:
第一次需要对各相邻元素进行两两归并,归并后结果如下:
第三次需要对上图中相邻色块的元素进行两两归并,归并后的结果如下:
接下来便是最后一次两两归并了,归并后便的到了有序的序列,如下:
第一次实现的代码
根据2-路归并操作的思想,站在节省辅助空间的角度上考虑,我写出的归并操作的代码如下:
调用上面的函数,得到的归并排序的代码应该是这样的:
输入任意数组来测试,结果也是正确的。
第二次实现的代码
我看很多书上或者网上的例子给出的代码都要在Merge函数中多传入一个用来保存归并后有序序列的数组,并在MSort函数中另外声明一个临时数组,传给该参数,这样每次递归调用的时候都要在局部声明一个临时数组,很不好。正当我对自己的代码感觉良好时,看到了下面这段话:
Merge例程是精妙的。如果对Merge的每个调用均局部声明一个临时数组(本人备注:即在MSort函数中声明),那么在任意时刻就可能有logN个临时数组处于活动期,这对于小内存的机器是致命的。另一方面,如果Merge例程动态分配并释放最小量临时内存,那么由malloc占用的时间会很多。严密测试指出,由于Merge位于MSort的最后一行,因此在任一时刻只需要一个临时数组活动,而且可以使用该临时数组的任意部分。(摘自Weiss数据结构与算法分析)
很明显,我没有考虑malloc所带来的效率损耗,而且这里说得很好,由于Merge位于MSort的最后一行,因此每一次递归调用中只会存在一个临时数组,而不会有上一层递归中声明的临时数组(已经释放掉了)。
为了避免递归使用malloc和free,我们还是用这种经典的实现方式的好,代码(一块贴上完整的测试代码)如下:
小总结
归并排序的最好最坏和平均时间复杂度都是O(n*logn),但是需要额外的长度为n的辅助数组(每次递归调用前都会释放上次递归中传入到Merge函数的brr数组),因此空间复杂度为O(n),而不会因为栈的最大深度为O(logn)而积累至O(n*logn)。占用额外空间是归并排序不足的地方,但是它是几个高效排序算法(快速排序、堆排序、希尔排序)中唯一稳定的排序方法。
快速排序
如名所示,快速排序是已知的平均时间复杂度均为O(n*logn)的几种排序算法中效率最高的一个,该算法之所以特别快,主要是由于非常精炼和高度优化的内部循环,它在最坏情况下的时间复杂度为O(n*n),但只要稍加努力(正确选择枢轴元素)就可以避免这种情形。
本部分的重点在于对分治思想的理解和代码的书写,不打算过多地讨论枢轴元素的选择,因为这本身就不是一个简单的问题,笔者对此也没有什么研究,更不敢造次。先来看实现思想。
实现思想
快速排序的基本思想如下:
1、从待排序列中任选一个元素作为枢轴;
2、将序列中比枢轴大的元素全部放在枢轴的右边,比枢轴小的元素全部放在其左边;
3、以枢轴为分界线,对其两边的两个子序列重复执行步骤1和2中的操作,直到最后每个子序列中只有一个元素。
一趟快速排序(以排序后从小到大为例)的具体做法如下:
附设两个元素指针low和high,初值分别为该序列的第一个元素的序号和最后一个元素的序号,设枢轴元素的值为val,则首先从high所指位置起向前搜索到第一个值小于val的元素,并将其和val互换位置,而后从low所指位置起向后搜索到第一个值大于val的元素,并将其和val交换位置,如此反复 ,直到low=high为止。
我们上面说交换位置,只是为了便于理解,我们在前面几篇内部排序的博文中一直在强调,应尽量避免比较多的元素交换操作,因此下面的分析和代码的实现中,我们并不是采取交换操作,而是先将枢轴元素保存在val变量中,然后每次遇到需要交换的元素时,先将该元素赋给val所在的位置,而后再将该元素所在位置“挖空”,之后的每一次比较,就用需要交换的元素来填充上次“挖空”的位置,同时将交换过来的元素所在的位置再“挖空”,以等待下次填充。
同样为了便于理解,我们以下面的序列为例来展示快速排序的思想。
下图为无序序列的初始状态,我们选取val为第一个元素4,low和high分别指向4和5:
进行1次比较之后(即从high开始遇到比val小的元素):
进行2次比较之后(即从low开始遇到比val大的元素):
进行3次比较之后(再次从high开始向左搜索):
进行4次比较之后(再次从low开始向右搜索):
进行5次比较之后(再次从high开始向左搜索),此时向左high向左移动一个位置后,出现low=high,第一趟排序结束,我们将val插入到此时被挖空的位置,也即low或high所指向的位置。因此第一趟排序后的结果如下:
而后,我们分别对3、1、2和6、7、5进行同样操作,最终便可以得到如下有序序列:
其中,加粗的元素为每趟比较时选取的枢轴元素,我们这里每次均选择子序列的第一个元素为枢轴。在这里,4为第一趟排序时选择的枢轴,3和6为第二趟排序时左右子序列分别选择的枢轴,第二趟排序后,数组的后半部分已经有序,前半部分虽然也有序了,但是根据定义,我们需要再选1作为枢轴,来对子序列{1,2}进行第三趟排序,这样最终便得到了该有序序列。
实现代码
很明显,实现快速排序要用到递归,我们根据以上思想,实现的代码如下(包含完整测试代码):
算法导论版快速排序
算法导论上的快速排序有点不同,主要是对findposs(Partition)函数的思路不同,它是以最后一个元素为枢轴元素(如果随机选取,将随机选取到的元素与最后一个元素交换位置即可),并且不是从两头往中间进行比较,而是从左一直往右比较,思路从下面的图中很容易看出:
该思路的findposs(Partition)函数实现代码如下(代码中的small相当于上图中的i):
下面加入随机选取枢轴元素的代码:
小总结
通常,快速排序被认为在所有同数量级(平均时间复杂度均为O(n*logn))的排序方法中,平均性能最好。但是若初始记录已经基本有序,这样每次如果还选择第一个元素作为枢轴元素,则再通过枢轴划分子序列时,便会出现“一边倒”的情况,此时快速排序就完全成了冒泡排序,这便是最坏的情况,时间复杂度为O(n*n)。所以通常枢轴元素的选择一般基于“三者取中”的原则,即比较首元素、末元素、中间元素的值,取三者中中间大小的那个。经验表明,采取这种方法可以大大改善快速排序在最坏情况下的性能。
快速排序需要一个栈来实现递归,很明显,如果序列中的元素是杂乱无章的,而且每次分割后的两个子序列的长度相近,则栈的最大深度为O(logn),而如果出现子序列“一边倒”的情况,则栈的最大深度为O(n)。因此就平均情况来看,快速排序的空间复杂度为O(logn)。