快速排序是一种原地排序算法,其最坏的运行时间为n2,期望的运行时间为nlgn,且隐含的常数因子很小。所以快速排序通常是用于排序最佳的实用选择。7.3节介绍了快速排序的一个随机化变形,这一版本的平均运行时间较好,也没有什么特殊的输入会导致最坏运行状态。
7.1 快速排序的描述
与合并排序一样,快速排序也是基于分治模式的。下面是对一个典型子数组A[p...r]排序的分治过程的三个步骤。
分解:数组A[p...r]被划分成两个子数组A[p...q-1]和A[q+1,r],使得A[p...q-1]中的每个元素都小于等于A[q],而A[q]小于等于A[q+1...r]中的元素,下标q也在这个划分过程中进行计算。
解决:通过递归调用快速排序,对子数组A[p...q-1]和A[q+1...r]排序
合并:不需要合并
下面的过程实现快速排序:
QUICKSORT(A,p,r)
1 if p<r
2 then q<-- PARTITION(A,p,r)
3 QUICKSORT(A, p, q-1)
4 QUICKSORT(A, q+1, r)
快速排序算法的关键PARTITION过程,它对子数组A[p...r]进行就地重排:
PARTITION(A, p, r)
1 x <-- A[r]
2 i <-- p-1
3 for j<-- p to r-1
4 do if A[j] <= x
5 then i <-- i+1
6 exchange A[j] <--> A[i]
7 exchange A[i+1] <--> A[r]
8 return i+1
上述过程中, 变量i,j将 A[p...r-1]分成了三个区段:
(1) 对p<=k<=i有 A[k]<= x;
(2) 对i<k<j有A[k]>=x;
(3) j<= k <= r-1为尚未比较的未知段。
这三个区段的性质正是上述过程中的循环不变式。
7.2快速排序的性能
快速排序的运行时间与划分是否对称有关,而二者由于与选择哪个元素来进行划分有关。如果划分是对称的,那么本算法从渐进意义上与合并算法一样快;如果划分是不对称的,那么就和插入算法一样慢。
最坏情况划分
最坏情况就是划分过程产生的两个区域分别包含0和n-1个元素,假设算法的每一次递归调用都产生了这种不对称的划分,那么运行时间可递归地表示为:
T(n) = T(n-1) + T(0) + Θ(n) = T(n-1) + Θ(n) = Θ(n2)
最佳情况划分
当划分产生的两个区域分别包含 n/2个元素时,产生最佳划分。此时有
T(n) = T(n/2) + Θ(n) = Θ(nlgn)
平衡的划分
快速排序的平均性能与最佳情况很接近,而不是接近最差情况。
假设划分过程总是产生9:1的划分,乍一看这种划分很不平衡,这时快速排序运行时间可递归表示为:
T(n) = T(9n/10) + T(n/10) + Θ(n)
通过递归树可得 T(n) = Θ(nlgn)。
实际上任何一种按常数比例进行划分都会产生深度为Θ(lgn)的递归树,其中每一层的代价为O(n),其总的运行时间都是O(nlgn)。
7.3快速排序的随机化版本
为了避免特定输入导致快速排序产生最差的划分,可以对算法加入随机化的成分,以便获得较好的平均性能。很多人认为快速排序的随机化版本是对足够大的输入的理想化选择。对快速排序来说,没有必要像5.3节介绍的那样对输入进行随机化排列,这里采取一种不同的,称为随机取样的随机化技术。在这种方法中,不是始终采用A[r]作为主元,而是从子数组A[p...r]中随机选择一个元素。
与原算法相比,新的划分过程如下:
RANDOMIZED-PARTITION(A, p, r)
1 i <-- RANDOM(p,r)
2 exchange A[r] <--> A[i]
3 return PARTITION(A, p, r)
7.4快速排序分析
7.2从直觉上对快速排序的最坏情况、它为何运行得较快作了一些讨论,本节要对快速排序性能进行严格的分析。先进行最坏情况分析,这对随机化版本和非随机化版本都一样,在分析随机化版本的平均情况性能。
最坏情况
利用代换法(4.1节)可以证明快速排序最差的运行时间为O(n2):
T(n) = max0<=q<=n-1{T(q)+T(n-q-1) }+ Θ(n)
猜测 T(n) <= cn2,c为某个常数。
T(n) <= max0<=q<=n-1{cq2+c(n-q-1)2}+ Θ(n) = c*(q2+(n-q-1)2) + Θ(n) <= cn2-c(2n-1) + Θ(n)
只要选择足够大的才c,使得 c(2n-1)可以支配Θ(n), T(n) <= cn2就能成立。
同样也可以证明 T(n) = Ω(n2);
随机化的期望性能
快速排序的时间主要是花在PARTITION上的时间决定的,每当PARTITON被调用时,就要选出一个主元素,后续的递归不会再涉及该主元素,所以整个排序过程中PARTITON最多被调用n次,调用一次PARTITION的时间为O(1)再加上for循环中元素比较的次数,如果我们能够知道总的元素比较次数,就能够知道快速排序的运行时间了。
假设元素比较的次数为X,那么 算法的运行时间为 O(n+X)。
为了得出元素的比较次数,我们要分析两个元素何时进行比较,何时不进行比较。为此对元素进行重新命名z1,z2,...,zn,zi为数组中第i小的元素,而且还定义Zij = {zi,...,zj}为zi和zj之间元素的集合。
在PARTITION的过程中,每个元素与主元素进行比较,之后不会再和主元素比较,这说明两个元素最多比较一次。这个特性使我们可以用指示器随机变量来分析问题:
Xij = I{zi和zj进行比较}
那么算法何时会比较zi和zj,何时两个元素不会比较呢?要比较的话,必须有其中一个元素在某个递归层次被选为主元素并且此时两个元素都还在这个划分中。某个包含Zij的划分中有元素 zi<x<zj被选为主元素,那么zi和zj就再也没有机会比较了。所以zi和zj的比较取决于Zij中哪个元素首先被选为主元素(Zij之外的元素何时被选为主元素不影响)。
P{Xij=1} = P{zi和zj进行比较} = P{zi或zj在Zij中首先被选为主元素} = P{zi在Zij中首先被选为主元素} + P{zj在Zij中首选被选为主元素} = 2/(j-i+1)。
E[X] = ∑i=1~n-1∑j=i+1~n 2/(j-i+1)
= ∑i=1~n-1∑k=1~n-i 2/(k+1)
<=∑i=1~n-1∑k=1~n 2/k = O(nlgn)
练习:
7..4-5对插入排序来说,当输入已经“几乎”排好序时,运行时间是很快的,在实践中可以充分利用这一特点来改善快速排序的运行时间,当在一个长度小于k的子数组上来调用快速排序时,让它不做任何排序就返回。当顶层的快速排序调用返回后,对整个数组进行一次插入排序。证明这一算法的期望运行时间为O(nk+nlg(n/k))。实践中如何选择k。
分析:(1)该算法的运行时间由两部分组成:一是快速排序的时间,而是插入排序的时间。 前者和标准快速排序的时间的唯一区别来说就是递归的深度相对较低,参考前面分析算法执行时间的递归树,标准算法的递归树的高度为O(lgn),该算法的递归树高度为O(lgn-lgk)。 因此运行时间为O(nlgn/k)。 再看插入排序,假设快速排序最终将数组重新划分成了A1,A2,...,Am m个区块,每个区块的元素个数为k1,k2,...,km, 有ki<=k, ∑ki = n。于是算法的复杂度为O(k12+k22+...+km2) = O(k1k+k2k+...+kkm) = O(kn)。
(2)我认为实践中还要找到使插入排序比快速排序块的k的临界点。
思考题
7.1 Hoare划分
本章给出的PARTITION算法并不是其最初的版本。西面给出Hoare设计的划分算法。
HPARE-PARTITION(A, p, r)
1 x <-- A[p]
2 i <-- p-1
3 j <-- r+1
4 while TRUE
5 do repeat j<-- j-1
6 until A[j] <= x
7 do repeat i<--i+1
8 untile A[i]>=x
9 if i<j
10 then A[i]<-->A[j]
11 else return j
HOARE划分与PARTITION不同,不是将数组划分成围绕主元的两个部分,而是主元也放入了其中一个部分,并保证前一部分的所有元素小于或等于后一部分的所有元素。如果使用HOAR划分,快速排序的主体过程也要相应修改。
证明上述过程的正确性。
(1)先证明在整个过程中不会访问到A[p...r]以外的位置,假设P<=r:第一轮循环中第6行不会越界,因为至少还有A[p]会使内循环停止;同样的原因同样第8行也不会越界。在之后的循环中,由于有i<j,且A[i]<=x,A[j]>=x。第6行和第8行也不会越界。
(2)再证明循环不变式对j<k<=r有A[k]>=x。为了辅助,证明另一有性质:a、在循环开始之前有,对j=<k<=r有A[k]>=x。在第一次循环开始前,性质a成立。循环过程中由于5、6行使得对k>j有A[k]>=x,如果这次循环没有结束的话,那么亦有A[j]>=x,那么每次循环前a都成立。就已证明不变式在循环前成立。 在每次循环开始前性质a成立,那么当该次循环结束时5、6行足以保证不变式成立。
同样可以证明另一不变式:对p=<k<i有A[k]<=x。 当循环结束时,i>=j,在结合前面的这些性质。可知A[p...j]中的元素小于或等于A[j+1...r]中的元素。
7.4快速排序中的堆栈深度:消除尾递归
QUIKSORT算法包含两个对其自身的递归调用,其中第二个递归并不是必须的,可以考虑用迭代来代替它。这种技术称作“尾递归”。
QUIKSORT' (A, p, r)
1 while (p<r)
2 do q<-- PARTITON(A,p,r)
3 QUIKSORT(A,p,q-1)
4 p<-- q+1
堆栈深度分析:上述过程省去了第二个递归导致的栈深度,对第一次递归导致的堆栈深度并没有什么影响。如果第一次递归的总是栈深度比第二次大,那么这个改动虽然将一个递归换成了迭代而提升了一点速度,但并没有起到减小栈深度的效果。在极端情况下,如果总是有q = r,那么栈深度为Θ(n)。 为了改进,可以在第一次递归前做一个判断,对元素少的段进行递归,对大的段用“尾递归”消除。
7.5“三数取中”划分
一种改进划分的方法,在选取主元素时,在三个随机元素中选择大小居中的一个。
7.6对区间的模糊排序
给定n个形如[ai,bi]的闭区间,其中ai<=bi。算法的目标是对这些区间进行模糊排序,产生一个排列(i1,i2,...,in),使得存在一个cj∈[aij,bij],满足c1<=c2<=...<=cn。设计一个算法进行排序,算法应该具有算法的一般结构,快速排序左部端点(即个ai),也要能充分利用重叠区域改善性能。在一般情况下,算法的期望的运行时间是Θ(nlgn),但当所有的区间都重叠时运行时间为Θ(n)。
设计算法如下:在快速排序的PARTITION过程中,分别记录两个划分的所有区间是否存在共同的重叠区间。如果存在这个划分就不需要再递归了。