据有关砖家说每天YY几次有益身心健康,所以让我们来练习一下:如果有一天我们也发明了个什么算法,叫什么名字好呢?
不如先参考一下前辈们都是怎么做的。
1)根据物理特性或实现方法命名:插入排序、归并排序、二分查找、螺旋丸、色诱术(天杀的,这个居然是A级忍术);
2)以发明者名字命名:希尔排序、霍夫曼编码、高斯消去法、Linux;
3)用单词首字母组合命名:SUN(Standford Unix Networks,啧啧,一看就是纯技术公司,以后我要是开公司就叫 CRUD & HTML & Information & NewTech & ASP & .Net & BS——CHINA.NB)、IBM(International Business Machines Corporation)、UNIX(Uniplexed Information and Computering System);
4)借用地名、动物名以及各种八杆子打不着的东西的名字:东芝(东京芝浦电气株式会社)、小天鹅、苹果、Oracle(神谕、预言)、NIKE(希腊胜利女神的名字)、Java、python、.net(说实话每次去Google的时候都不知到应该搜“.net”还是“dotnet”,见过无厘头的);
5)诗意型:美的、Hibernate、月读、红黑树(为什么不叫黑白树呢?不知道作者是不是特别喜欢看《红与黑》)。
6)心系祖国型:匈牙利命名法、木叶旋风。
7)比你强比你强型:C语言(比B语言强)、C++(比C语言强)、C#(比C++强2倍)、Eclipse(有哥在,Sun也要黯然失色);
8)跟你差不多型:Ruby(因为Perl与pearl谐音,你叫珍珠来我就叫宝石);
9)自信型:快速排序(只要有哥在,你们永远只能排第二)。
命名方法真是五花八门、不胜枚举,不过自信型的还真是挺少见的。这也难怪,就算是世界冠军也不敢说自己的记录永远没人破得了,更何况是一个见多识广的科学家呢?如果要用一个形容词来命名的话,叫“酷毙的、优雅的、梦幻般的”等等都没什么大不了的,但是如果叫快速排序——我们职业程序员兼副业起名专家都知道,一旦有了个更快的算法,就得叫QuickerSort,以及QuickestSort,以及ReallyQuickestSort,以及ReallyReallyQuickestSort……还有,所有的教科书都得加上一句话,“以前,快速排序确实是最快的排序算法,不过,自从XX算法发明以来,快速排序就名不符实了”,这可多没面子!
不过,事实证明Hoare的自信是很有根据的,自从这位仁兄在1962年发表了这个排序算法,它就一直稳居“最实用排序算法”的宝座。它到底厉害在什么地方?让我来一探究竟吧。
快速排序
快速排序也是使用了分治法的思路,只是我怀疑Hoare以前在中国餐馆当过厨师,因为这哥们在分解前先给数组改了个刀。
分解:将数组s[p..r]划分成两个(可能为空)的子数组A和C以及一个元素x,划分后s={ A, x, C },并确保A中的每个元素都小于等于x,C中的每个元素都大于等于x。
解决:递归调用快速排序,对子数组A和C排序。
合并:只要将子数组处理成有序的,整个数组就是有序的,不需要合并。
static void QuickSort(int[] s, int p, int r) { // 判断是否能够直接解决 if(p < r) // 需要进一步分解 { // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x int q = Partition(s, p, r); // 递归解决 QuickSort(s, p, q-1); // 排序A QuickSort(s, q+1, r); // 排序C // 合并:A和C处理成有序的之后,整个数组就是有序的,不需要合并 } // p >= r时, s[p..r]是空数组或只有一个元素,本身就是有序的,直接返回 }
这个用来扒堆的Partition()函数看上去有些复杂。
static int Partition(int[] s, int p, int r) { int x = s[r]; int i = p-1; for(int j = p; j<=r-1; j++) { if(s[j] <= x) { i++; int temp = s[i]; s[i] = s[j]; s[j] = temp; } } int temp2 = s[i+1]; s[i+1] = s[r]; s[r] = temp2; return i+1; }
第一个问题是,如何确定选哪个元素做x?因为s是无序的,所以我们无法确定s里面的元素的分布情况,所以答案是“只能随便选一个好了”,例如上面的代码就是选数组的最后一个元素s[r]作为x。
接下来,我们使用第一篇里面介绍的增量法生成A和C(A中的所有元素都小于等于x,C中的所有元素都大于x),A=s[p..i],C=s[i+1..j-1],U=s[j..r-1]为无序的子数组。每次迭代都会从U中拿出一个元素放入A或C中。我们把第一次调用Partition()的过程写一下。
输入:s={ 6, 2, 5, 1, 7, 4, 3 };
初始时:A={ },C={ },U={ 6, 2, 5, 1, 7, 4 },x=3;
第1次迭代:A={ 6 },C={ },U={ 2, 5, 1, 7, 4 },将A中最后一个元素与U中第一个元素交换,得到A={ 2 },U={ 6, 5, 1, 7, 4 };
第2次迭代:A={ 2 },C={ 6 },U={ 5, 1, 7, 4 };
第3次迭代:A={ 2, 6 },C={ 5 },U={ 1, 7, 4 },将A中最后一个元素与U中第一个元素交换,得到A={ 2, 1 },C={ 5 }, U={ 6, 7, 4 };
第4次迭代:A={ 2, 1 },C={ 5, 6 }, U={ 7, 4 };
第5次迭代:A={ 2, 1 },C={ 5, 6, 7 }, U={ 4 };
迭代结束时:A={ 2, 1 },C={ 5, 6, 7, 4 }, U={ };
再将x与C的第一个元素交换位置,得到结果s={ A, x, C }, A={ 2, 1 },x=3,C={ 6, 7, 4, 5 }。
快速排序的性能
快速排序和归并排序都是分治法,它们的区别在于,归并排序用(非常微小的)常量时间代价划分子数组,然后用Θ(n)的时间代价合并子数组;快速排序用Θ(n)的时间代价划分子数组,但是不需要合并操作。快速排序的平均运行时间为Θ(n log n),而且Θ(n log n)记号中隐含的常数因子很小。也就是说,快速排序通常要比归并排序快一点。为什么说快速排序的常数因子比较小呢?因为归并排序的Merge()函数是实打实的一个元素一个元素地进行赋值操作(而且得赋值2遍),而在快速排序的Partition()里面经常是一个j++就过去了。
不过还不能高兴得太早,刚刚说的只是平均时间代价。在某些极端情况下,例如输入是像 { 1, 2, 3 }或{ 3, 2, 1 }这种有序的数组,或者像{ 3, 3, 3 }这种所有元素的值都相同的时候,每次对数组的划分都会是{ A, x } 或 { x, C }这种极端不平衡的形式,在这种最坏情况下,快速排序的时间代价为Θ(n2),和插入排序一样慢。
所以,快速排序要想成为名副其实的“最实用排序算法”,必须得想办法爬过“有序输入”与“重复元素输入”这两座大山才行。
对付“重复元素输入”
如果输入是 { 2, 2, 2, 2, 2 },我们希望能划分为 {{2,2}, 2, {2,2}} 这种形式,而不是 {{2,2,2,2}, 2}}。要想达到这个目标,只需要对Partition()稍作修改,让 i 和 j 分别从数组的两头向中间靠拢。
static int EPartition(int[] s, int p, int r) { int x = s[r]; int i = p; int j = r - 1; // 每次迭代都确保 s[p..i-1] 中的每个元素都小于等于x,s[j+1..r] 中的每个元素都大于等于x while (i <= j) { while (i<=r-1 && s[i] < x) i++; while (j>=p && x < s[j]) j--; if (i <= j) { int temp = s[i]; s[i] = s[j]; s[j] = temp; i++; j--; } } // 交换s[r] 和 s[j+1] s[r] = s[j + 1]; s[j + 1] = x; return j + 1; }
我们仍然使用增量法生成A和C,只不过这次A=s[p..i-1],C=s[j+1..r],U=s[i..j],x=s[r]。初始时A={},C={},每次迭代都从U中拿出若干元素放入A和C中,并通过交换A的最后一个元素和C的第一个元素的方法确保每次迭代A中的每个元素都小于等于x,C中的每个元素都大于等于x,直到A和C相邻时迭代结束,再将x与C互换位置即可。
对付“有序输入”——随机化Partition和三数取中法
如果每次都从一个固定的位置取x,就很可能每次都取到最大或最小的元素,造成极端不平衡的划分。于是聪明的人类想到了每次都从一个随机的位置取得x,如果这样还每次都取到最大或最小元素,那几率要比中500万大奖还要低得多。我们把Partition()函数改造一下,先随机选一个元素与s[r]交换(第4行~第7行)。
static Random random = new Random(); static int RPartition(int[] s, int p, int r) { int xi = random.Next(p, r+1); // 取 [p,r+1) 之间的随机数 int temp1 = s[xi]; s[r] = s[xi]; s[xi] = temp1; int x = s[r]; int i = p - 1; for (int j = p; j <= r - 1; j++) { if (s[j] <= x) { i++; int temp = s[i]; s[i] = s[j]; s[j] = temp; } } int temp2 = s[i + 1]; s[i + 1] = s[r]; s[r] = temp2; return i + 1; }
不过人们还是不满足,想着“有什么办法能把数组划分得更加平衡——也就是让x更加接近数组的中数呢?”。人们想到了随机取3个元素,然后取这3个元素的中数作为x,这就是三数取中法。不过.Net Framwork里面的QuickSort虽然也是用的三数取中法,却不是取随机数,而是固定取数组的“第一个元素、中间的元素和最后一个元素”这3个数的中数作为x。想想也对,取3个随机元素的中数或是取3个固定位置的元素的中数在效果上是差不多的(再说调用random.Next()会耗费额外的时间),除非有某个坏人在看过了.net的源码之后,故意反其道而行之,搞出一个让微软出丑的输入(你能做到么?)。
那么如何让s[r]成为s[p]、s[n/2]、s[r]的中数呢?其实就是用冒泡排序法对s[p]、s[r]、s[n/2]这三个数拍个序。
static int TPartition(int[] s, int p, int r) { int m = p + (r - p) / 2; // 数组中间的元素的下标 // 为使s[r]成为s[p]、s[r]、s[m]的中数,对这三个数排序 if (s[p] > s[r]) { int temp1 = s[p]; s[p] = s[r]; s[r] = temp1; } if (s[r] > s[m]) { int temp1 = s[r]; s[r] = s[m]; s[m] = temp1; } if (s[p] > s[r]) { int temp1 = s[p]; s[p] = s[r]; s[r] = temp1; } int x = s[r]; int i = p - 1; for (int j = p; j <= r - 1; j++) { if (s[j] <= x) { i++; int temp = s[i]; s[i] = s[j]; s[j] = temp; } } int temp2 = s[i + 1]; s[i + 1] = s[r]; s[r] = temp2; return i + 1; }
如果划分极端不平衡,递归的深度将趋近于n,而不是平衡时的log n,这就不仅仅是速度快慢的问题了。栈的大小是很有限的,每次递归调用都会耗费一定的栈空间(函数的参数越多,每次递归耗费的空间也越多),像我们最开始实现的那个QuickSort(),排序40000个元素的有序数组都会导致堆栈溢出。
实用版的快速排序
把前面实现的EPartition()和TPartition()合起来,就是一个比较实用的Partition()函数了,我们先叫它ETPartition()。
public static int ETPartition(int[] s, int p, int r) { int m = p + (r - p) / 2; // 数组中间的元素的下标 // 为使s[r]成为s[p]、s[r]、s[m]的中数,对这三个数排序 if (s[p] > s[r]) { int temp1 = s[p]; s[p] = s[r]; s[r] = temp1; } if (s[r] > s[m]) { int temp1 = s[r]; s[r] = s[m]; s[m] = temp1; } if (s[p] > s[r]) { int temp1 = s[p]; s[p] = s[r]; s[r] = temp1; } int x = s[r]; int i = p; int j = r - 1; // 每次迭代都确保 s[p..i-1] 中的每个元素都小于等于x,s[j+1..r] 中的每个元素都大于等于x while (i <= j) { while (i <= r - 1 && s[i] < x) i++; while (j >= p && x < s[j]) j--; if (i <= j) { int temp = s[i]; s[i] = s[j]; s[j] = temp; i++; j--; } } // 交换s[r] 和 s[j+1] s[r] = s[j + 1]; s[j + 1] = x; return j + 1; }
现在,我们的快速排序在任何极端的条件下都不会忽快忽慢的了。接下我们要使用一些小技巧,进一步榨一些油水出来。
提升性能小技巧1——使用移位操作
由于计算机做加减和移位操作比较快,而乘除操作相对较慢,所以遇到n/2这样的情况,可以用右移操作 n>>1 来代替它。n>>1等价于n/2,n>>2等价于n/4……n<<1等价于n*2,n<<2等价于n*4……
在若干年前,一个程序员要是不知道移位操作会被鄙视的,不过在CPU越来越快的今天,即使重复100万次也只能节省几毫秒而已。而且,.Net运行时在遇到n/2这种情况会自动把它优化成右移运算,所以写n/2和n>>1的效果是一样的。不管怎么说,.Net Framework源代码的QuickSort()确实使用了移位操作,也许有这么几种可能:1)他是个老资格程序员;2)有便宜不占非好汉;3)还是不要依赖优化的好;4)这么写才显得专业;5)不这么写心里不踏实;6)不这么写怕被人笑话……谁知道。
提升性能小技巧2——使用循环代替递归
看一下QuickSort()的第11行。
static void ETQuickSort(int[] s, int p, int r) { // 判断是否能够直接解决 if (p < r) // 需要进一步分解 { // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x int q = ETPartition(s, p, r); // 递归解决 ETQuickSort(s, p, q - 1); // 排序A ETQuickSort(s, q + 1, r); // 排序C } }
在执行了“ETQuickSort(s, q + 1, r); // 排序C”之后、函数返回之前,没有任何与参数p和r有关的操作,也就是说,它是一个天生的尾递归。所以我们很容易就能用一个循环代替它。只要将第4行的if换成while,把p赋值为q+1,这样循环回来就相当于调用了“F1QuickSort(s, q + 1, r); // 排序C”。
static void F1QuickSort(int[] s, int p, int r) { // 判断是否能够直接解决 while (p < r) // 需要进一步分解 { // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x int q = ETPartition(s, p, r); // 递归解决 F1QuickSort(s, p, q - 1); // 排序A p = q + 1; //F1QuickSort(s, q + 1, r); // 排序C } }
如果ETPartition()的划分是平衡的,或者C总是元素较多的那个子数组,F1QuickSort()确实可以提高一点性能。但是如果C的元素比较少呢?或者极端一点,如果C总是空数组呢?这时还是不断地递归调用“F1QuickSort(s, p, q - 1); // 排序A”,我们的好意全都白费了。解决这个问题的方法是,先判断A和C哪个元素多,再决定用循环替代那个递归调用。
static void F2QuickSort(int[] s, int p, int r) { while (p < r) // 需要进一步分解 { // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x int q = ETPartition(s, p, r); // 递归解决 if (q - p <= r - q) // A 的元素比较少 { F2QuickSort(s, p, q - 1); // 排序A p = q + 1; } else // C 的元素比较少 { F2QuickSort(s, q + 1, r); // 排序C r = q - 1; } } }
还有一个小问题,当A或C的元素个数小于2的时候,我们也还是会递归调用它,在调用之后才在判断如果“p<r”就退出。如果在调用之前就判断一下,就可以把递归深度再减少1了。
static void FQuickSort(int[] s, int p, int r) { while (p < r) // 需要进一步分解 { // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x int q = ETPartition(s, p, r); // 递归解决 if (q - p <= r - q) // A 的元素比较少 { if(p < q-1) FQuickSort(s, p, q - 1); // 排序A p = q + 1; } else // C 的元素比较少 { if(q+1 < r) FQuickSort(s, q + 1, r); // 排序C r = q - 1; } } }
但是这样又带来了一个新问题:新加的if判断虽然减少了最后的那层递归调用,但是代价是在前面的那些调用之前都增加了一次判断,那我们到底是赔了还是赚了?其实这并不是问题,因为既然可以确保每次递归调用FQuickSort()的时候输入的元素个数一定大于1个,我们可以把FQuickSort()里面while循环改成先do再while循环,另外ETPartition()里面的while循环也可以改成do while的形式。
static void FQuickSort(int[] s, int p, int r) { do { // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x int q = FPartition(s, p, r); // 递归解决 if (q - p <= r - q) // A 的元素比较少 { if(p < q-1) FQuickSort(s, p, q - 1); // 排序A p = q + 1; } else // C 的元素比较少 { if(q+1 < r) FQuickSort(s, q + 1, r); // 排序C r = q - 1; } } while (p < r); // 需要进一步分解 } public static int FPartition(int[] s, int p, int r) { int m = p + (r - p) / 2; // 数组中间的元素的下标 // 为使s[r]成为s[p]、s[r]、s[m]的中数,对这三个数排序 if (s[p] > s[r]) { int temp1 = s[p]; s[p] = s[r]; s[r] = temp1; } if (s[r] > s[m]) { int temp1 = s[r]; s[r] = s[m]; s[m] = temp1; } if (s[p] > s[r]) { int temp1 = s[p]; s[p] = s[r]; s[r] = temp1; } int x = s[r]; int i = p; int j = r - 1; // 每次迭代都确保 s[p..i-1] 中的每个元素都小于等于x,s[j+1..r] 中的每个元素都大于等于x do { while (i <= r - 1 && s[i] < x) i++; while (j >= p && x < s[j]) j--; if (i <= j) { int temp = s[i]; s[i] = s[j]; s[j] = temp; i++; j--; } } while (i <= j); // 交换s[r] 和 s[j+1] s[r] = s[j + 1]; s[j + 1] = x; return j + 1; }
但是如果用户最开始输入的数组就是个空数组或是只有一个元素呢?看样子我们只能再增加一个方便用户调用的Sort()函数了。
static void Sort(int[] s) { if (s != null && s.Length > 1) FQuickSort(s, 0, s.Length - 1); }
也许是因为现在的CPU真是太快了,在我的机器上排序10万个元素的数组,用了上面的技巧也只节省了几毫秒而已。
.Net Framework 源代码中的QuickSort()
经过一番辛苦的折腾之后,我们的FQuickSort()已经与.Net Framework 源代码中的 QuickSort() 十分相像了,我把源码帖子下面供您参考。需要注意的是,微软的QuickSort()是取数组中间那个元素作为x,而不像我们是取数组的最后一个元素作为x。代码Copy自“allsource\dd\ndp\clr\src\BCL\System\Collections\Generic\ArraySortHelper.cs”,是Array.Sort<T>()所调用的众多QuickSort重载的一个(其它的也都大同小异)。
internal static void QuickSort(T[] keys, int left, int right, IComparer<T> comparer) { do { int i = left; int j = right; // pre-sort the low, middle (pivot), and high values in place. // this improves performance in the face of already sorted data, or // data that is made up of multiple sorted runs appended together. int middle = i + ((j - i) >> 1); SwapIfGreaterWithItems(keys, comparer, i, middle); // swap the low with the mid point SwapIfGreaterWithItems(keys, comparer, i, j); // swap the low with the high SwapIfGreaterWithItems(keys, comparer, middle, j); // swap the middle with the high T x = keys[middle]; do { while (comparer.Compare(keys[i], x) < 0) i++; while (comparer.Compare(x, keys[j]) < 0) j--; BCLDebug.Assert(i >= left && j <= right, "(i>=left && j<=right) Sort failed - Is your IComparer bogus?"); if (i > j) break; if (i < j) { T key = keys[i]; keys[i] = keys[j]; keys[j] = key; } i++; j--; } while (i <= j); if (j - left <= right - i) { if (left < j) QuickSort(keys, left, j, comparer); left = i; } else { if (i < right) QuickSort(keys, i, right, comparer); right = j; } } while (left < right); } private static void SwapIfGreaterWithItems(T[] keys, IComparer<T> comparer, int a, int b) { if (a != b) { if (comparer.Compare(keys[a], keys[b]) > 0) { T key = keys[a]; keys[a] = keys[b]; keys[b] = key; } } }
小贴士 用谷歌的代码搜索功能搜索QuickSort,可以找到许多基于各种语言的五花八门的实现代码,其中有一个Ruby的实现真是简洁得不得了。(注:鉴于目前谷哥已由留校察看变成了劝退,虽然学校还念着旧情,允许我们跟他隔墙聊几句,不过在校长眼中,他早就成了不法商贩,早晚会依法让他脸不见血、身不见伤地完蛋,所以以上链接不保证长期稳定、有效,必要时请自行穿越)
推荐阅读
数组排序方法的性能比较(2):Array.Sort<T>实现分析
尾递归与Continuation
浅谈尾递归的优化方式