Quick Sort 快速排序
0.前言
前面的文章分析了归并排序这个性能极高的排序算法,本文将继续分析另一个性能相当的排序算法——快速排序。快速排序还被誉为20世纪十大最佳算法之一。在Java编程中Arrays.sort(o),如果括号里的o是java基本类型,那么该API就会调用快速排序算法。
1.快速排序
原理
快速排序运用了递归的思想:
input:数组s
sort(数组s):
1.调用partition()将数组分成两部分
2.对左右两部分(递归地)调用sort()
partition():将数组以j-位置为界限分成两部分,j左边全部小于j-值,j右边全部大于j-值
代码实现
public class Quick{
//将数组分成两部分
private static int partition(Comparable[] a, int lo, int hi){
int i = lo, j = hi+1;
while (true){
while (less(a[++i], a[lo]))
if (i == hi) break;
while (less(a[lo], a[--j]))
if (j == lo) break;
if (i >= j) break;
exch(a, i, j);
}
exch(a, lo, j);
return j;
}
//外部排序方法
public static void sort(Comparable[] a){
//关键:将数组顺序打乱
StdRandom.shuffle(a);
sort(a, 0, a.length - 1);
}
//内部排序方法
private static void sort(Comparable[] 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);
}
}
注意事项:
- 算法并没有使用到辅助数组,全部都在原数组上操作的。
- 在此算法中,j == lo是多余的判断,但i == hi确实必须的(考虑数组最大值在lo位置的情况)。
- 将数组打乱是非常关键的操作,因为有序的数组(无论是正序还是逆序)都是影响此算法的性能。当然,shuffle数组也要保证其性能。
- 如果待排序数组存在很多相同的值也会极大地影响此算法的性能,这一点将在下面详细分析。
时间性能
最好的情况best case:~NlgN
最坏的情况worst case:~1/2N^2
平均average case:~2NlgN,下图详细地分析了期数学过程。
(以上都是比较compare操作)
然而,有两种情况会极大地影响排序算法的性能:1)待排序数组已经有序,无论是正序还是逆序(所以排序前要打乱数组);2)待排序数组汇中存在很多相同的值。具体将在下文进行分析。
空间占用率
快速排序是在原数组上(in place)进行操作的,所以额外空间占用是常数级的。
稳定性
快速排序不是稳定的算法,因为值之间的交换跨度非常大。
优化
优化一:当数组被分割到很小的时候(临界值),改用插入排序算法来对其进行排序。这个临界值可以根据实际情况来确定。
private static void sort(Comparable[] a, int lo, int hi){
//CUTOFF就是临界值
if (hi <= lo + CUTOFF - 1){
Insertion.sort(a, lo, hi);
return;
}
int j = partition(a, lo, hi);
sort(a, lo, j-1);
sort(a, j+1, hi);
}
优化二:取中位数(非严格)进行patition的比较参考,即lo位置的应该是中位数。
private static void sort(Comparable[] a, int lo, int hi){
if (hi <= lo) return;
//这是简单地取lo、mid、hi位置三个数中的中位数
int m = medianOf3(a, lo, lo + (hi - lo)/2, hi);
swap(a, lo, m);
int j = partition(a, lo, hi);
sort(a, lo, j-1);
sort(a, j+1, hi);
}
对于稍大的数组,我们也可以使用Tukey’s ninther方法来取中位数。Tukey’s ninther的原理:
1.按照间隔,从数组中取出9个值
2.没3个值为一组,调用medianOf3()取中位数,取得3个中位数
3.再将3个中位数组成一组,再调用medianOf3()取中位数,得到结果
使用Tukey’s ninther的好处就是避免在排序开始前需要打乱(shuffle)数组,同时效率更高。
2.快速选择
在实际应用中,我们经常需要在一个N长的数组中取其第K大的值,此时,快速排序将是效率非常高的算法。
原理
1.与快速排序一样,使用patition()将数组a分成以j位置为界的两部分,左边的值全部小于a[j],右边全部大于a[j]。
2.如果j==k,那么就求得第K大的值;如果j<k,说明第k大的值在右边;如果j>k,说明第k大的值在左边。
3.根据第2步判断k的位置,在对应位置patition()。
4.loop:从第一步开始。
代码实现
public static Comparable select(Comparable[] a, int k){
//同理,需要打乱数组,否则影响算法性能(实际是patition的性能)
StdRandom.shuffle(a);
int lo = 0, hi = a.length - 1;
while (hi > lo){
int j = partition(a, lo, hi);
if (j < k) lo = j + 1;
else if (j > k) hi = j - 1;
else return a[k];
}
return a[k];
}
时间性能
在有序的情况下会出现最坏情况:~1/2N^2,因此需要打乱数组来保证时间性能
平均消耗了线性(linear)的时间:~N
分析:每一次都大概将数组对半分,所以存在N + N/2 + N/4 + … + 1 ~ 2N 比较(compares)。
空间占用率
快速选择是在原数组上(in place)进行操作的,所以额外空间占用是常数级的。
3. 3-way partitioning三分法
上文提到,如果数组中存在很多相等的值,将会影响快速排序的性能(但对归并排序并没有很大影响)。如果扫描过程中遇到相等的值时不停止扫描,将会消耗平方(quadratic)的时间。
所以,正确的做法是在扫描过程中遇到相等的值的时候就停止扫描。
存在一种更优的方法:如果在分区的时候,能够将相等的值分在同一个区,将会更好地优化性能问题,因此有人提出了3-way partition方法。
原理
1.用lo,lt,gt,hi将数组分成三部分,
2.lo-lt部分是小于参考值v的,lt-gt部分是等于v的,gt-hi是大于v的
代码实现
private static void sort(Comparable[] a, int lo, int hi){
if (hi <= lo) return;
int lt = lo, gt = hi;
Comparable v = a[lo];
int i = lo;
while (i <= gt){
int cmp = a[i].compareTo(v);
if (cmp < 0) exch(a, lt++, i++);
else if (cmp > 0) exch(a, i, gt--);
else i++;
}
sort(a, lo, lt - 1);
sort(a, gt + 1, hi);
}
时间性能
实际上,在最坏的情况下,至少需要如下图所示的比较次数。
当所有数组所有值都相异时,取NlgN(与快速排序相同);
当数组值存在部分等值时,去N(linear线性)。
空间占有率
三分快速排序是在原数组上(in place)进行操作的,所以额外空间占用是常数级的。
4.应用
java
在java中由一个常用的排序API:Arrays.sort(a)。如果a是基本数据类型,则使用快速排序(plus插入排序);如果a是类对象,则使用归并排序(plus插入排序)。
这么做的原因在于:如果程序使用类对象,说明空间性能并不是很重要,只需要关注时间性能,所以使用归并排序保证了排序的时间性能(不管什么情况都是NlgN);如果程序使用了基本数据类型,说明对空间和时间性能均有要求,所以使用快速排序(额外空间占用率是常数级)。
指标
- 稳定性 stability
- 并行 parallel
- 确定性 Deterministic
- 异值/同值 Keys all distinct
- 链表/数组 Linked list or arrays
- 多种类型的值 Multiple key types
- 大数 Large or small items
- 是否有序 Is your array randomly ordered
- 是否有性能保证 Need guaranteed performance
总结