《算法》笔记 5 - 快速排序
-
基本算法
- 代码
- 切分方法
- 性能特点
-
算法改进
- 切换到插入排序
- 三取样切分
- 针对重复元素的优化-三向切分
本节将要学习的快速排序,可以说是应用最广泛的排序算法了,很多语言如Java、C#的系统排序采用的便是快速排序。快速排序实现简单,而且适用于各种不同的输入数据,且在一般的应用中比其他排序算法都要快得多。快速排序与前面几种排序算法相比其优势在于,它不仅是原地排序算法(只需要一个很小的辅助栈),而且将长度为N的数组排序所需的时间和NlgN成正比,而之前的几种算法都无法兼具这两个特点。归并排序虽然速度也很快,但它的空间成本却是线性增长的。
快速排序是由C.A.R Hoare在1960年发明的,被誉为20世纪十大算法之一,Hoare本人也因快速排序和其它计算机科学方面的成就而获得1980年的图灵奖。
基本算法
快速排序是一种基于分治的排序算法,它的基本思想是将数组分为两半,使左边的一半总是小于某个元素,右边的一半总是大于某个元素,这一过程不断递归下去,直到整个数组变得有序。
代码
代码如下:
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 (lo >= hi)
return;
int j = partition(a, lo, hi); //切分方法,待实现
sort(a, lo, j - 1); //将左边子数组排序
sort(a, j + 1, hi); //将右边边子数组排序
}
...
}
快速排序和归并排序是互补的,归并排序也是将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序,递归调用发生在处理整个数组之前;而快速排序将数组排序的方式则是通过让切分元素左右两边的子数组分别小于、大于切分元素,最终整个数组自然就是有序的了,它的递归调用发生在处理整个数组之后。
切分方法
切分方法partition()的作用是将切分元素a[j]放到其应该呆的位置,然后基于切分元素,会递归地调用sort方法将左右子数组排序。在排序前会先将数组打乱,这样可以让算法的性能免受输入特征的影响。
切分方法的代码为:
private static int partition(Comparable[] a, int lo, int hi) {
int i = lo, j = hi + 1;
Comparable v = a[i];
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);
return j;
}
切分的过程如图:
取a[lo]为切分元素v,指针i,j分别从数组的左右两端开始扫描,从左至右找到一个大于v的元素,从右至左找到一个小于v的元素,这两个元素都是未被排定的,于是交换它俩的位置,然后继续扫描、交换,直到左“指针”i和右“指针”j相遇,便完成了一次针对元素v的切分过程,同时v也已经被排定到合适的位置了。每次切分总能排定一个元素,最终便会将所有的元素排到正确的位置。
扫描时,用less(a[++i], v)而不是less(a[i++], v),因为刚进来时i被赋值为lo,lo是切分元素,要从下一位开始扫描;而右“指针”j的比较代码为less(v, a[--j]),而不是j--,是因为j初始被赋值为hi+1。
在遇到边界元素时,这段代码也可以将其排定。
性能特点
关于快速排序的性能特点,可以假设一种理想的情况,既每次切分都可以将数组对半分,这样就类似归并排序的二叉树,每层都需要N次比较,所以共NLgN次。
在我的本本上,将10万条随机整数进行排序时,归并排序与快速排序的速度对比如下:
Merge, 0.249
Quick, 0.195
算法改进
切换到插入排序
快速排序与归并排序一样都利用了递归,那么在小规模数组时切换到插入排序可以提高算法的性能。
public class QuickX {
private static int CUTOFF = 7;
...
private static void sort(Comparable[] a, int lo, int hi) {
if (lo + CUTOFF >= hi) {
insertionSort(a, lo, hi);
return;
}
int j = partition(a, lo, hi);
sort(a, lo, j - 1);
sort(a, j + 1, hi);
}
private static void insertionSort(Comparable[] a, int lo, int hi) {
for (int i = lo; i <= hi; i++) {
for (int j = i; j > lo && less(a[j], a[j - 1]); j--) {
exch(a, j, j - 1);
}
}
}
}
三取样切分
切分的效果对快速排序的性能影响很大,前面设想了一种理想的情况,既每次切分都可以将数组对半分;但如果切分时第一次从最小的元素切分,第二次从第二小的元素切分,以此类推,每次调用只会移除一个元素,这种情况下的性能将非常差。在开始排序前先进行打乱,就是为了避免类似的情况。三取样切分也是一种增加切分平衡性的方法,在选择切分元素时,选择数组最左、最右、中间三个元素的中位数。
三取样的代码如下:
public class QuickX {
private static int partition(Comparable[] a, int lo, int hi) {
int n = hi - lo + 1;
int m = median3(a, lo, lo + n / 2, hi); //三取样切分
exch(a, lo, m);
int i = lo, j = hi + 1;
Comparable v = a[i];
while (true) {
while (less(a[++i], v)) { // less(a[i++], v)
if (i == hi)
break;
}
while (less(v, a[--j])) { // less(v, a[j--])
if (j == lo)
break;
}
if (i >= j)
break;
exch(a, i, j);
}
exch(a, lo, j);
return j;
}
private static int median3(Comparable[] a, int i, int j, int k) {
return (less(a[i], a[j]) ? (less(a[j], a[k]) ? j : less(a[i], a[k]) ? k : i)
: (less(a[k], a[j]) ? j : less(a[k], a[i]) ? k : i));
}
}
在经过插入排序和三取样切分的优化后,再试试将10万条随机整数进行排序时,快速排序与优化后快速排序的速度对比:
Quick, 0.203
QuickX, 0.159
针对重复元素的优化-三向切分
在实际应用中经常会出现含有大量重复元素的数组,比如生日、性别等,遇到这种类型的输入时,如果子数组的元素已经全部是重复的,再继续运行算法将其切分、排定只会是浪费时间,针对这种情况,还可以进一步优化。
三向切分是将数组切分为三部分,分别对应小于、等于、大于切分元素的数组元素,代码为:
public class Quick3Way {
...
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 + 1;
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] < v = a[lt..gt] < a[gt+1..hi].
sort(a, lo, lt-1);
sort(a, gt+1, hi);
}
}
分别维护两个指针lt和gt,依次扫描数组,使得a[lo..lt-1] 都小于 v,a[lt..i-1]都等于v,a[gt+1..hi]都大于v,a[i..gt]中的元素是尚未比较大小的元素。
最后用由100个整数、每个重复1万次得到的100万条数据作为输入试试效果:
Quick, 0.408
Quick3Way, 0.03