算法—5.快速排序
快速排序可能是应用最广泛的排序算法了。流行的原因是它实现简单、适用于各种不同的输入数据且在一般应用中比其他排序算法都要快得多。快速排序引人注目的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和NlgN成正比。我们已经学习过的排序算法都无法将这两个优点结合起来。另外,快速排序的内循环比大多数排序算法都要短小,这意味着它无论是在理论上还是在实际中都要更快。
1.基本思想
快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。在第一种情况中,递归调用发生在处理整个数组之前;在第二种情况中,递归调用发生在处理整个数组之后。在归并排序中,一个数组被等分为两半;在快速排序中,切分的位置取决于数组的内容。如图
2.具体算法
/** * 快速排序 * @author huazhou * */ public class Quick extends Model{ public void sort(Comparable[] a){ StdRandom.shuffle(a); //消除对输入的依赖 sort(a, 0, a.length - 1); } private void sort(Comparable[] a, int lo, int hi){ if(hi <= lo){ return ; } int j = partition(a, lo, hi); //切分 sort(a, lo, j-1); //将左半部分a[lo..j-1]排序 sort(a, j+1, hi); //将右半部分a[j+1..hi]排序 } /** * 快速排序的切分 * 将数组切分为a[lo..i-1],a[i],a[i+1..hi] */ private int partition(Comparable[] a, int lo, int hi){ int i = lo, j = hi+1; //左右扫描指针 Comparable v = a[lo]; //切分元素 //扫描左右,检查扫描是否结束并交换元素 while(true){ while(less(a[++i], v)){ if(i == hi){ break; } } while(less(v, a[--j])){ if(j == lo){ break; } } if(i >= j){ break; } exch(a, i, j); } exch(a, lo, j); //将v=a[j]放入正确的位置 return j; //a[lo..j-1]<=a[j]<=a[j+1..hi]达成 } }
快速排序递归地将子数组a[lo..hi]排序,先用partition()方法将a[j]放到一个合适位置,然后再用递归调用将其他位置的元素排序。
该方法的关键在于切分,这个过程使得数组满足下面三个条件:
■对于某个j,a[j]已经排定;
■a[lo]到a[j-1]中的所有元素都不大于a[j];
■a[j+1]到a[hi]中的所有元素都不小于a[j]。
我们就是通过递归地调用切分来排序的。
因为切分过程总是能排定一个元素,用归纳法不难证明递归能够正确地将数组排序:如果左子数组和右子数组都是有序的,那么由左子数组(有序且没有任何元素大于切分元素)、切分元素和右子数组(有序且没有任何元素小于切分元素)组成的结果数组也一定是有序的。
要完成这个实现,需要实现切分方法。一般策略是先随意地取a[lo]作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这个两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[lo]和左子数组最右侧的元素(a[j])交换然后返回j即可。切分方法的大致过程如下图。
/** * 快速排序的切分 * 将数组切分为a[lo..i-1],a[i],a[i+1..hi] */ private int partition(Comparable[] a, int lo, int hi){ int i = lo, j = hi+1; //左右扫描指针 Comparable v = a[lo]; //切分元素 //扫描左右,检查扫描是否结束并交换元素 while(true){ while(less(a[++i], v)){ if(i == hi){ break; } } while(less(v, a[--j])){ if(j == lo){ break; } } if(i >= j){ break; } exch(a, i, j); } exch(a, lo, j); //将v=a[j]放入正确的位置 return j; //a[lo..j-1]<=a[j]<=a[j+1..hi]达成 }
这段代码按照a[lo]的值v进行切分。当指针i和j相遇时主循环退出。在循环中,a[i]小于v时我们增大i,a[j]大于v时我们减小j,然后交换a[i]和a[j]来保证i左侧的元素都不大于v,j右侧的元素都不小于v。当指针相遇时交换a[lo]和a[j],切分结束(这样切分值就留在a[j]中了)。
2.1 原地切分
如果使用一个辅助数组,我们可以很容易实现切分,但将切分后的数组复制回去的开销也许会使我们得不偿失。一个初级java程序员甚至可能会将空数组创建在递归的切分方法中,这会大大降低排序的速度。
2.2 别越界
如果切分元素是数组中最小或最大的那个元素,我们就要小心别让扫描指针跑出数组的边界。partition()实现可进行明确的检测来预防这种情况。测试条件(j == lo)是冗余的,因为切分元素就是a[lo],它不可能比自己小。数组右端也有相同的情况,它们都是可以去掉的。
2.3 保持随机性
数组元素的顺序是被打乱过的。因为快速排序算法对所有的子数组都一视同仁,它的所有子数组也都是随机排序的。这对于预测算法的运行时间很重要。保持随机性的另一种方法是在partition()中随机选择一个切分元素。
2.4 终止循环
一个最常见的错误是没有考虑到数组中可能包含和切分元素的值相同的其他元素。
2.5 处理切分元素值有重复的情况
左侧扫描最好是在遇到大于等于切分元素值的元素时停下,右侧扫描则是遇到小于等于切分元素值的元素时停下。尽管这样可能会不必要地将一些等值的元素交换,但在某些典型应用中,它能够避免算法的运行时间变为平方级别。
2.6 终止递归
例如,实现快速排序时一个常见的错误就是不能保证将切分元素放入正确的位置,从而导致程序在切分元素正好是子数组的最大或是最小元素时陷入了无限的递归循环之中。
3.算法分析
命题:将长度为N的无重复数组排序,快速排序平均需要~2NlnN次比较(以及1/6的交换)
证明:令CN为将N个不同元素排序平均所需的比较次数。显然C0=C1=0,对于N>1,由递归程序可以得到以下归纳关系:
CN=N+1+(C0+C1+...+CN-2+CN-1)/N+(CN-1+CN-2+...+C0)/N
第一项是切分的成本(总是N+1),第二项是将左子数组(长度可能是0到N-1)排序的平均成本,第三项是将右子数组(长度和左子数组相同)排序的平均成本。将等式左右两边乘以N并整理各项得到:
NCN=N(N+1)+2(C0+C1+...+CN-2+CN-1)
将该等式减去N-1时的相同等式可得:
NCN-(N-1)CN-1=2N+2CN-1
整理等式并将两边除以N(N+1)可得:
CN/(N+1)=CN-1/N+2/(N+1)
归纳法推导可得:
CN~2(N+1)(1/3+1/4+...+1/(N+1))
括号内的量是曲线2/x下从3到N的离散近似面积加一,积分得到CN~2NlnN。注意到2NlnN≈1.39NlgN,也就是说平均比较次数只比最好情况多39%
4.总结
尽管快速排序有很多优点,它的基本实现仍有一个潜在的缺点:在切分不平衡时这个程序可能会极为低效。例如,如果第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,每次调用只会移除一个元素。这会导致一个大子数组需要切分很多次。我们要在快速排序前将数组随机排序的主要原因就是要避免这种情况。它能够使产生糟糕的切分的可能性降到极低,我们就无需为此担心了。
总的来说,可以肯定的是对于大小为N的数组,快速排序算法的运行时间在1.39NlgN的某个常数因子的范围之内。归并排序也能做到这一点,但是快速排序一般会更快(尽管它的比较次数多39%),因为它移动数据的次数更少。这些保证都来自于数学概率,你完全可以相信它。
千万级
亿级
5.算法改进
如果你的排序代码会被执行很多次或者会被用在大型数组上(特别是如果它会被发布成一个库函数,排序的对象数组的特性是未知的),那么下面的改进意见值得你参考。需要注意的是,你需要通过实验来确定改进的效果并为实现选择最佳的参数。一般来说它们能将性能提升20%~30%
切换到插入排序
和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:
■对于小数组,快速排序比插入排序慢;
■因为递归,快速排序的sort()方法在小数组中也会调用自己。
因此,在排序小数组时应该切换到插入排序。简单地改动算法就可以做到这一点:将sort()中的语句
if(hi <= lo) return;
替换成下面这条语句来对小数组使用插入排序:
if(hi <= lo + M){Insertion.sort(a, lo, hi); return;}
转换参数M的最佳值是和系统相关的,但是5~15之间的任意值在大多数情况下都能令人满意。
【源码下载】