快速排序及精确性能统计
快速排序及精确性能统计
Table of Contents
1 快速排序
1.1 快速排序算法
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。 步骤为:
- 从数列中挑出一个元素,称为 "基准"(pivot),
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
递归的最底部情形,是数列的大小是零或一,也就是永远都已经被排序好了。虽然一直递归下去,但是这个算法总会退出,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
上面简单版本的缺点是,它需要Ω(n)的额外存储空间,也就跟归并排序一样不好。额外需要的存储器空间配置,在实际上的实现,也会极度影响速度和高速缓存的性能。有一个比较复杂使用原地(in-place)分区算法的版本,且在好的基准选择上,平均可以达到O(log n)空间的使用复杂度。 以上内容来自维基百科的快速排序。
1.2 实现
我们不讨论非原地分区的版本,先讨论三种不同的实现方式。他们之间的实质差异并不大,主要集中在基准的选择上。
1.2.1 单向划分
这个版本是单向划分,选择串的最后一项作为基准,使用两个变量 i,j 保存索引,从左至右扫描,j中保存着扫描进度一旦发现有大于基准的项立即与i后面一项交换。
function qsort(arr,l,u){ if(l>=u) return; var i=l-1,j=l; for(;j<u;j++){ if(arr[j]<=arr[u]) swap(arr,j,++i); } swap(arr,j,++i); qsort(arr,l,i-1); qsort(arr,i+1,u); }
中规中矩的实现,其过程分析请参见JULY的快速排序算法。 博主JULY已经分析的比较透彻图文并茂,此处不必再赘述。
1.2.2 双向划分
这里对上面的作一个改进,采用双向划分来遍历串。到底改进哪方面,我们先看一下代码:
function qsort2(arr,l,u){ if(l>=u) return; var i=l,j=u,key=arr[l]; while(i<j){ while(i<j && arr[j]>key) j--; if(i<j) arr[i++]=arr[j]; while(i<j && arr[i]<key) i++; if(i<j) arr[j--]=arr[i]; } arr[i]=key; qsort2(arr,l,i-1); qsort2(arr,i+1,u); }
这段代码对串进行双向划分,并且减少了第一种实现中的不必要的交换,但是比较次数没有任何变化。
1.2.3 使用随机数划分
使用一个随机数所在位置的项作为基准,下面的代码出自大师Jon Bentley,《编程珠玑》作者,他坦言这是他曾编写过的最漂亮的代码。
function qsort3(arr,l,u){ if(l>=u) return; swap(arr,u,rand(l,u)); var i=l-1,j=l; for(;j<u;j++) if(arr[j]<=arr[u]) swap(arr,++i,j); swap(arr,++i,j); qsort3(arr,l,i-1); qsort3(arr,i+1,u); }
代码中的rand用来获得l与u之间的随机数并且与最后一项交换,其他代码与单向划分方法代码相同。为什么说是最漂亮的代码,作者认为这段代码已经精简到不能再精简并且保持了正确性和健壮性。
其实还有其他方法来提高速度,例如,三值取中,局部使用插入排序等等。因为本文主要集中在如何对快速排序的性能做精确计算,所以这里不会列举出来。如果想要了解所有版本,可以在JULY的博客快速排序算法所有版本的实现。
1.3 性能评估
1.3.1 时间复杂度
从数学上可以分析出快速排序的时间在最好情况下,选取中值作为划分元素,只需nlgn次的比较就可以完成对数组的排序。在最坏情况下,可能需要n2的时间来进行排序。那么在平均情况下需要的比较次数是多少?今天我们不用数学公式去计算,而是使用程序来计算。
1.3.2 简单统计
我们给上面qsort3程序中的循环内部加入一个计数的变量comps用来计算统计程序比较次数。
for(;j<u;j++){ comps++; if(arr[j]<=arr[u]) swap(arr,++i,j); }
如果使用一个值n来运行程序,运行一次获得总共的比较次数,重复运行多次并使用统计的方法就可获得平均情况下qsort3对n个项的输入排序比较次数,这个值是1.4nlgn。
1.3.3 优化实现
如果是本人对比较作统计,进行到这一步也就心满意足了。毕竟已经得到想要的结果,但大师Jon Bentley觉得,如果有充足的时间,就让我们来对代码进行修改,并且努力编写出更短同时更好的程序。
所以接下去要做的事情就是加快这个算法的速度。并且从一步步改进当中加深对程序的理解。从上面的代码中看出来,其实在循环中的步骤是固定的,因此不需要在循环中每一步累加,只需放到循环外部把循环次数加上就可以了。
comps+=u-l; for(;j<u;j++){ if(arr[j]<=arr[u]) swap(arr,++i,j); }
由于我们只需要统计比较次数,程序里面的实际操作的代码可以去除,例如swap调用。所以得到以下代码:
function profile2(arr,l,u){ if(l>=u) return; var m = rand(l,u); comps+=u-l; profile2(arr,l,m-1); profile2(arr,m+1,u); }
这个程序能够满足我们的需求是因为虽然我们没有在第一次调用中交换位置,但是通过随机函数rand可以获得划分基准。并且假设所有的项都是不相等的。目前为止,这个程序的运行时间与n成正比,并且存储空间缩减为递归堆栈大小————与lgn成正比。
虽然在排序的时候,数组下标非常重要,但是在当下场景中显得有点多余。我们把数组下标省略掉,并对统计函数的形式改造。只接受一个数组大小的数值n来做为参数。
function profile3(n){ if(n<=1) return; var m=rand(1,n); comps+=n-1; profile3(m-1); profile3(n-m); }
到这为止,我们还是使用的全局变量做的统计,我们会自然而然的想到直接把这个函数做成一个功能函数直接返回一个结果值,表示算法中的比较次数。下面给出函数:
function profile4(n){ if(n<=1) return 0; var m=rand(1,n); return n-1+profile4(m-1)+profile4(n-m); }
好了,到目前为止,我们碰到的问题是,“当快速排序对n个元素的数组进行一次排序时,需要进行多少次的比较?”现在对这个问题进行引申,“对n个项的串进行排序,需要比较的平均次数是多少?”我们对上面的代码扩展并引出下面的代码:
function profile5(n){ if(n<=1) return 0; var sum=0; for(var i=1;i<=n;i++){ sum+=n-1+profile5(i-1)+profile5(n-i); } return sum/n; }
如果输入串最多只有一个项,那么Quicksort将不会进行比较,对于更大的n值,这段代码将考虑每个划分元素m(从第一个到最后一个元素都是等可能的)并且确定在这个元素的位置上进行划分 的运行开销。然后这段代码将统计这些开销的总合(这样就递归地解决了一个大小为m-1的问题和一个大小为n-m的问题),然后将总和除以n得到平均值并返回这个结果。
再观察上面代码,其实已经重复计算了中间结果。这种情况下,我们使用动态规划来存储中间结果,从而避免重复计算。有关动态规划可以前往动态规划算法 这篇博文作详细了解。这里我们定义一个表t[N+1],其中在t[n]中存储c[n],并且按照升序来计算它的值。用N表示n的最大值:
t[0]=0; for(n=1;n<=N;n++){ sum=0; for(i=1;i<=n;i++) sum += n-1 + t[i-1] + t[n-i]; t[n] = sum/n; }
我们再对程序做进一步的简化,把n-1移到循环外部。
t[0]=0; for(n=1;n<=N;n++){ sum=0; for(i=1;i<=n;i++) sum += t[i-1] + t[n-i]; t[n] = n-1+sum/n; }
我们不难发现,在上面代码中t从i到n之间具有对称性。例如,当n为4时,内部循环计算总和为: t1+t2 + t3+t4 + t4+t3 + t2+t1 因为都是加法,直接取二倍的累加值即可。利用这种对称性,得到如下的代码:
t[0]=0; for(n=1;n<=N;n++){ sum=0; for(i=1;i<=n;i++) sum += 2*t[i]; t[n] = n-1+sum/n; }
仔细观察代码,可以发现两层循环中还有有重复计算。所以把内层循环去掉,可以更加快速。
t[0]=0;sum=0; for(n=1;n<=N;n++){ sum += 2*t[n-1]; t[n] = n-1+sum/n; }
目前程序的运行时间已经是线性的,这里的t数组保存了数组中从元素0到元素N的真实平均值。对于每个从1到N的整数,程序将生成一个表:
N | Sum | t[n] |
0 | 0 | 0 |
1 | 0 | 0 |
2 | 0 | 1 |
3 | 2 | 2.667 |
4 | 7.333 | 4.833 |
5 | 17 | 7.4 |
6 | 31.8 | 10.3 |
7 | 52.4 | 13.486 |
8 | 79.371 | 16.921 |
表中的第一行是用代码中的常量来初始化的。下一行的数值都是通过下列公式来计算的: A3=A2+1 B3=B2+2*C2 C3=A3-1+B3/A3
假设我们不需要记录所有的值,我们只关心给定的一部分数值,那么我们把表t改造成定长的。
sum=0,t=0; for(n=1;n<=N;n++){ sum += 2*t; t = n-1+sum/n; }
终于,这已经是最简单的版本了,它的时间复杂度为线性,空间复杂度仅仅是1!
我们不得不惊叹大师的功力,仅仅用4行代码就可以精确计算出快速排序的平均比较次数,而且是最快速度,最少空间。这个优化过程是《计算机编程艺术》第三卷中给出的,作者Knuth对Hoare的数学表达式进行逐步优化,而Jon Bentley把这个数学过程全部用代码的改进来一步步实现,也就显得更加易于理解。
最后,向大师们致敬。同时还是那句话送给各位,less is more!