为了展示初级排序算法性质的价值,接下来我们将学习一种基于插人排序的快速的排序算法。对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另端。例如,如果主键最小的元素正好在数组的尽头,要将它挪到正确的位置就儒要N-1次移动。希尔排序为了加快速度简单地改进了插人排序,交换不相邻的元素以对数组的局部进行排序,并最终用插人排序将局部有序的数组排序。
希尔排序的思想是使数组中任意间隔为h的元索都是有序的。这样的数组被称为h有序数组。换句话说,一个h有序数组就是h个互相独立的有序数组编织在一起组成的一个数组在进行排序时,如果h很大,我们就能将元素移动到很远的地方,为实现更小的h有序创造方便。用这种方式,对于任意以1结尾的h序列,我们都能够将数组排序。这就是希尔排序。算法2.3的实现使用了序列1/2(3-1),从N3开始递减至1。我们把这个序列称为递增序列。下列算法实时计算了它的递增序列,另一种方式是将递增序列存储在一个数组中。
实现希尔排序的一种方法是对于每个h,用插入排序将h个子数组独立地排序。但因为子数组是相互独立的,一个更简单的方法是在h-子数组中将每个元素交换到比它大的元素之前去(将比它大的元素向右移动一格)。 只需要在插入排序的代码中将移动元素的距离由1改为h即可。这样,希尔排序的实现就转化为了一个类似于插入排序但使用不同增量的过程。
希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。子数组部分有序的程度取决于递增序列的选择。透彻理解希尔排序的性能至令仍然是一项挑战。实际上,下列算法是我们唯一无法准确描述其对于乱序的数组的性能特征的排序方法。
public class Shell{
public static void sort(Comparable[] a){
// 将a[]按升序排列
int N = a.length;
while (h < N/3) h = 3*h + 1; //1,4,13,40,121,364, 1093,.....
while (h>= 1){
// 将数组变为h有序
for(int i = h; i < h; i++){
//将a[i]插入到a[i-h], a[i-2*hj, ati-3*h]... 之中.
for (int j=i; j >= h.&& less(a[j], a[j-h]); j -= h){
exch(a, j, j-h);
}
h = h/3;
}
}
}
如果我们在插入排序中加入一个外循环来将h按照递增序列递减,我们就能得到这个简洁的希尔排序。增幅h的初始值是数组长度乘以一个常数因子,最小为1。
如何选择递增序列呢?要回答这个问题并不简单。算法的性能不仅取决于h,还取决于h之间的数学性质,比如它们的公因子等。有很多论文研究了各种不同的递增序列,但都无法证明某个序列是“最好的”。上面的算法中递增序列的计算和使用都很简单,和复杂递增序列的性能接近。但可以证明复杂的序列在最坏情况下的性能要好于我们所使用的递增序列。
和选择排序以及插入排序形成对比的是,希尔排序也可以用于大型数组。它对任意排序(不一定是随机的)的数组表现也很好。实际上,对于一个给定的递增序列,构造一个使希尔排序运行缓慢的。
通过SortCompare可以看到,希尔排序比插入排序和选择排序要快得多,并且数组越大,优势越大。一个很重要理念:通过提升速度来解决其他方式无法解决的问题是研究算法的设计和性能的主要原因之一。
在输入随机排序数组的情况下,我们在数学上还不知道希尔排序所需要的平均比较次数。人们发明了很多递增序列来渐进式地改进最坏情况下所需的比较次数(N", N*, N…但这些结论大多只有学术意义,因为对于实际应用中的N来说它们的递增序列的生成函数(以及与N乘以一个常数因子)之间的区别并不明显。
性质E:使用递增序列1,4, 13, 40, 121, 3…的希尔排序所需的比较次数不会超出N的若干倍乘以递增序列的长度。
例证:记录上面的算法中比较的数量并将其除以使用的序列长度。大量的实验证明平均每个增福所带来的比较次数约为N",但只有在N很大的时候这个增长幅度才会变得明显。这个性质似乎也和输入模型无关。
有经验的程序 员有时会选择希尔排序,因为对于中等大小的数组它的运行时间是可以接受的。它的代码量很小,且不需要使用额外的内存空间。在下面的几节中我们会看到更加高效的算法,但除了对于很大的N.它们可能只会比希尔排序快两倍(可能还达不到),而且更复杂。如果你需要解决一个排序问题而又没有系统排序函数可用(例如直接接触硬件或是运行于嵌人式系统中的代码),可以先用希尔排序,然后再考虑是否值得将它替换为更加复杂的排序算法。
一部分的算法都基于归并这个简单的操作,即将两个有序的数组归并成一个更大的有序数组。很快人们就根据这个操作发明了一种简单的递归排序算法:归并排序。要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。你将会看到,归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和MogN成正比;它的主要缺点则是它所需的额外空间和N成正比。
实现归并的种直截了当的办法是将两个不同的有序数组归并到第三个数组中,两个数组中的元素应该都实现了Comparable接口。实现的方法很简单,创建一个适当大小的数组然后将两个输人数组中的元素一个个从小到大放入这个数组中。
但是,当用归并将一个大数组排序时,我们需要进行很多次归并,因此在每次归并时都创建个新数组来存储排序结果会带来问题。我们更希望有一种能够 在原地归并的方法,这样就可以先将前半部分排序,再将后半部分排序,然后在数组中移动元素而不需要使用额外的空间。你可以先停下来想想应该如何实现这一点, 乍一看很容易做到,但实际上已有的实现都非常复杂,尤其是和使用额外空间的方法相比。
尽管如此,将原地归并抽象化仍然是有帮助的。与之对应的是我们的方法签名merge(a,lo,mid,hi),它会将子数组a[lo…mid]和a[mid+1…hi]归并成一个有序的数组并将结果存放在a[lo…hi]中。下面的代码只用几行就实现了这种归并。它将涉及的所有元素复制到一个辅助数组中,再把归并的结果放回原数组中。
public static void mergeComparable[] a. int lo, int mid, int hi){
// 将a[lo..mid]和a[mid+1..hi] 归并
int i = lo, j = mid+1;
for(int k = lo;k <= hi;k++){
aux[k] = a[k];
}
for (int k= lo; k <= hi; k++) { // 归并回到a[lo..hi]
if(i > mid) a[k] = aux[j++];
else if(j > hi) a[k] = aux[i++];
else if(less(aux[j], aux[i]) a[k] = aux[j++];
else a[k] = aux[i++];
}
}
该方法先将所 有元素复制到aux[]中,然后再归并回a[]中。方法在归并时(第二个for循环).进行了4个条件判断:左半边用尽(取右半边的元素)、右半边用尽(取左半边的元素)、右半边的当前元素小于左半边的当前元素(取右半边的元素)以及右半边的当前元素大于等于左半边的当前元素(取左半边的元素)。
下面的算法基于原地归并的抽象实现了另一种递归归并, 这也是应用高效算法设计中分治思想的最典型的一个例子。这段递归代码是归纳证明算法能够正确地将数组排序的基础:如果它能将两个子数组排序,它就能够通过归并两个子数组来将整个数组排序。
public class Merge{
private static Comparable[] aux; //归并所需的辅助数组
public static void sort(Comparable[] a){
aux = new Comparable[a.length]; // 一次性分配空间
sort(a, 0, a.length -1);
}
private static void sort(Comparable[] a, int lo, int hi){ //将数组a[lo. .hi]排序
if (hi-<= lo) return;
int mid = lo+ (hi - lo)/2;
sort(a, lo, mid); //将左半边排序
sort(a, mid+1, hi); //将右半边排序
merge(a, lo, mid, hi); //归并结果(代码见“原地归并的抽象方法”)
}
}
要对子数组a[lo…hi]进行排序,先将它分为a[lo. .mid]和a[mid+1…hi]两部分,分别通过递归调用将它们单独排序,最后将有序的子数组归并为最终的排序结果。
命题F:对于长度为N的任意数组,自顶向下的归并排序需要%NgN至MgN次比较。
证明:令C(N表示将一个长度为N的数组排序时所需要的比较次数。我们有C(0)=C(1)-0,对于N>0,通过递归的sort()方法我们可以由相应的归纳关系得到比较次数的上限:
C(MN)≤ C(N/2)+C(N/2)+N
右边的第一项是将数组的左半部分排序所用的比较次数,第二项是将数组的右半部分排序所用的比较次数,第三项是归并所用的比较次数。因为归并所需的比较次数最少为N/2,比较次数的下限是:
C(M)≥ C(N/2)+C(N/2)+N/2
当N为2的幂且等号成立时我们能够得到一个解。首先,可以得到:
C(2)= 2C(2”-2)+2”
将两边同时除以2”可得:
C(2)/2"= C(2" )2*-+1
用这个公式替换右边的第一项,可得:
(2)/2" =(2-3)2-+1+1
将上一步重复n-1遍可得:
C(2")/2"= C(2)/2"+n
将两边同时乘以2”就可以解得:
C(N)=C(2)=n2= MgN
对于一般的N,得到的准确值要更复杂一些。但对比较次数的上下界不等式使用相同的方法不难证明前面所述的对于任意N的结论。这个结论对于任意输入值和顺序都成立。
命题G:对于长度为N的任意数组,自顶向下的归并排序最多需要访问数组6MgN次。
证明:每次归并最多需要访问数组6N次(2N次用来复制,2N次用来将排好序的元素移动回去,另外最多比较2N次),根据命题F即可得到这个命题的结果。
命题F和命题G告诉我们归并排序所需的时间和MgN成正比。它表明我们只需要比遍历整个数组多个对数因子的时间就能将一个庞大的数组排序。可以用归并排序处理数百万甚至更大规模的数组,这是插入排序或者选择排序做不到的。归并排序的主要缺点是辅助数组所使用的额外空间和N的大小成正比。
用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中方法的调用过于频繁,所以改进对它们的处理方法就能改进整个算法。对排序来说,我们已经知道插入排序(或者选择排序)非常简单,因此很可能在小数组上比归并排序更快。
我们可以添加一个判断条件,如果a[mid]小于等于a[mid+1],我们就认为数组已经是有序的并跳过merge()方法。这个改动不影响排序的递归调用,但是任意有序的子数组算法的运行时间就变为线性的了。
我们可以节省将数组元素复制到用于归并的辅助数组所用的时间(但空间不行)。要做到这一点我们要调用两种排序方法,一种将数据从输入数组排序到辅助数组,一种将数据从辅助数组排序到输入数组。这种方法需要一些技巧, 我们要在递归调用的每个层次交换输入数组和辅助数组的角色。
递归实现的归并排序是算法设计中分治思想的典型应用。我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。尽管我们考虑的问题是归并两个大数组,实际上我们归并的数组大多数都非常小。实现归并排序的另一种方法是先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一-起。 这种实现方法比标准递归方法所需要的代码量更少。首先我们进行的是两两归并(把每个元素想象成二个大小为1的数组),然后是四四归并(将两个大小为2的数组归并成一个有4个元素的数组),然后是八八的归并,一直下去。在每一轮归并中,最后一次归并的第二个子数组可能比第一个子数组要小(但这对merge()方法不是问题),如果不是的话所有的归并中两个数组大小都应该一样,而在下一轮中子数组的大小会翻倍。
public class MergeBU{
private static Comparable[] aux; //归并所需的辅助数组
//Merge()方法的代码见“原地归并的抽象方法”
public static void sort(Comparable[] a){
int N= a.length;
aux = new Comparable[N];
for(int s =1; s < N; s = s+s){ // s子数组大小
for (int lo = 0; lo < N-s; lo+= s+s){ // lo:子数组索引
merge(a, lo, lo+s-1, Math.min(lo+s+s-1, N-1));
}
}
}
}
自底向上的归并排序会多次遍历整个数组,根据子数组大小进行两两归并。子数组的大小s的初始值为1,每次加倍。最后个子数组的大小只有在数组大小是s的偶数倍的时候才会等于s(否则它会比s小)。