白话算法(3) 哥就是这么自信

据有关砖家说每天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排序。
合并:只要将子数组处理成有序的,整个数组就是有序的,不需要合并。


01static void QuickSort(int[] s, int p, int r)
02{
03    // 判断是否能够直接解决
04    if(p < r) // 需要进一步分解
05    {
06        // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x
07        int q = Partition(s, p, r);
08         
09        // 递归解决
10        QuickSort(s, p, q-1); // 排序A
11        QuickSort(s, q+1, r); // 排序C
12         
13        // 合并:A和C处理成有序的之后,整个数组就是有序的,不需要合并
14    }
15    // p >= r时, s[p..r]是空数组或只有一个元素,本身就是有序的,直接返回
16}


这个用来扒堆的Partition()函数看上去有些复杂。

01static int Partition(int[] s, int p, int r)
02{
03    int x = s[r];
04    int i = p-1;
05    for(int j = p; j<=r-1; j++)
06    {
07        if(s[j] <= x)
08        {
09            i++;
10            int temp = s[i];
11            s[i] = s[j];
12            s[j] = temp;
13        }
14    }
15    int temp2 = s[i+1];
16    s[i+1] = s[r];
17    s[r] = temp2;
18    return i+1;
19}


  第一个问题是,如何确定选哪个元素做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 分别从数组的两头向中间靠拢。

01static int EPartition(int[] s, int p, int r)
02{
03    int x = s[r];
04    int i = p;
05    int j = r - 1;
06 
07    // 每次迭代都确保 s[p..i-1] 中的每个元素都小于等于x,s[j+1..r] 中的每个元素都大于等于x
08    while (i <= j)
09    {
10        while (i<=r-1 && s[i] < x) i++;
11        while (j>=p && x < s[j]) j--;
12 
13        if (i <= j)
14        {
15            int temp = s[i];
16            s[i] = s[j];
17            s[j] = temp;
18            i++;
19            j--;
20        }
21         
22    }
23 
24    // 交换s[r] 和 s[j+1]
25    s[r] = s[j + 1];
26    s[j + 1] = x;
27    return j + 1;
28}


  我们仍然使用增量法生成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行)。

01static Random random = new Random();
02static int RPartition(int[] s, int p, int r)
03{
04    int xi = random.Next(p, r+1); // 取 [p,r+1) 之间的随机数
05    int temp1 = s[xi];
06    s[r] = s[xi];
07    s[xi] = temp1;
08 
09    int x = s[r];
10    int i = p - 1;
11    for (int j = p; j <= r - 1; j++)
12    {
13        if (s[j] <= x)
14        {
15            i++;
16            int temp = s[i];
17            s[i] = s[j];
18            s[j] = temp;
19        }
20    }
21    int temp2 = s[i + 1];
22    s[i + 1] = s[r];
23    s[r] = temp2;
24    return i + 1;
25}


  不过人们还是不满足,想着“有什么办法能把数组划分得更加平衡——也就是让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]这三个数拍个序。

01static int TPartition(int[] s, int p, int r)
02{
03    int m = p + (r - p) / 2; // 数组中间的元素的下标
04    // 为使s[r]成为s[p]、s[r]、s[m]的中数,对这三个数排序
05    if (s[p] > s[r])
06    {
07        int temp1 = s[p];
08        s[p] = s[r];
09        s[r] = temp1;
10    }
11    if (s[r] > s[m])
12    {
13        int temp1 = s[r];
14        s[r] = s[m];
15        s[m] = temp1;
16    }
17    if (s[p] > s[r])
18    {
19        int temp1 = s[p];
20        s[p] = s[r];
21        s[r] = temp1;
22    }
23   
24    int x = s[r];
25    int i = p - 1;
26    for (int j = p; j <= r - 1; j++)
27    {
28        if (s[j] <= x)
29        {
30            i++;
31            int temp = s[i];
32            s[i] = s[j];
33            s[j] = temp;
34        }
35    }
36    int temp2 = s[i + 1];
37    s[i + 1] = s[r];
38    s[r] = temp2;
39    return i + 1;
40}


  如果划分极端不平衡,递归的深度将趋近于n,而不是平衡时的log n,这就不仅仅是速度快慢的问题了。栈的大小是很有限的,每次递归调用都会耗费一定的栈空间(函数的参数越多,每次递归耗费的空间也越多),像我们最开始实现的那个QuickSort(),排序40000个元素的有序数组都会导致堆栈溢出。

实用版的快速排序

  把前面实现的EPartition()和TPartition()合起来,就是一个比较实用的Partition()函数了,我们先叫它ETPartition()。

01public static int ETPartition(int[] s, int p, int r)
02{
03    int m = p + (r - p) / 2; // 数组中间的元素的下标
04    // 为使s[r]成为s[p]、s[r]、s[m]的中数,对这三个数排序
05    if (s[p] > s[r])
06    {
07        int temp1 = s[p];
08        s[p] = s[r];
09        s[r] = temp1;
10    }
11    if (s[r] > s[m])
12    {
13        int temp1 = s[r];
14        s[r] = s[m];
15        s[m] = temp1;
16    }
17    if (s[p] > s[r])
18    {
19        int temp1 = s[p];
20        s[p] = s[r];
21        s[r] = temp1;
22    }
23 
24    int x = s[r];
25    int i = p;
26    int j = r - 1;
27 
28    // 每次迭代都确保 s[p..i-1] 中的每个元素都小于等于x,s[j+1..r] 中的每个元素都大于等于x
29    while (i <= j)
30    {
31        while (i <= r - 1 && s[i] < x) i++;
32        while (j >= p && x < s[j]) j--;
33 
34        if (i <= j)
35        {
36            int temp = s[i];
37            s[i] = s[j];
38            s[j] = temp;
39            i++;
40            j--;
41        }
42 
43    }
44 
45    // 交换s[r] 和 s[j+1]
46    s[r] = s[j + 1];
47    s[j + 1] = x;
48    return j + 1;
49}


  现在,我们的快速排序在任何极端的条件下都不会忽快忽慢的了。接下我们要使用一些小技巧,进一步榨一些油水出来。

提升性能小技巧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行。

01static void ETQuickSort(int[] s, int p, int r)
02{
03    // 判断是否能够直接解决
04    if (p < r) // 需要进一步分解
05    {
06        // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x
07        int q = ETPartition(s, p, r);
08 
09        // 递归解决
10        ETQuickSort(s, p, q - 1); // 排序A
11        ETQuickSort(s, q + 1, r); // 排序C
12    }
13}


  在执行了“ETQuickSort(s, q + 1, r); // 排序C”之后、函数返回之前,没有任何与参数p和r有关的操作,也就是说,它是一个天生的尾递归。所以我们很容易就能用一个循环代替它。只要将第4行的if换成while,把p赋值为q+1,这样循环回来就相当于调用了“F1QuickSort(s, q + 1, r); // 排序C”。

01static void F1QuickSort(int[] s, int p, int r)
02{
03    // 判断是否能够直接解决
04    while (p < r) // 需要进一步分解
05    {
06        // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x
07        int q = ETPartition(s, p, r);
08 
09        // 递归解决
10        F1QuickSort(s, p, q - 1); // 排序A
11        p = q + 1;
12        //F1QuickSort(s, q + 1, r); // 排序C
13    }
14}


  如果ETPartition()的划分是平衡的,或者C总是元素较多的那个子数组,F1QuickSort()确实可以提高一点性能。但是如果C的元素比较少呢?或者极端一点,如果C总是空数组呢?这时还是不断地递归调用“F1QuickSort(s, p, q - 1); // 排序A”,我们的好意全都白费了。解决这个问题的方法是,先判断A和C哪个元素多,再决定用循环替代那个递归调用。

01static void F2QuickSort(int[] s, int p, int r)
02{
03    while (p < r) // 需要进一步分解
04    {
05        // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x
06        int q = ETPartition(s, p, r);
07 
08        // 递归解决
09        if (q - p <= r - q) // A 的元素比较少
10        {
11            F2QuickSort(s, p, q - 1); // 排序A
12            p = q + 1;
13        }
14        else // C 的元素比较少
15        {
16            F2QuickSort(s, q + 1, r); // 排序C
17            r = q - 1;
18        }
19    }
20}


  还有一个小问题,当A或C的元素个数小于2的时候,我们也还是会递归调用它,在调用之后才在判断如果“p<r”就退出。如果在调用之前就判断一下,就可以把递归深度再减少1了。

01static void FQuickSort(int[] s, int p, int r)
02{
03    while (p < r) // 需要进一步分解
04    {
05        // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x
06        int q = ETPartition(s, p, r);
07 
08        // 递归解决
09        if (q - p <= r - q) // A 的元素比较少
10        {
11            if(p < q-1) FQuickSort(s, p, q - 1); // 排序A
12            p = q + 1;
13        }
14        else // C 的元素比较少
15        {
16            if(q+1 < r) FQuickSort(s, q + 1, r); // 排序C
17            r = q - 1;
18        }
19    }
20}


  但是这样又带来了一个新问题:新加的if判断虽然减少了最后的那层递归调用,但是代价是在前面的那些调用之前都增加了一次判断,那我们到底是赔了还是赚了?其实这并不是问题,因为既然可以确保每次递归调用FQuickSort()的时候输入的元素个数一定大于1个,我们可以把FQuickSort()里面while循环改成先do再while循环,另外ETPartition()里面的while循环也可以改成do while的形式。

01static void FQuickSort(int[] s, int p, int r)
02{
03    do
04    {
05        // 分解:将数组s[p..r]分解为 { A, x, C }的形式,A=s[p..q-1], x=s[q], C=s[q+1..r]。并且A中的所有元素都小于等于x,C中的所有元素都大于x
06        int q = FPartition(s, p, r);
07 
08        // 递归解决
09        if (q - p <= r - q) // A 的元素比较少
10        {
11            if(p < q-1) FQuickSort(s, p, q - 1); // 排序A
12            p = q + 1;
13        }
14        else // C 的元素比较少
15        {
16            if(q+1 < r) FQuickSort(s, q + 1, r); // 排序C
17            r = q - 1;
18        }
19    while (p < r); // 需要进一步分解
20}
21 
22public static int FPartition(int[] s, int p, int r)
23{
24    int m = p + (r - p) / 2; // 数组中间的元素的下标
25    // 为使s[r]成为s[p]、s[r]、s[m]的中数,对这三个数排序
26    if (s[p] > s[r])
27    {
28        int temp1 = s[p];
29        s[p] = s[r];
30        s[r] = temp1;
31    }
32    if (s[r] > s[m])
33    {
34        int temp1 = s[r];
35        s[r] = s[m];
36        s[m] = temp1;
37    }
38    if (s[p] > s[r])
39    {
40        int temp1 = s[p];
41        s[p] = s[r];
42        s[r] = temp1;
43    }
44 
45    int x = s[r];
46    int i = p;
47    int j = r - 1;
48 
49    // 每次迭代都确保 s[p..i-1] 中的每个元素都小于等于x,s[j+1..r] 中的每个元素都大于等于x
50    do
51    {
52        while (i <= r - 1 && s[i] < x) i++;
53        while (j >= p && x < s[j]) j--;
54 
55        if (i <= j)
56        {
57            int temp = s[i];
58            s[i] = s[j];
59            s[j] = temp;
60            i++;
61            j--;
62        }
63 
64    while (i <= j);
65 
66    // 交换s[r] 和 s[j+1]
67    s[r] = s[j + 1];
68    s[j + 1] = x;
69    return j + 1;
70}


  但是如果用户最开始输入的数组就是个空数组或是只有一个元素呢?看样子我们只能再增加一个方便用户调用的Sort()函数了。

1static void Sort(int[] s)
2{
3    if (s != null && s.Length > 1)
4        FQuickSort(s, 0, s.Length - 1);
5}


  也许是因为现在的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重载的一个(其它的也都大同小异)。

01internal static void QuickSort(T[] keys, int left, int right, IComparer<T> comparer)
02{
03    do
04    {
05        int i = left;
06        int j = right;
07 
08        // pre-sort the low, middle (pivot), and high values in place.
09        // this improves performance in the face of already sorted data, or
10        // data that is made up of multiple sorted runs appended together.
11        int middle = i + ((j - i) >> 1);
12        SwapIfGreaterWithItems(keys, comparer, i, middle);  // swap the low with the mid point
13        SwapIfGreaterWithItems(keys, comparer, i, j);   // swap the low with the high
14        SwapIfGreaterWithItems(keys, comparer, middle, j); // swap the middle with the high
15 
16        T x = keys[middle];
17        do
18        {
19            while (comparer.Compare(keys[i], x) < 0) i++;
20            while (comparer.Compare(x, keys[j]) < 0) j--;
21            BCLDebug.Assert(i >= left && j <= right, "(i>=left && j<=right)  Sort failed - Is your IComparer bogus?");
22            if (i > j) break;
23            if (i < j)
24            {
25                T key = keys[i];
26                keys[i] = keys[j];
27                keys[j] = key;
28            }
29            i++;
30            j--;
31        while (i <= j);
32        if (j - left <= right - i)
33        {
34            if (left < j) QuickSort(keys, left, j, comparer);
35            left = i;
36        }
37        else
38        {
39            if (i < right) QuickSort(keys, i, right, comparer);
40            right = j;
41        }
42    while (left < right);
43}
44 
45private static void SwapIfGreaterWithItems(T[] keys, IComparer<T> comparer, int a, int b)
46{
47    if (a != b)
48    {
49        if (comparer.Compare(keys[a], keys[b]) > 0)
50        {
51            T key = keys[a];
52            keys[a] = keys[b];
53            keys[b] = key;
54        }
55    }
56}


小贴士 用谷歌的代码搜索功能搜索QuickSort,可以找到许多基于各种语言的五花八门的实现代码,其中有一个Ruby的实现真是简洁得不得了。(注:鉴于目前谷哥已由留校察看变成了劝退,虽然学校还念着旧情,允许我们跟他隔墙聊几句,不过在校长眼中,他早就成了不法商贩,早晚会依法让他脸不见血、身不见伤地完蛋,所以以上链接不保证长期稳定、有效,必要时请自行穿越)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值