快速排序
1、基本思想
快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序。它采用了一种分治的策略,通常称其为分治法(Divide-and-ConquerMethod)。
(1) 分治法的基本思想
分治法的基本思想是:将原问题分解为若干个规模更小但结构与原问题相似的子问题。递归地解这些子问题,然后将这些子问题的解组合为原问题的解。
(2)快速排序的基本思想
设当前待排序的无序区为R[low..high],利用分治法可将快速排序的基本思想描述为:
①分解
在R[low..high]中任选一个记录作为基准(Pivot),以此基准将当前无序区划分为左、右两个较小的子区间R[low..pivotpos-1)和R[pivotpos+1..high],并使左边子区间中所有记录的关键字均小于等于基准记录(不妨记为pivot)的关键字pivot.key,右边的子区间中所有记录的关键字均大于等于pivot.key,而基准记录pivot则位于正确的位置(pivotpos)上,它无须参加后续的排序。
注意:
划分的关键是要求出基准记录所在的位置pivotpos。划分的结果可以简单地表示为(注意pivot=R[pivotpos]):
R[low..pivotpos-1].keys≤R[pivotpos].key≤R[pivotpos+1..high].keys
其中low≤pivotpos≤high。
②求解
通过递归调用快速排序对左、右子区间R[low..pivotpos-1]和R[pivotpos+1..high]快速排序。
③组合
因为当"求解"步骤中的两个递归调用结束时,其左、右两个子区间已有序。对快速排序而言,"组合"步骤无须做什么,可看作是空操作。
2、快速排序算法
(1)标准快速排序
(2)快速排序优化
优化选取枢轴
分析:
代码中的pivotkey=L->data[low]变成了一个潜在的性能瓶颈,排序速度的快慢取决于L.data[1]的关键字处在整个序列的位置,L.data[1]太小或者太大,都会影响性能。因为在现实中,待排序的系列极有可能是基本有序的,此时,总是固定选取第一个关键字(其实无论是固定选取哪一个位置的关键字)作为整个枢轴就变成了极为不合理的作法。
我们的改进措施为,三树取中法,即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取。这样,至少中间数一定不会是最小或者最大的数,因此中间数位于较为中间的值的可能性就大大提高了。
优化不必要的交换
分析:
我们事实上将pivotkey备份到L.data[0]中,然后在之前是swap时,只做替换的工作,最终当low与high会合,即找到了枢轴的位置时,再将L.data[0]的数值赋值回L.data[low]。因为这当中少了多次交换数据的操作(数据交换时需要引入中间变量temp,有一定的空间损耗),在性能上又得到了部分提升。
优化小数组的排序方案
如果数组非常小,其实快速排序反而不如直接插入排序效果好(直接插入是简单排序中性能最好的)。其原因在于快速排序中用到了递归操作,在大量数据排序时,这点性能影响相对于它的整体算法优势而言可以忽略,但如果是只有几个记录需要排序时,这就大材小用了,我们需要改进一下QSort函数。
分析:
我们增加了一个判断,当high-low不大于某个常数(有资料认为7比较合适,也有认为50个宾馆合适,实际应用中适当调整),就用直接插入排序,这样能保证最大化地利用两种排序的优势来完成排序工作。
优化递归操作
递归对性能有一定的影响,QSort函数在其尾部有两次递归操作。如果待排序的序列划分极端不平衡,递归深度将趋近于n,而不是平衡时的log2n,这就不仅仅是速度快慢的问题了。栈的大小是很有限的,每一次递归调用都会耗费一定的栈空间,函数的参数越多,每次递归耗费的空间也就越多。因此如果能减少递归,将会大大提高性能。
于是我们对QSort实施尾递归优化,如下:
分析:
当我们将if改为while后,因为第一次递归以后,变量low就没有任何用处了,所以可以将pivot+1赋值给low,再循环后,来一次Partition(L,low,high),其效果等同于“QSort(L,pivot+1,high);“。结果相同,但因采用迭代而不是递归的方法可以缩减堆栈深度,从而提高整体性能。
拓展:
#include <stdio.h> int a[101],n; //定义全局变量,这两个变量需要在子函数中使用 void quicksort(int left,int right) { int i,j,t,temp; if(left>right) return; //这样做的目的是保证以左侧数字为参考值,不满足的情况直接跳出方法 temp=a[left]; //temp中存的就是基准数 i=left; j=right; //快排链式表的初始化 while(i!=j) { //顺序很重要,要先从右往左找 while(a[j]>=temp && i<j) j--; //再从左往右找 while(a[i]<=temp && i<j) i++; //交换两个数在数组中的位置 if(i<j) //当哨兵i和哨兵j 没有相遇时 { t=a[i]; a[i]=a[j]; a[j]=t; } } a[left]=a[i]; a[i]=temp; //这一步是互换两个参数 quicksort(left,i-1); //继续处理左边的,这里是一个递归的过程 quicksort(i+1,right); //继续处理右边的,这里是一个递归的过程} int main() { int i,j; //读入数据 scanf("%d",&n); for(i=1;i<=n;i++) scanf("%d",&a[i]); quicksort(1,n); //快速排序调用 //输出排序后的结果 for(i=1;i<=n;i++) printf("%d ",a[i]); getchar();getchar(); return 0; } 易错点: for(i=0;i<n;i++) scanf("%d",&a[i]); quicksort(1,n); for(i=0;i<n;i++) printf("%d ",a[i]); 这里虽然i的取值范围不变,但是启示代码出现了0,会出现错误,具体表现在第二个数字为0,排序出错。注意上面有i-1操作,会出现负值。
3、快排算法分析
快速排序的时间主要耗费在划分操作上,对长度为k的区间进行划分,共需k-1次关键字的比较。
(1)最坏时间复杂度
最坏情况是每次划分选取的基准都是当前无序区中关键字最小(或最大)的记录,划分的结果是基准左边的子区间为空(或右边的子区间为空),而划分所得的另一个非空的子区间中记录数目,仅仅比划分前的无序区中记录个数减少一个。
因此,快速排序必须做n-1次划分,第i次划分开始时区间长度为n-i+1,所需的比较次数为n-i(1≤i≤n-1),故总的比较次数达到最大值:
Cmax = n(n-1)/2=O(n2)
如果按上面给出的划分算法,每次取当前无序区的第1个记录为基准,那么当文件的记录已按递增序(或递减序)排列时,每次划分所取的基准就是当前无序区中关键字最小(或最大)的记录,则快速排序所需的比较次数反而最多。
(2)最好时间复杂度
在最好情况下,每次划分所取的基准都是当前无序区的"中值"记录,划分的结果是基准的左、右两个无序子区间的长度大致相等。总的关键字比较次数: 0(nlgn)
注意:
用递归树来分析最好情况下的比较次数更简单。因为每次划分后左、右子区间长度大致相等,故递归树的高度为O(lgn),而递归树每一层上各结点所对应的划分过程中所需要的关键字比较次数总和不超过n,故整个排序过程所需要的关键字比较总次数C(n)=O(nlgn)。
因为快速排序的记录移动次数不大于比较的次数,所以快速排序的最坏时间复杂度应为0(n2),最好时间复杂度为O(nlgn)。
(3)基准关键字的选取
在当前无序区中选取划分的基准关键字是决定算法性能的关键。
① "三者取中"的规则
"三者取中"规则,即在当前区间里,将该区间首、尾和中间位置上的关键字比较,取三者之中值所对应的记录作为基准,在划分开始前将该基准记录和该区伺的第1个记录进行交换,此后的划分过程与上面所给的Partition算法完全相同。
② 取位于low和high之间的随机数k(low≤k≤high),用R[k]作为基准
选取基准最好的方法是用一个随机函数产生一个取位于low和high之间的随机数k(low≤k≤high),用R[k]作为基准,这相当于强迫R[low..high]中的记录是随机分布的。用此方法所得到的快速排序一般称为随机的快速排序。具体算法【参见教材】
注意:
随机化的快速排序与一般的快速排序算法差别很小。但随机化后,算法的性能大大地提高了,尤其是对初始有序的文件,一般不可能导致最坏情况的发生。算法的随机化不仅仅适用于快速排序,也适用于其它需要数据随机分布的算法。
(4)平均时间复杂度
尽管快速排序的最坏时间为O(n2),但就平均性能而言,它是基于关键字比较的内部排序算法中速度最快者,快速排序亦因此而得名。它的平均时间复杂度为O(nlgn)。
(5)空间复杂度
快速排序在系统内部需要一个栈来实现递归。若每次划分较为均匀,则其递归树的高度为O(lgn),故递归后需栈空间为O(lgn)。最坏情况下,递归树的高度为O(n),所需的栈空间为O(n)。
(6)稳定性
快速排序是非稳定的,例如[2,2,1]。