算法(第四版)2.2 归并排序
算法(第四版)2.2 归并排序
归并排序,即将两个有序的数组归并成一个更大的有序数组。很-很快人们就根据这个操作发明了一种简单的递归排序算法:归并排序。要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。
原地归并的抽象方法
实现归并的一种直截了当的方法时将两个不同的有序数组归并到第三个数组中,两个数组中的元素应该都实现了Comparable
接口。实现的方法很简单,创建一个适当大小的数组然后将两个输入数组中的元素一个个从小到大放入这个数组中。
但是,当用归并将一个大数组排序时,我们需要进行很多次归并,因此在每次归并时都创建一个新数组来存储排序结果会带来问题。我们更希望有一种能够在原地归并的方法,这样就可以先将前半部分排序,再将后半部分排序,然后在数组中移动元素而不需要额外的空间。
尽管如此,将原地归并抽象化仍然是有帮助的。与之对应的是我们的方法签名merge(a,lo,mid,hi)
,它会将子数组a[lo..mid]
和a[mid+1..hi]
归并成一个有序的数组并将结果存放在a[lo..hi]
中。下面的代码只用了几行就实现了这种归并。它将涉及的所有元素复制到一个辅助数组中,再把归并的结果放回原数组中。
原地归并的抽象方法
public static void merge(Comparable[] 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]; // 将a[lo..hi]复制到aux[lo..hi]中
}
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); // 归并结果(代码见“原地归并的抽象方法”)
}
}
命题F。 对于长度为N的任意数组,自顶向下的归并排序需要½NlgN至NlgN次比较。
证明。令
C(N)
表示将一个长度为N
的数组排序时所需要的比较次数。我们有C(0)=C(1)=0
,对于N>0
,通过递归的sort()
方法我们可以由相应的归纳关系得到比较次数的上限:\[C(N) <= C(\lceil N/2\rceil) + C(\lfloor N/2\rfloor) + N \]右边的第一项,即\(C(\lceil N/2\rceil)\)是将数组的左半部分排序所用的比较次数,右边的第二项,即\(C(\lfloor N/2\rfloor)\)是将数组右半部分排序所用的比较次数,第三项时归并所用的比较次数。因此归并所需的比较次数最少为\(\lfloor N/2\rfloor\),比较次数的下限是:
\(C(N)>=C(\lceil N/2\rceil) + C(\lfloor N/2\rfloor) + \lfloor N/2\rfloor\)
当N为2的幂时(即\(N=2^n\))且等号成立时我们能够得到一个解。首先,因为\(\lfloor N/2\rfloor=\lceil N/2\rceil=2^{n-1}\),可以得到:
\[C(2^n) = 2C(2^{n-1}) + 2^n \]将两边同时除以\(2^n\)可得:
\[C(2^n)/2^n = C(2^{n-1})/2^{n-1} + 1 \]用这个公式替换右边的第一项,可得:
\[C(2^n)/2^n = C(2^{n-2})/2^{n-2} + 1 + 1 \]将上一步重复n-1遍可得:
\[C(2^n)/2^n = C(2^{0})/2^{0} + n \]将两边同时乘以\(2^n\)就可以解得:
\[C(N) = C(2^n)=n2^n=NlgN \]对于一般的N,得到的准确值要复杂一些。但对于比较次数的上下界不等式使用相同的方法不难证明所述的对于任意N的结论。这个结论对于任意输入值和顺序都成立。
上面的过程是证明了比较次数的上限,依样画葫芦,就可以得到下限为½NlgN。
命题G。对于长度为
N
的任意数组,自顶向下的归并排序最多需要访问数组6NlgN
次。证明。每次归并最多需要访问数组6N次(2N次用来复制,2N次用量将排好序的元素移动回去,另外最多需要比较2N次),根据命题F即可得到这个命题的结果。
自底向上的归并排序
实现归并排序的两一种方法时先归并那些微型数组,然后再成对归并得到的子数组,如此这般,这般如此,直到我们将整个数组并在一起。
public class MergeBU {
private static Comparable[] aux; // 归并所需的辅助数组
private static void sort(Comparable[] a){
// 将数组a[lo..hi]排序
int N = a.length;
aux = new Comparable[N];
for (int sz = 1; sz < N; sz = sz + sz) { // 子数组大小
for (int lo = 0; lo < N - sz; lo += sz + sz) { // lo:子数组索引
merge(a, lo, lo + sz -1, Math.min(lo + sz + sz -1, N -1));
}
}
}
}
自底向上的归并排序会多次遍历整个数组,根据子数组的大小进行两两归并。子数组的大小sz
的初始值为1
,每次加倍。最后一个子数组的大小只有在数组大小是sz
的偶数倍的时候才会等于sz
(否则它会比sz
小)。
自底向上的归并排序的归并结果
sz = 1
merge(a, 0, 0, 1)
merge(a, 2, 2, 3)
merge(a, 4, 4, 5)
merge(a, 6, 6, 7)
merge(a, 8, 8, 9)
merge(a, 10, 10, 11)
merge(a, 12, 12, 13)
merge(a, 14, 14, 15)
sz = 2
merge(a, 0, 1, 3)
merge(a, 4, 5, 7)
merge(a, 8, 9, 11)
merge(a, 12, 13, 15)
sz = 4
merge(a, 0, 3, 7)
merge(a, 8, 11, 15)
sz = 8
merge(a, 0, 7, 15)
命题H。 对于长度为N的任意数组,自底向上的归并排序需要1/2NlgN至NlgN次比较,最多访问数组6NlgN次。
证明。 处理一个数组的遍数正好是\(\lceil lgN\rceil (即2^n <= N <2^{n+1}中的n)\)。每一遍会访问数组6N次,比较次数在N/2和N之间。
当数组长度为2的幂时,自顶向下和自底向上的归并排序所用的比较次数和数组访问次数正好相同,只是顺序不同。
自底向上的归并排序比较适合用链表组织的数据。想象一下将链表先按大小为1的子链表进行排序,然后是大小为2的子链表,然后是大小为4的子链表等。这种方法只需要重新组织链表链接就能将链表原地排序(不需要创建任何新的链接节点)。
added by wxp: 并不是太明白。。。
排序算法的复杂度
命题I。 没有任何基于比较的算法能够保证使用少于\(lg(N!) 到 NlgN\)次比较将长度为N的数组排序。
证明。 首先,假设没有重复的主键,因为任何排序算法都必须能够处理这种情况。我们使用二叉树来表示所有的比较。树中的结点要么是一片叶子 \(i_0 i_1 i_2 ...i_n\),表示排序完成且原输入的排列顺序是\(a[i_0],a[i_1]...a[i_n]\),要么是一个内部结点
i:j
,表示a[i]
和a[j]
之间的一次比较操作,它的左子树表示a[i]
小于a[j]
时进行的其他比较,右子树表示a[i]
大于a[j]
时进行的其他比较。从跟结点到叶子结点每一条路径都对应着算法在建立叶子结点所示的顺序是进行的所有比较。我们从来没有明确地构造这棵树——它只是用来描述算法中的比较的一个数学工具。
从比较树观察得到的第一个重要结论是这棵树应该至少有
N!
个叶子结点,因为N个不同的主键会有N!
种不同的排列。如果叶子结点少于N!
,那肯定有一些排列顺序被遗漏了。算法对于那些被遗漏的输入肯定会失败。从跟结点到叶子结点的一条路径上的内部结点的数量即是某种输入下算法进行比较的次数。我们感兴趣的事这种路径能有多长(也就是树的高度),因为这也就是算法比较次数的最坏情况。二叉树的一个基本的组合学性质就是高度为
h
的树最多只可能有\(2^h\)个叶子结点,拥有\(2^h\)个结点的树是完美平衡的,或称为完全树。结合前两段的分析可知,任意基于比较的排序算法都对应着一棵高
h
的比较树。\[N! <= 叶子结点的数量 <= 2^h \]
h
的值就是最坏情况下的比较次数,因此对不等式的两边取对数可得到任意算法的比较次数至少是lgN!
。根据斯特灵公式对阶乘函数的近似可得lgN! ~ NlgN
。
命题J。归并排序是一种渐进最优的基于比较排序的算法。
证明。 更准确的说,这句话的意思是,归并排序在最坏的情况下的比较次数和任意基于比较的排序算法所需的最少的比较次数都是
~NlgN
。命题H和命题I证明了这些结论。