在各种排序算法层出不穷的今日,QuickSort即快速排序可谓独立风骚了。当然它不是最快的排序算法,因为它也是基于比较的排序算法,平均运行时间为Θ(nlogn),在最坏的情况下,它的性能也只能和插入排序相当,即Θ(n^2)。下面我们就来揭开它的真面目吧!
本质上看,快速排序是基于分治的思想。说到分治,就是将一个问题大而化小,分而治之。待子问题各个击破之后,在合并起来,成为我们想要的答案。是不是有点模块化的样子呢?当然,这里不同于真正的模块化拼接,我们只需要一种方法去解决所有的子问题,这个也是递归的思想。我们来看看到底快排是怎么体现这些思想的吧。
首先,我们要知道,需要排序的是一个无序的数组。我们先从简单的方面入手。
加入这个数组只有3个数,你可以这个来排序:先从中取一个任意的数x,然后将剩余的两个数按≥x和<x分别放到x的两边,这样是不是轻而易举地就排好了呢?但是这样你可能觉得有点像插入排序了。没错,但是我再增加点难度,从7个数入手试试。假设需要排序的数组为x1,x2,x3,x4,x5,x6,x7,我们先从中找一个数作为参考(学过物理学中的参照物吧),假如我们找到的是x1,然后找出比≤x1的和>x1像下面这样分成两拨:
再具体一点,比如这样:
现在恭喜你,已经完成第一趟了的任务了。我们接着往下做,使用同样的方法处理左右两部分,这个过程就和前面3个数差不多了。左边排完可能是这样x3,x2,x4,右边可能是x6,x5,x7。当然,如果不是这样也没关系,道理都是相同的。最后从整体上看,这个数组已经变成了:x3,x2,x4,x1,x6,x5,x7,
这个数组已经是有序的了。如果用符号不太明显的话,我们不妨用实例来试试。
设数组为4,3,2,1,7,6,5
一趟划分后:3 2 1 4 7 6 5
二趟划分后:1 2 3 4 5 6 7
哈哈,是不是就OK了呢?
上面还只是热身。一个算法应该能够处理各种各样的输入,不能局限于像刚才那样几个有限的数字。接下来我们就要寻找普适的算法流程了,就像给出数列的通项公式一样。
想一想,刚才热身的时候是不是有个地方说的比较模糊呢?对,就是每一趟根据关键元素(成为主元pivot element)划分数组的过程。这里也是QuickSort的灵魂所在。完成它之后,就是递归调用的问题了。这个划分数组的过程,姑且称为partition。
我们还是按照《算法导论》上面例子的顺序来介绍吧,先看一个优化过的partition版本,之后再来分析原始的版本。
我们要把一个数组分成两拨,并且要就地排序,不开辟额外空间,首先想到的就是交换元素了。这样,我们就可以弄两个标号i,j,i用来指示已经放置好的小于主元的元素位置,j用来指示要遍历的元素。如下图:
然后我们就可以开始了。首先i,j的位置都在数组A[p…r]的开头,即i=j=p-1。
之后j开始向右遍历寻找,找到一个元素A[k],如果A[k]≤x,就把A[k]交换到A[i+1]的位置,这时A[i+1]就换到后面去了(小元素在前,大元素在后),再找到一个A[k’]≤x时,同样交换,即换到A[k]的后面,如下图:
最后遍历结束后(j=p→r-1),数组变成下面这样了:
之后,我们再做一个重要的交换A[i+1]↔A[r],把x放中间,得到:
那么,一趟划分就完成了,我们可以通过具体数字再演示一下:假设我们要排序的数组为2,8,7,1,3,5,6,4
具体划分过程如下:
我们把整个流程图画在下面:
现在我们可以开始写伪代码了。
PARTITION(A,p,r) x=A[r] i=p-1 for j=p→r-1 if A[j]≤x i=i+1 swap(A[i],A[j]) swap(A[i+1],A[r]) return i+1
大部分工作已经完成了,可以开始写c的实现代码了。
int partition(int A[],int p,int r) { int x=A[r]; int i=p-1; int j; for(j=p;j<r;j++) { if(A[j]<=x) { i++; swap(&A[i],&A[j]); } } swap(&A[i+1],&A[r]); return i+1; }其中交换子程序swap( int* a, int* b)如下:
void swap(int* a,int* b) { int temp; temp=*a; *a=*b; *b=temp; }
到此为止,这一版本的partition也算完成了。
===============================================华丽的分割线===================================================================
下面我们再来看看原始版本的partition,《算法导论(中文第2版)》的P160思考题提到这个版本是A.R.Hoare最初设计的,并给出了下面这个伪代码(个人习惯将赋值符号←改为=,exchange改为swap):
HOARE-PARTITION(A,p,r) x=A[p] i=p-1 j=r+1 while TRUE do repeat j=j-1 until A[j]<=x repeat i=i+1 until A[i]>=x if i<j then swap(A[i],A[j]) else return j
下面我们就给它来个简单的剖析吧!
思路也很清晰。基本上就是两个指示器分别从左向右和从右向左进行遍历,左边找到大元素,右边找到小元素(针对于主元x),然后进行交换,将小的放左边,大的放右边,直到所有的元素都放置好,一趟划分就结束了。这里要注意一点就是,主元是包含在要遍历的数据集里面的,最后划分完只剩两部分了,不像前一个版本还有三个部分
先给出一个例子:待排序数组为13,19,9,5,12,8,7,4,11,2,6,21
第一趟选择13为主元。
下面是一趟划分的过程:
整个流程图就省略了。
这样,hoare-partition也可以写实现代码了:
int hoare_partition(int A[],int p,int r) { int x=A[p]; int i=p-1; int j=r+1; while(1) { do { j=j-1; }while(A[j]>x); do { i=i+1; }while(A[i]<x); if(i<j) swap(&A[i],&A[j]); else return j; } }
===================================================华丽的分割线================================================================
到此为止,两个版本的partition已经全部分析完,我们接下来实现递归调用,完成快速排序的整个函数:
伪代码如下:
QUICKSORT(A,p,r) if p<r then q=PARTITION(A,p,r) QUICKSORT(A,p,q-1) QUICKSORT(A,q+1,r)
需要注意的是,hoare版本递归的时候有点小小的区别,就是边界的问题:
QUICKSORT(A,p,r) if p<r then q=PARTITION(A,p,r) QUICKSORT(A,p,q) //注意第三个参数与前面的区别 QUICKSORT(A,q+1,r)
当然,按照《算法导论》上提示的,也可以写成如下形式,将第二个递归调用用迭代结果进行替代:
QUICKSORT(A,p,r) while p<r q=PARTITION(A,p,r) QUICKSORT(A,p,q-1) p=q+1
实现c代码这里只给出前一个的:
void quicksort(int* A,int p,int r) { int q; if(p<r) { q=partition(A,p,r); quicksort(A,p,q-1); quicksort(A,q+1,r); } }
==================================================华丽的分割线=================================================================
乘热打铁,我们再来简单分析一下快排的性能吧!
最平衡的划分的时候,得到的两个子问题大小都不会大于n/2,即其中一个为└n/2┘,另一个为┌n/2┐-1,这种情况下运行时间递归式满足
T(n)≤2T(n/2) +Θ(n)
这里可以按照《算法导论》上提出的主定理和递归树得出T(n)=O(nlogn),但是我用具体推导的方式来计算,思想就是高中的高阶线性递归数列的通项公式求法。
推导过程:
令n=2^m,有T(2^m)≤2*T(2^(m-1))+c*2^m(注:Θ(n)=c*n,c为常数)
两边除以2^m,得到T(2^m)/ 2^m≤T(2^(m-1))/ 2^(m-1)+c
再令a(m)= T(2^m)/ 2^m,有a(m)=a(m-1)+c
哈哈,是不是看到等差数列的递推关系式了。
这样a(m)=a(0)+m*c=T(2^0)/2^0+m*c=c*(m+1)
再代入m=logn,有T(n)/n=c*(logn+1)
T(n)=cn*(logn+1),也就有T(n)= O(nlogn)了,圆满结束。
至于最坏情况划分,就是每次划分都产生n-1个和1个元素的子问题,这时
T(n)=T(n-1)+T(1)+Θ(n)=T(n-1)+Θ(n)
容易得到T(n)=O(n^2),因此,这个时候的性能不比插入排序好。
===================================================华丽的分割线==============================================================
这里还没讲完。怎么避免出现最坏划分的情况呢?
我们从开始到现在,再讲partition的时候,每次选取主元的时候都是固定选取某一个元素,比如第一个,或者最后一个。这样的前提是我们假定输入的数据的排列都是等可能的,但是实际上并不一定都是这样。为了获得理想的评价情况性能,我们就需要做一下工作了。比如我们可以每次从随机选取一个元素作为主元,可以这样操作:
先随机选一个数A[i],然后交换A[i]与A[r],这样我们就能在r上获得比较随机的选择了。伪代码如下:
RANDOMIZED-PARTITION(A,p,r) i=RANDOM(p,r) swap(A[r],A[i]) return PARTITION(A,p,r)
然后,我们只需要换一下递归调用的函数就可以了:
RANDOMIZED-QUICKSORT(A,p,r) if p<r then q=RANDOMIZED-PARTITION(A,p,r) RANDOMIZED-QUICKSORT(A,p,q-1) RANDOMIZED-QUICKSORT(A,q+1,r)
实现的c代码比较简单,这里就不赘述了。
=================================================华丽的分割线==================================================================
下面贴上测试的代码和结果(更多测试的样例读者可以自己写):
int _tmain(int argc, _TCHAR* argv[]) { int a[10]={3,1,5,6,2,67,2,0,2,3}; cout<<"before sort:"<<endl; for(int i=0;i<10;i++) cout<<a[i]<<" "; cout<<endl; quicksort(a,0,9); cout<<"sorted array:"<<endl; int j; for(j=0;j<10;j++) cout<<a[j]<<" "; cout<<endl; cout<<"completed!"<<endl; return 0; }