第一章 算法概念
1.3 算法设计
1.3.1 分治算法
伪代码:
MERGE(A, p, q, r)
1 n1 ← q - p + 1
2 n2 ← r - q
3 create arrays L[1 ‥ n1 + 1] and R[1 ‥ n2 + 1]
4 for i ← 1 to n1
5 do L[i] ← A[p + i - 1]
6 for j ← 1 to n2
7 do R[j] ← A[q + j]
8 L[n1 + 1] ← ∞
9 R[n2 + 1] ← ∞
10 i ← 1
11 j ← 1
12 for k ← p to r
13 do if L[i] ≤ R[j]
14 then A[k] ← L[i]
15 i ← i + 1
16 else A[k] ← R[j]
17 j ← j + 1
这个函数的功能是,将A[p … r]排顺序,假设A[p … q]和A[q + 1 … r]已经排好顺序(p≤q<r—)。
我手上的影印本《算法导论》里,≤和<很难区分清楚,所以不知道是否中文版本身就有问题,看上去都一样。但实际上,这种边界条件非常重要的,所以,必须参照英文原版。
显然,这只是分治算法的一部分。大方向上来说,分治算法一般分为:分治,解决,合并。
上面的MERGE(A, p, q, r)就是“合并”。
而这段分治排序法的合并部分,在其他场合也一样有用。那就是对已经排序的数列进行合并。由于没有双重循环,所以其时间复杂度为Θ(n) 。循环体最多被执行n次(n = r - p + 1)。
伪代码:
MERGE-SORT(A, p, r)
1 if p < r
2 then q ← ┕(p + r)/2┙
3 MERGE-SORT(A, p, q)
4 MERGE-SORT(A, q + 1, r)
5 MERGE(A, p, q, r)
这就是“分治”和“解决”了。递归的好处,或者说这里的分治的好处就是:程序清晰。上面这段代码太让人赏心悦目了。q ← ┕(p + r)/2┙,设定分治点;A[p … r]分成A[p … q]和A[q + 1 … r]两部分,然后调用MERGE(A, p, q, r)进行“合并”。
Java实现:
import java.util.Arrays; import java.util.Comparator; public class MergeSort { public static <T> void merge(T[] a, int p, int q, int r, Comparator<? super T> c) { T[] left = Arrays.copyOfRange(a, p, q); T[] right = Arrays.copyOfRange(a, q, r); int indexLeft = 0; int indexRight = 0; for (int i = p; i < r; i++) { if (indexLeft >= left.length) { break; } if (indexRight >= right.length) { System .arraycopy(left, indexLeft, a, i, left.length - indexLeft); break; } if (c.compare(left[indexLeft], right[indexRight]) < 0) { a[i] = left[indexLeft++]; } else { a[i] = right[indexRight++]; } } } public static <T> void mergeSort(T[] a, int p, int r, Comparator<? super T> c) { if (p + 1 < r) { int q = (p + r) / 2; mergeSort(a, p, q, c); mergeSort(a, q + 1, r, c); merge(a, p, q, r, c); } } public static <T> void mergeSort(T[] a, Comparator<? super T> c) { mergeSort(a, 0, a.length, c); } public static void main(String[] args) { // merge's test System.out.println("merge method test"); Integer[] tempMerge = new Integer[] { 1, 4, 7, 11, 14, 17, 2, 4, 6, 8, 10, 20, 30, 40 }; merge(tempMerge, 0, 6, tempMerge.length, new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return o1 - o2; } }); for (int i : tempMerge) { System.out.print(i + " "); } System.out.println(); // mergeSort's test System.out.println("mergeSort method test"); Integer[] tempMergeSoft = new Integer[] { 5, 2, 4, 6, 1, 3, 2, 6 }; mergeSort(tempMergeSoft, new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return o1 - o2; } }); for (int i : tempMergeSoft) { System.out.print(i + " "); } System.out.println(); } }
输出:
merge method test
1 2 4 4 6 7 8 10 11 14 17 20 30 40
mergeSort method test
1 2 2 3 4 5 6 6
coding后感
1,很有意思的是,java.util.Collections的“public static <T> void sort(List<T> list, Comparator<? super T> c)”,里面调用的java.util.Arrays类里面的函数,正是“private static void mergeSort(Object[] src, Object[] dest, int low, int high, int off)”,这是个private函数,外面不可见,参数low,high,off和《算法导论》里的参数p,q,r,意思是不同的,特别是off是偏移量,而q是中间值;《算法导论》里面的MERGE(A, p, q, r)是只是合并,分治和解决在MERGE-SORT(A, p, r)里面,而Arrays是放在的mergeSort里面,全部搞定的。这点区别还是要注意一下的。
2,为了练手,看到Arrays里面有mergeSort后,故意不参考它,只是根据《算法导论》的伪代码写下的程序,结果当然是差不多。 看来我一个人也抵得上Josh Bloch、Neal Gafter、John Rose他们仨了(Arrays类的作者,2006年),呵呵。
3,上一篇写“插入排序”的时候,我说“插入排序本身是没有任何实用价值的”。可能是我错了。因为Arrays里面也有插入排序,上次我没有看到。看来插入排序也是有实用的地方,就现在这个分治排序也是有用到的地方的,凡排序必云“快速排序”,是不行的。
4,合并函数merge,其实还是很有用的。不只是这个分治算法里可用,更普遍的情况,对2个(甚至多个)有序组数的再排序,可以把两个已排好序的数组,直接连在一起后,调用merge。所以我特地加了段测试merge函数的代码放在main的最前面。
5,Java的数组从0开始是到“长度-1”为止这个特点,再次使我比较困扰。比方说mergeSort里面的if文,伪代码的是“ p < r”,如果Java里面也写“p < r”,结果是不变的,一样可以正常排序。但是我debug时发现,如果用p < r,当分治到只有2个元素是,还会再分,直到分治到只有一个元素,而合并时就是没有元素的数组和只有一个元素的数组进行合并,平白多了一层递归,和一次没有用的合并。改成“p + 1 < r”后,就没有这个问题了,分治到2个元素为止,2个元素时返回(退出一层递归),交个merge函数去合并。
6,我在上面即用了System的arraycopy函数,又用了Arrays的copyOfRange,我的本意是不想用java.util包的东西的(接口除外),希望不熟悉API的初学者也可以看懂所有的代码。但是细看copyOfRange后,发现里面也就是新建一个数组,调用System的arraycopy函数来复制,还是很容易理解的。现在这篇东西主要讲分治,所以copyOfRange的细节就不讲述了,有兴趣的人可以自己去看看,还是很好玩的。
7,结合5,6两点,Java的数组的上下限,System的arraycopy的参数的意义(主要是位置参数和长度参数),Arrays的copyOfRange的参数的意义(复制的是由from和to参数定义的前闭后开区间),这3样混在一起,会发现,伪代码和Java实现的样子,看上去会很不同。
8,和前面的插入排序一样,这里分治排序Java的实现里面,是直接改变被排序的数组里面的内容的。
9,MERGE伪代码里面的1到7行,在Java实现里面就只需要2句话,API真是强大啊(或者说会这么用API的人,真是强大啊。哈哈。)。MERGE伪代码里面的8,9行,一点也不优雅,被俺好不犹豫的舍弃了。后面的for循环,做的事儿是一样的,但是我的代码比伪代码要更高效一点。不需要循环整个被排序区间,只要循环到左右两段被排序区间的任意一段遍历完成就行。遍历完后,如果是左段先结束,那么什么都不用做,排序就已经好了,因为右段本身就是已经排好的;如果是右段先结束,那么左段还剩下的元素要紧跟着,复制到已经排好的数组元素的后面。当然,Java更高效的原因是存在数组复制的API,而不是伪代码的错(不优雅的“凭空增加一个数组元素,往里面赋值无限大”除外)。