1.前言
在之前的博客当中,我们已经学习了插入,选择,冒泡,希尔,以及归并排序,而今天这篇博客当中,我们将学到目前应用最广泛的一种算法:快速排序算法,同时也将会用java代码实现它。
2.基本算法思想
快速排序流行的原因是它实践比较简单,适用于各种不同的输入数据,快速排序引人注目的特点包括它是一个原址排序,且将长度为N的数组排序所需的时间和NlgN成正比。但是它的缺点也十分明显,适用的时候要非常的小心,才能够避免低劣的性能,许多错误的用法会导致算法的时间复杂度退化到平方级别。
同之前的归并排序一样,快速排序算法也是分治算法的一个典型应用,它将一个数组分成两个子数组,将两部分独立的排序。快速排序和归并排序是互补的,归并排序将数组分成两个子数组分别排序,并将有序子数组归并以将整个数组排序;而快速排序则是当两个子数组都有序的时候,整个数组也有序了。
快速排序的关键在于如何对数组进行切分,这个过程需要是的数组满足下面三个条件:
(1)对于某个j,a[j]已经到了其最终位置
(2)a[lo]到a[j-1]中的所有元素必须小于等于a[j]
(3)a[j+1]到a[hi]中的所有元素都必须大于等于a[j]
而实现这个划分的一般策略:1.我们每次都选取数组的第一个元素a[lo]作为切分元素;2.指针i从左到右扫描数组,直到找到一个大于他的元素;3.指针j从右到左扫描数组直到找到一个小于它的元素;4 交换两个元素的位置,保证了指针i左侧的元素都不大于切分元素,指针j右侧的元素都不小于切分元素;5当两个指针相遇的时候,我们只需要将a[lo]与左数组最右侧的元素a[j]进行交换即可。
java代码的具体实现:
public class QuickSort {
public static void sort ( int[] a) {
sort( a ,0 ,a.length -1 );
}
public static void sort ( int[] a , int lo ,int hi){
if( hi <= lo){
return ;
}
int j = partition ( a , lo , hi);
sort( a , lo , j - 1 );
sort( a , j+1 , hi );
}
public static int partition( int a[] , int lo , int hi ){
int i = lo ;
int j =hi +1 ;
int v =a[lo];
while( true ){
while ( a[++i] <= v ){
if ( i == hi){
break ;
}
}
while ( a[--j]>= v ){
if( j == lo ){
break;
}
}
if( i>=j){
break;
}
int temp = a[i] ;
a[i] = a[j] ;
a[j] = temp ;
}
int temp = a[lo];
a[lo] = a[j] ;
a[j] = temp ;
return j ;
}
public static void main ( String args[]){
int a[] = { 6 , 1, 7, 3, 4,4};
sort( a );
for( int i = 0 ; i < a.length ; i ++ ){
System.out.print(a[i]+" ");
}
}
}
这部分代码的思路来自于算法第四版,但是我一开始在这里却是被卡了很久,问题并非处在于代码本身实现的难度上,而是在于其中的一些小的逻辑细节,于是打算在这里分享一下。
会有这个问题,主要还是因为我之前学快速排序的时候,学得模棱两可,记住了老师疯狂强调的一点:如果你的基数是选取的第一个元素的话,快速排序一定要让右边的指针先进行循环,否则你的排序可能会出错!为什么会这样呢,我这里写了一小段代码来证明:
另外一个版本的错误实现:
public static int partition1( int a[] , int lo , int hi ){
int i = lo ;
int j =hi ;
int v =a[lo];
while( true ){
while ( a[i] <= v && i<j){
i++;
}
while ( a[j]>= v && i<j){
j--;
}
if( i==j){
break;
}
int temp = a[i] ;
a[i] = a[j] ;
a[j] = temp ;
}
int temp = a[lo];
a[lo] = a[j] ;
a[j] = temp ;
return j ;
}
此时的排序运行结果是:
我们交换一下顺序,变成正确的版本:
public static int partition1( int a[] , int lo , int hi ){
int i = lo ;
int j =hi ;
int v =a[lo];
while( true ){
while ( a[j]>= v && i<j){
j--;
}
while ( a[i] <= v && i<j){
i++;
}
if( i==j){
break;
}
int temp = a[i] ;
a[i] = a[j] ;
a[j] = temp ;
}
int temp = a[lo];
a[lo] = a[j] ;
a[j] = temp ;
return j ;
}
此时的运行结果:
为什么当我们选择从左指针开始循环的时候,就无法得到排序正确的排序结果呢?
我们可以分析一下我们输入的数组{6,1,2,3,7,9};对程序进行debug
当i=j=4的时候,跳出循环,此时我们将a[0]和a[4]进行交换,结果变成了{7,1,2,3,6,9},明显发现这个数组是不符合要求的,简而言之,如果我们从左指针开始 ,左指针停下的那个元素,我们只能确保该元素左边的所有元素都小于等于切分元素,但是停下来的这个数却有可能是大于切分元素的,我们进它行交换明显就会出错。但是为什么我们按照算法的代码却完全不用考虑这个情况呢?问题的关键就在于两个指针的循环结束条件小了一个i<j;意思就是说,我们所谓的两个指针相遇,对于该算法却往往已经是擦肩而过的回眸,我们拿同样的组数进行debug也可以证明这一点:
可以很明显的看到我们循环结束的时候,i=4>j=3,而我们进行交换的元素是a[lo]和a[j];为什么会这样呢?简而言之,我们的指针在进行循环扫描的时候,我们不用考虑他和另外一个指针的大小关系,而且可以很简单的证明,两个指针在插肩而过的一瞬间,就会终止循环(因为插肩而过意味着右指针已经进入了左指针扫描过的数组当中,而这部分数组一定是小于切分元素的)。所以,对于算法第四版的代码我们可以肆无忌惮选择两个指针循环的额顺序。
3.快速排序的优化版本
(1)对于小数组,我们选取更快的插入排序:
将代码中的
if ( hi <+ lo ) return;
替换成
if ( hi <= lo + M) { 插入排序 ( a , lo , hi ) } ;
转换参数M的最佳值是和系统相关的,但是5~15之间的任意值在大多数情况下都是令人满意的。
(2)三取样划分
三向切分顾名思义就是将数组划分为三个部分,从左到右分别是小于切分元素、等于切分元素、大于切分元素。然后再对小于切分元素和大于切分元素的部分数组进行排序。对于有大量重复元素的数组,这个方法比标准的快速排序的效率高很多。
4.总结
对于快速排序的学习就先到此为止了,文章中提到的三向切分也只是了解了一下,并没有真正动手的去实现它。而对于排序算法,后序还会更新堆排序,基数排序等常见的学习笔记。最后提一下,快速排序是一个不稳定的算大,最优情况下的时间复杂度为O(nlog(n)),最差情况下的时间复杂度为O(n^2)。