常見排序演算法之快速排序
文章目錄
前言
排序演算法的成本模型計算的是比較和交換的次數。less()方法對元素進行比較,exch()方法將元素交換位置。
private static boolean less(Comparable v, Comparable w) {
return (v.compareTo(w) < 0);
}
private static void exch(Comparable[] a, int i, int j) {
Comparable swap = a[i];
a[i] = a[j];
a[j] = swap;
}
思路
快速排序是一種分治的排序演算法,它將一個數組分成兩個子陣列,將兩部分獨立地排序。
快速排序和歸併排序是互補的:歸併排序將陣列分成兩個子陣列分別排序,並將有序的子陣列歸併以將整個陣列排序;而快速排序將陣列排序的方式則是當兩個子陣列都有序時整個陣列也就自然有序了。前者的遞迴呼叫發生在處理整個陣列之前,而後者的遞迴呼叫則發生在處理整個陣列之後。
實現過程
基本演算法
public class Quick {
public static void sort(Comparable[] a) {
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);
}
private void shuffle(T[] nums) {
List<Comparable> list = Arrays.asList(nums);
Collections.shuffle(list);
list.toArray(nums);
}
}
該方法的關鍵在於切分。
切分方法
一般策略是先隨意地取a[lo]作為切分元素,即那個將會被排序的元素,然後我們從陣列的左端開始向右掃描直到找到一個大於等於它的元素,再從陣列的右端開始向左掃描直到找到一個小於等於它的元素。這兩個元素顯然是沒有排定的,因此交換它們的位置。如此繼續,我們就可以保證左指標i的左側元素都不大於切分元素,右指標j的右側元素都不小於切分元素。當兩個指標相遇時,我們只需要將切分元素a[lo]和左子陣列最右側的元素(a[j])交換然後返回j即可。
private static int partition(Comparable[] a, int lo, int hi) {
int i = lo;
int j = hi + 1;
Comparable v = a[lo];
while (true) {
// find item on lo to swap
while (less(a[++i], v)) {
if (i == hi) break;
}
// find item on hi to swap
while (less(v, a[--j])) {
if (j == lo) break; // redundant since a[lo] acts as sentinel
}
// check if pointers cross
if (i >= j) break;
exch(a, i, j);
}
// put partitioning item v at a[j]
exch(a, lo, j);
// now, a[lo .. j-1] <= a[j] <= a[j+1 .. hi]
return j;
}
這個過程使得陣列滿足下面三個條件:
- 對於某個j,a[j]已經排定
- a[lo]到a[j-1]中的所有元素都不大於a[j]
- a[j+1]到a[hi]中的所有元素都不小於a[j]
複雜度分析
快速排序的最好情況是每次都正好將陣列對半分。在這種情況下快速排序所用的比較次數正好滿足分治遞迴的Cn=2Cn/2+n。2Cn/2表示將兩個子陣列排序的成本,n表示用切分元素和所有陣列元素進行比較的成本,這個遞迴公式的解Cn~nlogn。(下文有具體數學推導)
而在最壞情況下,切分不平衡使得第一次從最小的元素切分,第二次從第二小的元素切分,如此繼續,每次切分後兩個子陣列之一總是為空的,比較次數為(n - 1) + (n - 2) +...+ 1 = n × (n - 1 ) / 2
。
而對於空間複雜度來說,主要考慮的是遞迴呼叫使用的棧空間,在最好的情況下(也就是對半分),遞迴深度為logn,最壞情況下的遞迴深度為n。
- 最壞時間複雜度 О(n²)
- 最優時間複雜度 O(nlogn)
- 平均時間複雜度 O(nlogn)
- 最壞空間複雜度 O(n)
- 最優空間複雜度 O(logn)
- 不穩定
最優時間複雜度的數學證明
演算法改進
切換到插入排序
因為快速排序在小陣列中也會遞迴呼叫自己,對於小陣列,插入排序比快速排序的效能更好,因此在小陣列中可以切換到插入排序。
只需要將程式碼中的if (hi <= lo) return;
改為if (hi <= lo + M) {Insertion.sort(a, lo, hi); return;}
。
三取樣切分
最好的情況下是每次都能取陣列的中位數作為切分元素,但是計算中位數的代價很高。人們發現取 3 個元素並將大小居中的元素作為切分元素的效果最好。
三向切分法
從左到右遍歷陣列一次,維護一個指標lt使得a[lo…lt-1]中的元素都小於v,一個指標gt使得a[gt+1…hi]中的元素都大於v,一個指標i使得a[lt…i-1]中的元素都等於v,a[i…gt]中的元素都還未確定。
一開始i和lo相等,對a[i]進行三向比較:
- a[i]小於v,將a[lt]和a[i]交換,將lt和i加一
- a[i]大於v,將a[gt]和a[i]交換,將gt減一
- a[i]等於v,將i加一
對於包含大量重複元素的陣列,它將排序時間從線性對數級降低到了線性級別。
public class Quick3way {
public static void sort(Comparable[] a) {
shuffle(a);
sort(a, 0, a.length - 1);
}
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);
}
private void shuffle(T[] nums) {
List<Comparable> list = Arrays.asList(nums);
Collections.shuffle(list);
list.toArray(nums);
}
}