1. 程式人生 > >快速排序詳細分析

快速排序詳細分析

看了程式設計珠璣Programming Perls第11章關於快速排序的討論,發現自己長年用庫函式,已經忘了快排怎麼寫。於是整理下思路和資料,把至今所瞭解的快排的方方面面記錄與此。

綱要

  1. 演算法描述
  2. 時間複雜度分析
  3. 具體實現細節
    1. 劃分
      1. 選取樞紐元
        1. 固定位置
        2. 隨機選取
        3. 三數取中
      2. 分割
        1. 單向掃描
        2. 雙向掃描
        3. Hoare的雙向掃描
        4. 改進的雙向掃描
        5. 雙向掃描的其他問題
    2. 分治
      1. 尾遞迴
  4. 參考文獻

一、演算法描述(Algorithm Description)

快速排序由C.A.R.Hoare於1962年提出,演算法相當簡單精煉,基本策略是隨機分治。
首先選取一個樞紐元(pivot),然後將資料劃分成左右兩部分,左邊的大於(或等於)樞紐元,右邊的小於(或等於樞紐元),最後遞迴處理左右兩部分。
分治演算法一般分成三個部分:分解、解決以及合併。快排是就地排序,所以就不需要合併了。只需要劃分(partition)和解決(遞迴)兩個步驟。因為劃分的結果決定遞迴的位置,所以Partition是整個演算法的核心。

對陣列S排序的形式化的描述如下(REF[1]):

  1. 如果S中的元素個數是0或1,則返回
  2. 取S中任意一元素v,稱之為樞紐元
  3. 將S-{v}(S中其餘元素),劃分成兩個不相交的集合:S1={x∈S-{v}|x<=v} 和 S2={x∈S-{v}|x>=v}
  4. 返回{quicksort(S1) , v , quicksort(S2)}

二、時間複雜度分析(Time Complexity)

快速排序最佳執行時間O(nlogn),最壞執行時間O(N^2),隨機化以後期望執行時間O(nlogn),關於這些任何一本演算法資料結構書上都有證明,就不寫在這了,一下兩點很重要:

  1. 選取樞紐元的不同, 決定了快排演算法時間複雜度的數量級;
  2. 劃分方法的劃分方法總是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)。

三、參考文獻:

[1]Mark Allen Weiss.Data Structures and Algorithms Analysis in C++. Pearson Education, Third Edition, 2006.[2]Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein.Introduction to Algorithms. MIT Press, 2001, Second Edtion, 2001. [3]Jon Bently.Programming Pearls. Addison Wesley, Second Edition, 2000.