本节的主题是快速排序,他可能是应用最广泛的排序算法了.
快速排序引人注目的特点包括他是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和NlogN成正比.我们已经学习过排序算法都无法将这两个优点结合起来
另外快速排序内循环比大多数排序算法都要短小,这意味着他无论是理论上还是实际上都要更快.他的主要缺点是非常脆弱,在实现时需要非常小心才能避开低劣的性能
快速排序算法是一种基于分治的排序算法.他将一个数组分成两个子数组,将两部分独立的排序.快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的两个子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了.在第一种情况中,递归调用发生在处理整个数组之前;第二种情况中,递归调用发生在处理整个数组之后.在归并排序中,一个数组被等分成两半;在快速排序中,切分(partition)的位置取决于数组的内容.快速排序的大致过程如图所示
快速排序递归的将子数组a[lo,hi]排序,先用partition()方法将a[j]放在一个合适的位置,然后再用递归调用将其他位置的元素排序
我们就是通过递归的调用切分来排序的
要完成这个实现,需要实现切分方法,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于他的元素,再从数组的右端向左扫描直到直到一个小于等于他的元素.这两个元素显然是没有排定的,因此我们交换他们的位置.如此继续,我们就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素.当两个指针相遇时,我们只需要将切分元素a[lo]和左子数组最右侧的元素(a[j])交换,然后返回j即可,切分方法大致如下图所示
快速排序的切分实现如下
package 排序.快速排序;
public class Quick {
public static void sort(Comparable[] a){
//消除对输入的依赖
}
private static void sort(Comparable[] a, int lo, int hi){
if (lo >= hi) return;
//切分
int j = partition(a, lo, hi);
//将左半部分a[lo,,,,,,j-1]排序
sort(a,lo,j-1);
//将右半部分a[j,,,,,,,hi]排序
sort(a, j+1, hi);
}
private static int partition(Comparable[] a, int lo, int hi){
//将数组且分为a[lo,,,i-1],a[i],a[i+1,,,,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;
}
private static boolean less(Comparable w, Comparable v){
return w.compareTo(v) < 0;
}
private static void exch(Comparable[] a, int w, int v){
Comparable temp = a[w]; a[w] = a[v]; a[v] = temp;
}
}
将这段代码按照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]中了)
如果使用一个辅助数组,我们很容易实现切分,但是将切分后的数组复制回去的开销也许会使得我们得不偿失.一个初级Java程序员甚至可能会将空数组创建在递归的切分方法中,这样会大大降低排序的速度
如果切分元素是数组中最大或者最小的那个元素,我们就小心别让扫描指针跑出数组的边界.patition()实现可以进行明确的检测来预防这种情况.测试条件(j==lo)是冗余的,因为切分元素就是a[lo],他不可能比自己小.数组右端也有同样的情况.他们都是可以去掉的
保持随机性的另一种方法是在patition()中随机选择一个元素
一个常见的错误是没有考虑数组中可能存在与切分元素的值相同的其他元素
可以避免算法复杂度变为平方级别
一个常见的错误是不能保证将切分元素放入正确的位置,从而导致程序在切分元素正好是数组最大值或者是最小值时陷入了无限的循环中
和大多数递归排序算法一样,改进快速排序性能的一个简单方法是基于一下两点
因此,在排序小数组时应该切换到插入排序.简单的改动quick算法即可:将sort()中的语句
if(hi <= lo) return;
替换成下面这条语句来对小数组使用插入排序
if(hi <= lo+M) {Insertion.sort(a,lo,hi)}
改进快速排序算法性能的第二个办法是使用子数组的一小部分元素的中位数来切分数组.这样做得到的切分更好,但是代价是需要计算中位数.人们发现将取样大小设置为3的效果最好
解决大量重复元素数组
一个简单的想法是将数组且分为三部分,分别对应小于等于大于切分元素的数组元素.这种且分实现b比我们目前使用的二分法更加复杂,人们为了解决它想出了很多不同的办法.这也是Dijstra的荷兰国旗问题引发的一道经典编程题目,因为这就好像用三种可能的主键值将数组排序一样,三个主键的值对应着荷兰国旗上的三种颜色
Dijstra的解法如"三向切分的快速排序"总即为简洁的切分代码所示.他从左到右遍历数组一次,维护一个指针lt使得a[lo,lt-1]的元素都小于v,一个指针gt使得a[gt,hi]中的元素都大于v,一个指针i使得a[lt,i-1]中的元素都等于v,a[i,gt]中的元素不确定,如下图所示
一开始i和lo相等,我们使用Comparable接口,而非less对a[i]进行三向比较直接处理一下情况:
这写操作都会保证数组元素不变且缩小gt-i的值(这样循环才会结束).另外除非和切分元素相等,其他元素都会被交换
package 排序.快速排序;
public class Quick3way {
public static void sort(Comparable[] a){
//消除对输入的依赖
}
private static void sort(Comparable[] a, int lo, int hi){
// if (lo >= hi) return;
// //切分
// int j = partition(a, lo, hi);
// //将左半部分a[lo,,,,,,j-1]排序
// sort(a,lo,j-1);
// //将右半部分a[j,,,,,,,hi]排序
// sort(a, j+1, hi);
if (hi <= lo) return;
int lt = lo, i = lo+1, gt=hi;
Comparable v = a[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++;
}
//现在a[lo..lt-1]
sort(a,lo,lt-1);
sort(a,gt+1,hi);
}
private static int partition(Comparable[] a, int lo, int hi){
//将数组且分为a[lo,,,i-1],a[i],a[i+1,,,,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;
}
private static boolean less(Comparable w, Comparable v){
return w.compareTo(v) < 0;
}
private static void exch(Comparable[] a, int w, int v){
Comparable temp = a[w]; a[w] = a[v]; a[v] = temp;
}
}