快速排序詳細分析
看了程式設計珠璣Programming Perls第11章關於快速排序的討論,發現自己長年用庫函式,已經忘了快排怎麼寫。於是整理下思路和資料,把至今所瞭解的快排的方方面面記錄與此。
綱要
- 演算法描述
- 時間複雜度分析
- 具體實現細節
- 劃分
- 選取樞紐元
- 固定位置
- 隨機選取
- 三數取中
- 分割
- 單向掃描
- 雙向掃描
- Hoare的雙向掃描
- 改進的雙向掃描
- 雙向掃描的其他問題
- 選取樞紐元
- 分治
- 尾遞迴
- 劃分
- 參考文獻
一、演算法描述(Algorithm Description)
快速排序由C.A.R.Hoare於1962年提出,演算法相當簡單精煉,基本策略是隨機分治。
首先選取一個樞紐元(pivot),然後將資料劃分成左右兩部分,左邊的大於(或等於)樞紐元,右邊的小於(或等於樞紐元),最後遞迴處理左右兩部分。
分治演算法一般分成三個部分:分解、解決以及合併。快排是就地排序,所以就不需要合併了。只需要劃分(partition)和解決(遞迴)兩個步驟。因為劃分的結果決定遞迴的位置,所以Partition是整個演算法的核心。
對陣列S排序的形式化的描述如下(REF[1]):
- 如果S中的元素個數是0或1,則返回
- 取S中任意一元素v,稱之為樞紐元
- 將S-{v}(S中其餘元素),劃分成兩個不相交的集合:S1={x∈S-{v}|x<=v} 和 S2={x∈S-{v}|x>=v}
- 返回{quicksort(S1) , v , quicksort(S2)}
二、時間複雜度分析(Time Complexity)
快速排序最佳執行時間O(nlogn),最壞執行時間O(N^2),隨機化以後期望執行時間O(nlogn),關於這些任何一本演算法資料結構書上都有證明,就不寫在這了,一下兩點很重要:
- 選取樞紐元的不同, 決定了快排演算法時間複雜度的數量級;
- 劃分方法的劃分方法總是O(n), 所以其具體實現的不同隻影響演算法時間複雜度的係數。
所以訴時間複雜度的分析都是圍繞樞紐元的位置展開討論的。
三、具體實現細節(Details of Implementaion)
1、劃分(Partirion)
為了方便討論,將Partition從QuickSort函式裡提出來,就像演算法導論裡一樣。實際實現時我更傾向於合併在一起,就一個函式,減少了函式呼叫次數。(REF[2])
void QuickSort(T A[], int p, int q) { if (p < q) { int q = Partition(A, p, q); QuickSort(A, p, q-1); QuickSort(A, q+1, r); } }
劃分又分成兩個步驟:選取樞紐元和按樞紐元將陣列分成左右兩部分
a.選取樞紐元(Pivot Selection)
固定位置
同樣是為了方便,將選取樞紐元單獨提出來成一個函式:select_pivot(T A[], int p, int q),該函式從A[p...q]中選取一個樞紐元並返回,且樞紐元放置在左端(A[p]的位置)。
對於完全隨機的資料,樞紐元的選取不是很重要,往往直接取左端的元素作為樞紐元。
int select_pivot(T A[], int p, int q)
{
return A[p];
}
但是實際應用中,資料往往是部分有序的,如果仍用兩端的元素最為樞紐元,則會產生很不好的劃分,使演算法退化成O(n^2)。所以要採用一些手段避免這種情況,我知道的有“隨機選取法”和“三數取中法”。
隨機選取
顧名思義就是從A[p...q]中隨機選擇一個樞紐元,這個用庫函式可以很容易實現
int select_pivot_random(T A[], int p, int q)
{
int i = randInt(p, q);
swap(A[p], A[i]);
return A[p];
}
其中randInt(p, q)隨機返回[p, q]中的一個數,C/C++裡可由stdlib.h中的rand函式模擬。
三數取中
即取三個元素的中間數作為樞紐元,一般是取左端、右斷和中間三個數,也可以隨機選取。(REF[1])
int select_pivot_median3(T A[], int p, int q)
{
int m = (p + q)/2;
/* swap to ensure A[m] <= A[p] <= A[q] */
if (A[p] < A[m]) swap(A[p], A[m]);
if (A[q] < A[m]) swap(A[q], A[m]);
if (A[q] < A[p]) swap(A[q], A[p]);
return A[p];
}
b.按樞紐元將陣列分成左右兩部分
雖然說分割方法隻影響演算法時間複雜度的係數,但是一個好係數也是比較重要的。這也就是為什麼實際應用中寧願選擇可能退化成O(n^2)的快速排序,也不用穩定的堆排序(堆排序交換次數太多,導致係數很大)。
常見的分割方法有三種:
單向掃描
單向掃描程式碼非常簡單,只有短短的幾行,思路也比較清晰。該演算法由N.Lomuto提出,演算法導論上也採用了這種演算法。對於陣列A[p...q],該演算法用一個迴圈掃描整個區間,並維護一個標誌m,使得迴圈不變數(loop invariant)A[p+1...m] < A[p] && A[m+1, i-1] >= x[l]始終成立。(REF[2],REF[3])
int partition(T A[], int p, int q)
{
int x = select_pivot(A, p, q);
int m = p, j;
for (int j = p+1; j <= q; ++j)
/* invariant : A[p+1...m] < A[p] && A[m+1, i-1] >= x[q] */
if (A[j] <= x)
swap(A[++m], j)
swap(A[p], A[m]);
/* A[p...m-1] < A[m] <= A[m+1...u] */
return m;
}
順便廢話幾句,在看國外的書的時候,發現老外在分析和測試演算法尤其是迴圈時,非常重視不變數(invariant)的使用。確立一個不變數,在迴圈開始之前和結束之後檢查這個不變數,是一個很好的保持演算法正確性的手段。
事實上第一種演算法需要的交換次數比較多,而且如果採用選取左端元素作為樞紐元的方法,該演算法在輸入陣列中元素全部相同時退化成O(n^2)。第二種方法可以避免這個問題。
雙向掃描
雙向掃描用兩個標誌i、j,分別初始化成陣列的兩端。主迴圈裡巢狀兩個內迴圈:第一個內迴圈i從左向右移過小於樞紐元的元素,遇到大元素時停止;第二個迴圈j從右向左移過大於樞紐元的元素,遇到小元素時停止。然後主迴圈檢查i、j是否相交併交換A[i]、A[j]。
int partition(T A[], int p, int q)
{
int x = select_pivot(A, p, q);
int i = p, j = q + 1;
for ( ; ; )
{
do ++i; while (i <= q && A[i] < x);
do --j; while (A[j] > x);
if (i > j) break;
swap(A[i], A[j]);
}
swap(A[p], A[j]);
return j;
}
雙向掃描可以正常處理所有元素相同的情況,而且交換次數比單向掃描要少。
Hoare的雙向掃描
這種方法是Hoare在62年最初提出快速排序採用的方法,與前面的雙向掃描基本相同,但是更難理解,手算了幾組資料才搞明白:(REF[2])
int partition(T A[], int p, int q)
{
int x = select_pivot(A, p, q);
int i = p - 1, j = q + 1;
for ( ; ; )
{
do --j; while (A[j] > x);
do ++i; while (A[i] < x);
if (i < j) swap(A[i], A[j])
else return j;
}
}
需要注意的是,返回值j並不是樞紐元的位置,但是仍然保證了A[p..j] <= A[j+1...q]。這種方法在效率上於雙向掃描差別甚微,只是程式碼相對更為緊湊,並且用A[p]做哨兵元素減少了內層迴圈的一個if測試。
http://www.see2say.com/channel/music/player.aspx?v_album_id=9804
改進的雙向掃描
樞紐元儲存在一個臨時變數中,這樣左端的位置可視為空閒。j從右向左掃描,直到A[j]小於等於樞紐元,檢查i、j是否相交併將A[j]賦給空閒位置A[i],這時A[j]變成空閒位置;i從左向右掃描,直到A[i]大於等於樞紐元,檢查i、j是否相交併將A[i]賦給空閒位置A[j],然後A[i]變成空閒位置。重複上述過程,最後直到i、j相交跳出迴圈。最後把樞紐元放到空閒位置上。
int partition(T A[], int p, int q)
{
int x = select_pivot(A, p, q);
int i = p, j = q;
for ( ; ; )
{
while (i < j && A[j] > x) --j;
A[i] = A[j];
while (i < j && A[i] < x) ++i;
A[j] = A[i];
}
A[i] = x; // i == j
return i;
}
這種類似迭代的方法,每次只需一次賦值,減少了記憶體讀寫次數,而前面幾種的方法一次交換需要三次賦值操作。由於沒有哨兵元素,不得不在內層迴圈裡判斷i、j是否相交,實際上反而增加了很多記憶體讀取操作。但是由於迴圈計數器往往被放在暫存器了,而如果待排陣列很大,訪問其元素會頻繁的cache miss,所以用計數器的訪問次數換取待排陣列的訪存是值得的。
關於雙向掃描的幾個問題
1.內層迴圈中的while測試是用“嚴格大於/小於”還是”大於等於/小於等於”。
一般的想法是用大於等於/小於等於,忽略與樞紐元相同的元素,這樣可以減少不必要的交換,因為這些元素無論放在哪一邊都是一樣的。但是如果遇到所有元素都一樣的情況,這種方法每次都會產生最壞的劃分,也就是一邊1個元素,令一邊n-1個元素,使得時間複雜度變成O(N^2)。而如果用嚴格大於/小於,雖然兩邊指標每此只挪動1位,但是它們會在正中間相遇,產生一個最好的劃分,時間複雜度為log(2,n)。
另一個因素是,如果將樞紐元放在陣列兩端,用嚴格大於/小於就可以將樞紐元作為一個哨兵元素,從而減少內層迴圈的一個測試。
由以上兩點,內層迴圈中的while測試一般用“嚴格大於/小於”。
2.對於小陣列特殊處理
按照上面的方法,遞迴會持續到分割槽只有一個元素。而事實上,當分割到一定大小後,繼續分割的效率比插入排序要差。由統計方法得到的數值是50左右(REF[3]),也有采用20的(REF[1]), 這樣原先的QuickSort就可以寫成這樣
void QuickSort(T A[], int p, int q)
{
if (q - p > cutoff) //cutoff is constant
{
int q = Partition(A, p, q);
QuickSort(A, p, q-1);
QuickSort(A, q+1, r);
}
else
InsertionSort(T A[], p, q); //user insertion sort for small arrays
}
二、分治
分治這裡看起來沒什麼可說的,就是一樞紐元為中心,左右遞迴,實際上也有一些技巧。
1.尾遞迴(Tail recursion)
快排演算法和大多數分治排序演算法一樣,都有兩次遞迴呼叫。但是快排與歸併排序不同,歸併的遞迴則在函式一開始, 快排的遞迴在函式尾部,這就使得快排程式碼可以實施尾遞迴優化。第一次遞迴以後,變數p就沒有用處了, 也就是說第二次遞迴可以用迭代控制結構代替。雖然這種優化一般是有編譯器實施,但是也可以人為的模擬:
void QuickSort(T A[], int p, int q)
{
while (p < q)
{
int m = Partition(A, p, q);
QuickSort(A, p, m-1);
p = m + 1;
}
}
採用這種方法可以縮減堆疊深度,由原來的O(n)縮減為O(logn)。