快速排序
快速排序流行的原因是它实现简单、适用于各种不同的输人数据且在一般应用中比其他排序算法都要快得多。快速排序引人注目的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和NlgN成正比。
快速排序的内循环比大多数排序算法都要短小,这意味着它无论是在理论上还是在实际中都要更快。它的主要缺点是非常脆弱,在实现时要非常小心才能避免低劣的性能。
快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。
快速排序和归并排序是互补的:
- 归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;
- 快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。
在第一种情况中,递归调用发生在处理整个数组之前;
在第二种情况中,递归调用发生在处理整个数组之后。
在归并排序中,一个数组被等分为两半;
在快速排序中,切分 (partition)的位置取决于数组的内容。
public class Quick {
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 - l); // 将左半部分a[lo...j - l] 排序
sort(a,j + 1,hi);
}
private static int partition(Comparable[] a, int lo , int hi) {
// 将数组切分为a[lo. _ i- l],a [ i] , a[i+l. .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-l] <= a [j] <= a[j+l. . hi] 达成
}
先随意地取a[lo] 作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个 小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针 i 的左侧元素都不大于切分元素,右指针j 的右侧元素都 不小于切分元素。当两个指针相遇时,我们只需要将切分 元素a [ lo ]和左子数组最右侧的元素(a [ j] ) 交换然后返 回 j 即可。
左侧扫描最好是在遇到大于等于切分元素值的元素时停下,右侧扫描则是遇到小于等于切分元素值的元素时停下。尽管这样可能会不必要地将一些等值的元素交换,但在某些 典型应用中,它能够避免算法的运行时间变为平方级别。
性能特点
快速排序切分方法的内循环会用一个递增的索引将数组元素和一个定值比较。这种简洁性也是快速排序的一个优点,很难想象排序算法中还能有比这更短小的内循环了。例如,归并排序和希尔排序一般都比快速排序慢,其原因就是它们还在内循环中移动数据。
快速排序另一个速度优势在于它的比较次数很少。排序效率最终还是依赖切分数组的效果,而这依赖于切分元素的值。切分将一个较大的随机数组分成两个随机子数组,而实际上这种分割可能 发生在数组的任意位置(对于元素不重复的数组而言)。下面我们来分析这个算法,看看这种方法和理想方法之间的差距。
快速排序的最好情况是每次都正好能将数组对半分。在这种情况下快速排序所用的比较次数正好满足分治递归的Cn = 2Cn/2 + N公式 。 2Cn/2表示将两个子数组排序的成本,n表示用切分元素和所有数组元素进行比较的成本。这个递归公式的解Cn --nlgn。尽管事情并不总会这么顺利,但平均而言切分元素都能落在数组的中间。
尽管快速排序有很多优点,它的基本实现仍有一个潜在的缺点:在切分不平衡时这个程序可能会极为低效。例如,如果第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,每次调用 只会移除一个元素。这会导致一个大子数组需要切分很多次。要在快速排序前将数组随机排序的主要原因就是要避免这种情况。
算法改进
切换到插入排序
和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:
- 对于小数组,快速排序比插入排序慢;
- 因为递归,快速排序的s o r t ( )方法在小数组中也会调用自己。
因此,在排序小数组时应该切换到插入排序。简单地改动算法2.5就可以做到这一点:
将sort ()中的语句
if(hi <= lo ) return;
替换成下面这条语句来对小数组使用插入排序:
if (hi <= lo + M) {
Insertion.sort(a,lo,hi);
return;
}
转换参数M的最佳值是和系统相关的,但 是5 〜15之间的任意值在大多数情况下都能令人满意。
三取样切分
改进快速排序性能的第二个办法是使用子数组的一小部分元素的中位数来切分数组。这样做得 到的切分更好,但代价是需要计算中位数。人们发现将取样大小设为3 并用大小居中的元素切分的 效果最好。
我们还可以将取样元素放在数组末尾作为“哨兵” 来去掉partition()中的数组边界测试。
熵最优的排序
在有大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现 ,这就有很大的改进潜力,将当前实现的线性对数级的性能提高到线性级别。
一个简单的想法是将数组切分为三部分,分别对应小于、等于和大于切分元素的数组元素。
它从左到右遍历数组一次,维护一个指针It使得a [lo . . l t - l ] 中的元素都小于v ,一个指针gt使得a[gt+l . . hi] 中的元素都大于v, —个指针i 使得a[lt . . i - l ] 中的元素都等于v ,a [ i. . g t ] 中的元素都还未确定。
- a[i] 小于v,将 a[lt] 和 a[i] 交换,将It和i加一;
- a [i] 大于V,将a[gt]和 a[i] 交换,将gt减一;
- a[i] 等于v , 将i加一。
public class Quick3way {
private static void sort(Comparable[] a, int lo, int hi) {
// 调用此方法的公有方法s o r t ( ) 请见算法2.5
if (hi <= lo) return;
int It = lo, i = lo + 1, gt = hi;
Comparable v = a[lo];
while (i <= gt) {
int cmp = a[i].compareTo(v);
if (cmp < 0)
exch(a, 11++, i++);
else if (cmp > 0)
exch(a,i,gt--);
else
i++;
} // 现在 a [lo..lt-l]
sort(a,lo,lt - 1) ;
sort(a,gt + 1,hi);
}
}
这段排序代码的切分能够将和切 分元素相等的元素归位,这样它们就不会被包含在递归调用处理的子数组之中了。对于存在大量重复元素的数组,这种方法比标准的快速排序的效率高得多。
三向切分的最坏情况正是所有主键均不相同。当存在重复主键时,它的性能就会比归并排序好得多。
对于标准的快速排序,随着数组规模的增大其运行时间会趋于平均运行时间,大幅偏离的情况非常罕见,因此可以肯定三向切分的快速排序的运行时间和输人的信息量的N倍是成正比的。在实际应用中这个性质很重要,因为对于包含大量重复元素的数组,它将排序时间从线性对数级降低到了线性级别。这和元素的排列顺序没有关系,因为算法会在排序之前将其打乱以避免最坏情况。元素的概率分布决定了信息量的大小,没有基于比较的排序算法能够用少于信息量决定的比较次数完成排序。这种对重复元素的适应性使得三向切分的快速排序成为排序库函数的最佳算法选择—— 需要将包含大量重复元素的数组排序的用例很常见。