一、思路
快速排序是一種分治排序算法。快速排序先把數組重新整理分割兩個子數組,然后對兩個子數組進行排序。
快速排序和歸並排序是互補的:
歸並排序中,算法先將數組分為兩個子數組進行排序,再將兩個子數組進行歸並成一個有序的數組。
快速排序中,算法先對數組進行重新整理分割成兩個子數組,再對兩個子數組進行排序,當兩個子數組是有序時,整個數組即為有序的。
歸並排序中,遞歸調用發生在處理整個數組之前。快速排序中,遞歸調用發生在處理整個數組之后。
歸並排序數組是對半平分的,快速排序數組切分位置取決於數組的內容。
歸並排序代碼:
private static void sort(Comparable[] input, int lo, int hi) { if(lo >= hi)//just one entry in array return; int mid = lo + (hi-lo)/2; sort(input, lo, mid); sort(input, mid+1, hi); merge(input, lo, mid, hi); }
快速排序代碼:
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); }
快速排序的關鍵在於partition方法,執行完partition方法之后應該達到,a[j]就是最終位置,a[lo~(j-1)]都要小於或等於a[j],a[j+1~hi]都要大於或等於a[j]。
策略:
1、選a[lo]作為切分元素
2、從數組左端開始查找大於或等於a[lo]的元素(下標i<=hi)
3、從數組右端開始查找小於或等於a[lo]的元素(下標j>=lo)
4、交換這兩個元素。
5、重復2-4步驟,直到i和j交叉,退出。
6、最后交換a[lo]和a[j],最后一個步驟的時候a[j]<=a[lo],a[i]>=a[lo]。
partition方法最后要達到的狀態是,切分元素左邊不大於切分元素,切分元素右邊不小於切分元素,所以a[lo]應該和a[j]交換。
最終切分元素在位置j上,a[j]在位置lo上,這是符合的。
partition代碼:
private static int partition(Comparable[] a, int lo, int hi) { int i = lo; int j = hi + 1; Comparable v = a[lo]; while(true) { while(less(a[++i], v))//find item larger or equal to v if(i == hi) break; while(less(v, a[--j]));//not need to worry about j will be out of bound if(i >= j)//cross break; exch(a, i, j); } exch(a, lo, j); return j; }
測試用例:
package com.qiusongde; import edu.princeton.cs.algs4.In; import edu.princeton.cs.algs4.StdOut; import edu.princeton.cs.algs4.StdRandom; public class Quick { public static void sort(Comparable[] a) { // StdRandom.shuffle(a);//eliminate dependence on input // show(a);//for test 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 static int partition(Comparable[] a, int lo, int hi) { int i = lo; int j = hi + 1; Comparable v = a[lo]; while(true) { while(less(a[++i], v))//find item larger or equal to v if(i == hi) break; while(less(v, a[--j]));//not need to worry about j will be out of bound if(i >= j)//cross break; exch(a, i, j); } exch(a, lo, j); StdOut.printf("partition(input, %4d, %4d), j is %4d\n", lo, hi, j);//for test show(a);//for test return j; } private static void exch(Comparable[] a, int i, int j) { Comparable t = a[i]; a[i] = a[j]; a[j] = t; } private static boolean less(Comparable v, Comparable w) { return v.compareTo(w) < 0; } private static void show(Comparable[] a) { //print the array, on a single line. for(int i = 0; i < a.length; i++) { StdOut.print(a[i] + " "); } StdOut.println(); } public static boolean isSorted(Comparable[] a) { for(int i = 1; i < a.length; i++) { if(less(a[i], a[i-1])) return false; } return true; } public static void main(String[] args) { //Read strings from standard input, sort them, and print. String[] a = In.readStrings(); show(a); sort(a); assert isSorted(a); show(a); } }
輸出結果:
K R A T E L E P U I M Q C X O S partition(input, 0, 15), j is 5 E C A I E K L P U T M Q R X O S partition(input, 0, 4), j is 3 E C A E I K L P U T M Q R X O S partition(input, 0, 2), j is 2 A C E E I K L P U T M Q R X O S partition(input, 0, 1), j is 0 A C E E I K L P U T M Q R X O S partition(input, 6, 15), j is 6 A C E E I K L P U T M Q R X O S partition(input, 7, 15), j is 9 A C E E I K L M O P T Q R X U S partition(input, 7, 8), j is 7 A C E E I K L M O P T Q R X U S partition(input, 10, 15), j is 13 A C E E I K L M O P S Q R T U X partition(input, 10, 12), j is 12 A C E E I K L M O P R Q S T U X partition(input, 10, 11), j is 11 A C E E I K L M O P Q R S T U X partition(input, 14, 15), j is 14 A C E E I K L M O P Q R S T U X A C E E I K L M O P Q R S T U X
二、注意細節
1、原地切分
如果像歸並排序算法那樣使用一個輔助數組可以很容易實現切分,但是將切分后的元素復制回原來的數組的開銷也非常大。
2、別越界
如果切分元素是最大元素或最小元素,要特別注意數組越界的情況。
partition方法可通過顯示的判斷來預防這種情況,也可以通過將數組的最大值放置在數組最右端。
從而將下邊紅色代碼去掉
while(less(a[++i], v))//find item larger or equal to v if(i == hi) break;
3、保持隨機性
在排序之前要打亂數組的順序,使之隨機排序。這對預測算法的運行時間特別重要,同時也是為了避免最后情況的出現(第一次切分元素是最小值,第二次切分元素是第二小值,……)。
4、終止循環
保證循環結束需要非常小心。正確檢測指針是否交叉(cross)需要一點技巧,並不像看上去那么簡單。一個常見的錯誤就是沒有考慮到和切分元素值相同的情況。
5、處理切分元素有重復的情況
左側掃描最好是在遇到大於或等於切分元素的時候停下,右側掃描最好是在遇到小於或等於切分元素的時候停下。
盡管這樣會導致一些不必要的值交換,但在處理只有幾個不同元素值得數組時,會導致運行時間變為平方級別。
6、終止遞歸
確保遞歸總能結束。
三、性能分析
1、最好性能
快速排序最好的情況是每次都正好把數組對半分。
比較次數:CN = 2CN/2 + N,即CN~NlgN。
2、平均性能(distinct keys)
平均比較次數:CN=N+1+(C0 + C1 + …… + CN-2 + CN-1)/N + (CN-1 + CN-2 + …… + C0)/N
第一項是partition函數的比較次數,第二項是左子數組的平均比較次數,第三項是右子數組的平均比較次數。
算出CN=2(N+1)(1/3+1/4+……+1/(N+1))~2(N+1)(ln(N+1)-ln3)~2NlnN約等於1.39NlgN
3、最差性能
在每次切分后,兩個子數組總有一個是空的情況,性能最差。
CN=N+(N-1)+(N-2)+……+ 2 + 1 = (N+1)N/2
這不僅說明了算法所需的時間是平方級別的,也顯示了算法所需的空間是線性的。
但是,對於大數組,這個事件發生的概率幾乎可以忽略不計。而且我們在進行排序之前還對數組進行隨機處理(shuffle),可以讓這種糟糕情況的可能性降到最低。
比較次數的標准差為0.65N,隨着N的增大,運行時間會趨於平均數,且不可能離平均數太大。
總結:對於大小為N的數組,快速排序運行時間在1.39NlgN的某個常數因子的范圍之內。歸並排序也能做到這點,但沒有快速排序快,因為歸並排序需要更多的數據移動次數。
這些數算法性能保證來自數學概率,但可以依賴之。
四、算法改進
1、切換為插入排序
對於小數組來說,快速排序比插入排序慢。
小數組容量M的最佳值是和系統相關的,5-15之間的任意值在大多數情況下都能滿足需要。
2、三取樣區分
改進快速排序性能的第二個辦法是使用子數組的一小部分元素的中位數來作為切分元素。
這樣做的代價是需要計算中位數,好處是可以使用sample item放在數組末尾作為哨兵,這樣就可以去掉判斷i和j出界的代碼。下邊j出界的代碼是多余的,已經去除。
while(less(a[++i], v))//find item larger or equal to v if(i == hi) break; while(less(v, a[--j]));//not need to worry about j will be out of bound
3、熵最優排序
實際應用中經常會出現大量重復元素的數組,在這些情況下,快速排序還有改進空間。
例如,一個元素全部重復的子數組就不需要排序了,但上述快速排序還會繼續切分為更小的數組。在有大量重復元素的情況下,快速排序的遞歸性會使元素重復的子數組經常出現。
這很有改進潛力,可將線性對數級別提升為線性級別。
簡單的想法是將數組分為三部分,分別小於、等於和大於切分元素的數組元素。
(1)Dijkstra的解法如下邊代碼所示:
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++; }
sort(a, lo, lt - 1);
sort(a, gt + 1, hi);
算法從左到右掃描子數組,維護指針lt,i,gt,使a[lo..lt-1]<v,a[gt+1,hi]>v,a[lt..i-1] = v,a[i..gt]是還未掃描的部分。
初始化lt=lo, gt = hi, i = lo + 1
當a[i]<v,交換a[i]和a[lt],然后lt和i都增1;
當a[i]>v,交換a[i]和a[gt],然后gt減1;
當a[i]=v,i加1。
但是,這個改進並沒有流行起來,因為當數組元素重復不多時,三切分比標准二切分多使用了很多次交換。
(2)J. Bentley 和 D. Mcilroy找到了一個更好的方法克服了這個問題:
int i = lo, j = hi+1; int p = lo, q = 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; // pointers cross if (i == j && eq(a[i], v)) exch(a, ++p, i); if (i >= j) break; exch(a, i, j); if (eq(a[i], v)) exch(a, ++p, i); if (eq(a[j], v)) exch(a, --q, j); } i = j + 1; for (int k = lo; k <= p; k++) exch(a, k, j--); for (int k = hi; k >= q; k--) exch(a, k, i++);
sort(a, lo, j); sort(a, i, hi);
這個方法,維護指針lo,p,i,j使得a[lo..p-1] = v,a[q+1,hi] = v,a[p..i-1] < v,a[j+1..q] > v。
最后將數組兩邊等於v的元素,搬移到中間。
這個方法和上個方法是等價的,只是這個方法的額外交換用於和切分元素相等的元素,而上個方法將額外交換用於和切分元素不相等的元素。
(3)性能分析:
由命題N,可知當排序有大量重復元素數組時,三向切分快速排序將排序時間從線性對數級降為線性級別。
結合命題M和命題N,可知三向切分快速排序算法是熵最優(entropy-optimal)的。