玩转算法之I love sorting —— QuickSort

         在各种排序算法层出不穷的今日,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*nc为常数)

两边除以2^m,得到T(2^m)/ 2^mT(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;
}











你可能感兴趣的:(玩转算法之I love sorting —— QuickSort)