上篇文章介紹了時間複雜度為O(nlgn)的合併排序,本篇文章介紹時間複雜度同樣為O(nlgn)但是排序速度比合並排序更快的快速排序(Quick Sort)。
快速排序是20世紀科技領域的十大演算法之一 ,他由C. A. R. Hoare於1960年提出的一種劃分交換排序。
快速排序也是一種採用分治法解決問題的一個典型應用。在很多程式語言中,對陣列,列表進行的非穩定排序在內部實現中都使用的是快速排序。而且快速排序在面試中經常會遇到。
本文首先介紹快速排序的思路,演算法的實現、分析、優化及改進,最後分析了.NET 中列表排序的內部實現。
一 原理
快速排序的基本思想如下:
- 對陣列進行隨機化。
- 從數列中取出一個數作為中軸數(pivot)。
- 將比這個數大的數放到它的右邊,小於或等於它的數放到它的左邊。
- 再對左右區間重複第三步,直到各區間只有一個數。
如上圖所示快速排序的一個重要步驟是對序列進行以中軸數進行劃分,左邊都小於這個中軸數,右邊都大於該中軸數,然後對左右的子序列繼續這一步驟直到子序列長度為1。
下面來看某一次劃分的步驟,如下圖:
上圖中的劃分操作可以分為以下5個步驟:
- 獲取中軸元素
- i從左至右掃描,如果小於基準元素,則i自增,否則記下a[i]
- j從右至左掃描,如果大於基準元素,則i自減,否則記下a[j]
- 交換a[i]和a[j]
- 重複這一步驟直至i和j交錯,然後和基準元素比較,然後交換。
劃分過程的程式碼實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
/// <summary> /// 快速排序中的劃分過程 /// </summary> /// <param name="array">待劃分的陣列</param> /// <param name="lo">最左側位置</param> /// <param name="hi">最右側位置</param> /// <returns>中間元素位置</returns> private static int Partition(T[] array, int lo, int hi) { int i = lo, j = hi + 1; while (true) { //從左至右掃描,如果碰到比基準元素array[lo]小,則該元素已經位於正確的分割槽,i自增,繼續比較i+1; //否則,退出迴圈,準備交換 while (array[++i].CompareTo(array[lo]) < 0) { //如果掃描到了最右端,退出迴圈 if (i == hi) break; } //從右自左掃描,如果碰到比基準元素array[lo]大,則該元素已經位於正確的分割槽,j自減,繼續比較j-1 //否則,退出迴圈,準備交換 while (array[--j].CompareTo(array[lo]) > 0) { //如果掃描到了最左端,退出迴圈 if (j == lo) break; } //如果相遇,退出迴圈 if (i >= j) break; //交換左a[i],a[j]右兩個元素,交換完後他們都位於正確的分割槽 Swap(array, i, j); } //經過相遇後,最後一次a[i]和a[j]的交換 //a[j]比a[lo]小,a[i]比a[lo]大,所以將基準元素與a[j]交換 Swap(array, lo, j); //返回掃描相遇的位置點 return j; } |
劃分前後,元素在序列中的分佈如下圖:
二 實現
與合併演算法基於合併這一過程一樣,快速排序基於分割(Partition)這一過程。只需要遞迴呼叫Partition這一操作,每一次以Partition返回的元素位置來劃分為左右兩個子序列,然後繼續這一過程直到子序列長度為1,程式碼的實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class QuickSort<T> where T : IComparable<T> { public static void Sort(T[] array) { Sort(array, 0, array.Length - 1); } private static void Sort(T[] array, int lo, int hi) { //如果子序列為1,則直接返回 if (lo >= hi) return; //劃分,劃分完成之後,分為左右序列,左邊所有元素小於array[index],右邊所有元素大於array[index] int index = Partition(array, lo, hi); //對左右子序列進行排序完成之後,整個序列就有序了 //對左邊序列進行遞迴排序 Sort(array, lo, index - 1); //對右邊序列進行遞迴排序 Sort(array, index + 1, hi); } } |
下圖說明了快速排序中,每一次劃分之後的結果:
一般快速排序的動畫如下:
三 分析
- 在最好的情況下,快速排序只需要大約nlgn次比較操作,在最壞的情況下需要大約1/2 n2 次比較操作。在最好的情況下,每次的劃分都會恰好從中間將序列劃分開來,那麼只需要lgn次劃分即可劃分完成,是一個標準的分治演算法Cn=2Cn/2+N,每一次劃分都需要比較N次,大家可以回想下我們是如何證明合併排序的時間複雜度的。在最壞的情況下,即序列已經排好序的情況下,每次劃分都恰好把陣列劃分成了0,n兩部分,那麼需要n次劃分,但是比較的次數則變成了n, n-1, n-2,….1, 所以整個比較次數約為n(n-1)/2~n2/2.
- 快速排序平均需要大約2NlnN次比較,來對長度為n的排序關鍵字唯一的序列進行排序。 證明也比較簡單:假設CN為快速排序平均花在比較上的時間,初始C0=C1=0,對於N>1的情況,有:其中N+1是分割時的比較次數, 表示將序列分割為0,和N-1左右兩部分的概率為1/N, 劃分為1,N-2左右兩部分的概率也為1/N,都是等概率的。然後對上式左右兩邊同時乘以N,整理得到:
然後,對於N為N-1的情況:
兩式相減,然後整理得到:
然後左右兩邊同時除以N(N+1),得到:
可以看到,這是一個遞迴式,我們將 遞迴展開得到:
然後處理一下得到:
- 平均情況下,快速排序需要大約1.39NlgN次比較,這比合並排序多了39%的比較,但是由於涉及了較少的資料交換和移動操作,他要比合並排序更快。
- 為了避免出現最壞的情況,導致序列劃分不均,我們可以首先對序列進行隨機化排列然後再進行排序就可以避免這一情況的出現。
- 快速排序是一種就地(in-place)排序演算法。在分割操作中只需要常數個額外的空間。在遞迴中,也只需要對數個額外空間。
- 另外,快速排序是非穩定性排序。
四 改進
對一般快速排序進行一些改進可以提高其效率。
1. 當劃分到較小的子序列時,通常可以使用插入排序替代快速排序
對於較小的子序列(通常序列元素個數為10個左右),我們就可以採用插入排序直接進行排序而不用繼續遞迴,演算法改造如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private const int CUTTOFF = 10; private static void Sort(T[] array, int lo, int hi) { //如果子序列為1,則直接返回 if (lo >= hi) return; //對於小序列,直接採用插入排序替代 if (hi - lo <= CUTTOFF - 1) { Sort<int>.InsertionSort(array, lo, hi); return; } //劃分,劃分完成之後,分為左右序列,左邊所有元素小於array[index],右邊所有元素大於array[index] int index = Partition(array, lo, hi); //對左右子序列進行排序完成之後,整個序列就有序了 //對左邊序列進行遞迴排序 Sort(array, lo, index - 1); //對右邊序列進行遞迴排序 Sort(array, index + 1, hi); } |
2. 三平均分割槽法(Median of three partitioning)
在一般的的快速排序中,選擇的是第一個元素作為中軸(pivot),這會出現某些分割槽嚴重不均的極端情況,比如劃分為了1和n-1兩個序列,從而導致出現最壞的情況。三平均分割槽法與一般的快速排序方法不同,它並不是選擇待排陣列的第一個數作為中軸,而是選用待排陣列最左邊、最右邊和最中間的三個元素的中間值作為中軸。這一改進對於原來的快速排序演算法來說,主要有兩點優勢:
(1) 首先,它使得最壞情況發生的機率減小了。
(2) 其次,未改進的快速排序演算法為了防止比較時陣列越界,在最後要設定一個哨點。如果在分割槽排序時,中間的這個元素(也即中軸)是與最右邊數過來第二個元素進行交換的話,那麼就可以省略與這一哨點值的比較。
對於三平均分割槽法還可以進一步擴充套件,在選取中軸值時,可以從由左中右三個中選取擴大到五個元素中或者更多元素中選取,一般的,會有(2t+1)平均分割槽法(median-of-(2t+1)。常用的一個改進是,當序列元素小於某個閾值N時,採用三平均分割槽,當大於時採用5平均分割槽。
採用三平均分割槽法對快速排序的改進如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
private static void Sort(T[] array, int lo, int hi) { //對於小序列,直接採用插入排序替代 if (hi - lo <= CUTTOFF - 1) { //Sort<int>.InsertionSort(array, lo, hi); return; } //採用三平均分割槽法查詢中軸 int m = MedianOf3(array, lo, lo + (hi - lo) / 2, hi); Swap(array, lo, m); //劃分,劃分完成之後,分為左右序列,左邊所有元素小於array[index],右邊所有元素大於array[index] int index = Partition(array, lo, hi); //對左右子序列進行排序完成之後,整個序列就有序了 //對左邊序列進行遞迴排序 Sort(array, lo, index - 1); //對右邊序列進行遞迴排序 Sort(array, index + 1, hi); } /// <summary> /// 查詢三個元素中位於中間的那個元素 /// </summary> /// <param name="array"></param> /// <param name="lo"></param> /// <param name="center"></param> /// <param name="hi"></param> /// <returns></returns> private static int MedianOf3(T[] array, int lo, int center, int hi) { return (Less(array[lo], array[center]) ? (Less(array[center], array[hi]) ? center : Less(array[lo], array[hi]) ? hi : lo) : (Less(array[hi], array[center]) ? center : Less(array[hi], array[lo]) ? hi : lo)); } private static bool Less(T t1, T t2) { return t1.CompareTo(t2) < 0; } |
使用插入排序對小序列進行排序以及使用三平均分割槽法對一般快速排序進行改進後執行結果示意圖如下:
3. 三分割槽(3-way partitioning) 快速排序
通常,我們的待排序的序列關鍵字中會有很多重複的值,比如我們想對所有的學生按照年齡進行排序,按照性別進行排序等,這樣每一類別中會有很多的重複的值。理論上,這些重複的值只需要處理一次就行了。但是一般的快速排序會遞迴進行劃分,因為一般的快速排序只是將序列劃分為了兩部分,小於或者大於等於這兩部分。
既然要利用連續、相等的元素不需要再參與排序這個事實,一個直接的想法就是通過劃分讓相等的元素連續地擺放:
然後只對左側小於V的序列和右側大於V對的序列進行排序。這種三路劃分與電腦科學中無處不在,它與Dijkstra提出的“荷蘭國旗問題”(The Dutch National Flag Problem)非常相似。
Dijkstra的方法如上圖:
從左至右掃描陣列,維護一個指標lt使得[lo…lt-1]中的元素都比v小,一個指標gt使得所有[gt+1….hi]的元素都大於v,以及一個指標i,使得所有[lt…i-1]的元素都和v相等。元素[i…gt]之間是還沒有處理到的元素,i從lo開始,從左至右開始掃描:
· 如果a[i]<v: 交換a[lt]和a[i],lt和i自增
· 如果a[i]>v:交換a[i]和a[gt], gt自減
· 如果a[i]=v: i自增
下面是使用Dijkstra的三分割槽快速排序程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
private static void Sort(T[] array, int lo, int hi) { //對於小序列,直接採用插入排序替代 if (hi - lo <= CUTTOFF - 1) { Sort<int>.InsertionSort(array, lo, hi); return; } //三分割槽 int lt = lo, i = lo + 1, gt = hi; T v = array[lo]; while (i<=gt) { int cmp = array[i].CompareTo(v); if (cmp < 0) Swap(array, lt++, i++); else if (cmp > 0) Swap(array, i, gt--); else i++; } //對左邊序列進行遞迴排序 Sort(array, lo, lt - 1); //對右邊序列進行遞迴排序 Sort(array, gt + 1, hi); } |
三分割槽快速排序的每一步如下圖所示:
三分割槽快速排序的示意圖如下:
Dijkstra的三分割槽快速排序雖然在快速排序發現不久後就提出來了,但是對於序列中重複值不多的情況下,它比傳統的2分割槽快速排序需要更多的交換次數。
Bentley 和D. McIlroy在普通的三分割槽快速排序的基礎上,對一般的快速排序進行了改進。在劃分過程中,i遇到的與v相等的元素交換到最左邊,j遇到的與v相等的元素交換到最右邊,i與j相遇後再把陣列兩端與v相等的元素交換到中間
這個方法不能完全滿足只掃描一次的要求,但它有兩個好處:首先,如果資料中沒有重複的值,那麼該方法幾乎沒有額外的開銷;其次,如果有重複值,那麼這些重複的值不會參與下一趟排序,減少了無用的劃分。
下面是採用 Bentley&D. McIlroy 三分割槽快速排序的演算法改進:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
private static void Sort(T[] array, int lo, int hi) { //對於小序列,直接採用插入排序替代 if (hi - lo <= CUTTOFF - 1) { Sort<int>.InsertionSort(array, lo, hi); return; } // Bentley-McIlroy 3-way partitioning int i = lo, j = hi + 1; int p = lo, q = hi + 1; T v = array[lo]; while (true) { while (Less(array[++i], v)) if (i == hi) break; while (Less(v, array[--j])) if (j == lo) break; // pointers cross if (i == j && Equal(array[i], v)) Swap(array, ++p, i); if (i >= j) break; Swap(array, i, j); if (Equal(array[i], v)) Swap(array, ++p, i); if (Equal(array[j], v)) Swap(array, --q, j); } //將相等的元素交換到中間 i = j + 1; for (int k = lo; k <= p; k++) Swap(array, k, j--); for (int k = hi; k >= q; k--) Swap(array, k, i++); Sort(array, lo, j); Sort(array, i, hi); } |
三分割槽快速排序的動畫如下:
4.並行化
和前面討論對合並排序的改進一樣,對所有使用分治法解決問題的演算法其實都可以進行並行化,快速排序的並行化改進我在之前的淺談併發與並行這篇文章中已經有過介紹,這裡不再贅述。
五 .NET 中元素排序的內部實現
快速排序作為一種優秀的排序演算法,在很多程式語言的元素內部排序中均有實現,比如Java中對基本資料型別(primitive type)的排序,C++,Matlab,Python,FireFox Javascript等語言中均將快速排序作為其內部元素排序的演算法。同樣.NET中亦是如此。
.NET這種對List<T>陣列元素進行排序是通過呼叫Sort方法實現的,其內部則又是通過Array.Sort實現,MSDN上說在.NET 4.0及之前的版本,Array.Sort採用的是快速排序,然而在.NET 4.5中,則對這一演算法進行了改進,採用了名為Introspective sort 的演算法,即保證在一般情況下達到最快排序速度,又能保證能夠在出現最差情況是進行優化。他其實是一種混合演算法:
- 當待分割槽的元素個數小於16個時,採用插入排序
- 當分割槽次數超過2*logN,N是輸入陣列的區間大小,則使用堆排序(Heapsort)
- 否則,使用快速排序。
有了Reflector這一神器,我們可以檢視.NET中的ArraySort的具體實現:
Array.Sort這一方法在mscorlib這一程式集中,具體的實現方法有分別針對泛型和普通型別的SortedGenericArray和SortedObjectArray,裡面的實現大同小異,我們以SortedGenericArray這個類來作為例子看:
首先要看的是Sort方法,其實現如下:
該方法中,首先判斷執行的.NET對的版本,如果是4.5及以上版本,則用IntrospectiveSort演算法,否則採用限定深度的快速排序演算法DepthLimitedQuickSort。先看IntrospectiveSort:
該方法第一個元素為陣列的最左邊元素位置,第二個引數為最右邊元素位置,第三個引數為2*log2N,繼續看方法內部:
可以看到,當num<=16時,如果元素個數為1,2,3,則直接呼叫SwapIfGreaterWithItem進行排序了。否則直接呼叫InsertSort進行插入排序。
這裡面也是一個迴圈,每迴圈一下depthLimit就減小1個,如果為0表示劃分的次數超過了2logN,則直接呼叫基排序(HeapSort),這裡面的劃分方法PickPivortAndPartitin的實現如下:
它其實是一個標準的三平均快速排序。可以看到在.NET 4.5中對Quick進行優化的部分主要是在元素個數比較少的時候採用選擇插入,並且在遞迴深度超過2logN的時候,採用基排序。
下面再來看下在.NET 4.0及以下平臺下排序DepthLimitedQuickSort方法的實現:
從名稱中可以看出這是限定深度的快速排序,在第三個引數傳進去的是0x20,也就是32。
可以看到,當劃分的次數大於固定的32次的時候,採用了基排序,其他的部分是普通的快速排序。
六 總結
由於快速排序在排序演算法中具有排序速度快,而且是就地排序等優點,使得在許多程式語言的內部元素排序實現中採用的就是快速排序,本問首先介紹了一般的快速排序,分析了快速排序的時間複雜度,然後就分析了對快速排序的幾點改進,包括對小序列採用插入排序替代,三平均劃分,三分割槽劃分等改進方法。最後介紹了.NET不同版本下的對元素內部排序的實現。
快速排序很重要,希望本文對您瞭解快速排序有所幫助。