1. 程式人生 > >快速排序的幾個實現及其在效率上的考慮

快速排序的幾個實現及其在效率上的考慮

快速排序,Quicksort,通常被認為是在基於比較的排序中,對於大型的,隨機的輸入具有最快的排序速度的程式。C標準庫中的qsort,c++ STL中的sort都是基於快速排序的(STL中的sort實現還包含了插入排序和堆排序)。

但是,Quicksort也很可能陷入最壞情況的時間複雜度O(n^2),這種情況往往是發生在把第一個或者最後一個元素作為樞紐然而陣列卻是接近有序或者完全有序的時候,此時分割將非常不平衡。在完全有序的狀況下,快排的每一次分割都等於無效分割,即樞紐元一邊為待分割陣列的所有元素-1,另一邊沒有元素的情況。對於接近有序的狀況,雖然初始分割不一定出現一邊是全部一邊沒有元素的狀況,但是遞迴進入子陣列時,就會出現子陣列是完全有序的情況,導致快排的退化至O(n^2)。但是,如果保證每次分割至少是1/10和9/10,那麼這種情況下時間複雜度仍然是O(nlogn)。

為了避免這種退化情況,快排做了一些的改進。這篇博文講述其中的一些變種。

零、本文的所用的比較函式、交換函式

快排是基於比較的排序,故為了適應各個不同型別的數值比較,以及確定升序還是降序或者類似於字串的字典序,需要一個比較函式。

對於整型陣列的升序排序可以使用以下的比較函式:

int cmp(int *i, int *j) 
{ return *i - *j; }
對於字串可以用C標準庫的strcmp。

另外,需要一個型別無關的交換函式,在C++中已經有swap實現了,在C語言中,還是得自己來。如下:

void swap(char *i, char *j, int n)
{
    do {
        char c = *i;
        *i++ = *j;
        *j++ = c;
    } while (--n > 0);
}
其中,i,j分別是指向待交換元素的指標,n是待交換元素的大小(類似於sizeof(int)得到的數值)。

一、從最基本的快排說起

首先,考慮對整型陣列進行升序排序。以下是一個非常短但能實現出快排的程式。演算法導論上的快排基礎版也是用著類似的思路。但是演算法導論上是用陣列的最後一個元素作為快排的樞紐元的,而這裡是採用第一個元素作為樞紐元。

void iqsort0(int *a, int n)
{
    int i, j;
    if (n <= 1) return;
    for (i = 1, j = 0; i < n; i++)
        if (a[i] < a[0])
            swap(++j, i, a);
        swap(0, j, a);
    iqsort0(a, j);
    iqsort0(a+j+1, n-j-1);
}

這段程式的迴圈不變式是

|P|      <P        |       >=P        |           ?         |

 0                   j                          i                 n-1

終止情況是

|P|           <P                  |           >=P            |

 0                                  j                              i

                                                                  n-1

結合程式與迴圈不變式來看,首先選陣列第一個元素作為樞紐元。i為遍歷整個陣列的指標,同時也是未訪問元素集合與大於樞紐元的元素集合的分割點,在每輪遍歷開始,i指向是在未分割元素一邊。j為小於樞紐元的元素集合與大於樞紐元的元素集合的分割點,指向在小於樞紐元的元素集合一邊。即1 -小於樞紐元- j -大於等於樞紐元- i -未訪問元素 - n-1這樣排列。

在每輪迴圈之前,i指向待處理的元素。(1)如果待處理的元素小於樞紐元,則先把j移向j+1處,此元素與(新的)j處的元素交換,i移向i+1處。(2)如果待處理的元素大小或者等於樞紐元,則直接把i移向i+1處。

(1)(2)兩者在每輪迴圈結束後,仍保持著迴圈不變式。在終止時,所有元素都都被訪問,i指向n-1,j指向小於樞紐元的元素,j+1指向大於樞紐元的元素。把樞紐元(第一個元素)和j指向的元素交換。則0到j-1的元素為小於樞紐元的,j+1到n-1的元素為大於等於樞紐元的。然後分別對這兩部分元素進行快排。

這個版本,對於陣列按從左到右的順序掃描,是比較容易理解和記憶的。

二、Hoare的原始版本和Sedgewick博士論文的版本

C.A.R. Hoare提出的最原始的快排,以及R. Sedgewick在他的博士論文裡的快排版本,其迴圈不變式使用了兩個索引是由兩頭向中間靠攏,直至兩者交錯為迴圈終止,這個版本是之後的基礎。但是這個版本也更容易在程式設計上出錯。程式碼如下:

void iqsort1(int *a, int n)
{
    int i, j;
    if (n <= 1) return;
    i = 0;
    j = n;
    for (;;) {
        do i++; while (i < n && a[i] < a[0]);
        do j--; while (a[j] > a[0]);
        if (j < i) break;
        swap(i, j, a);
    }
    swap(0, j, a);
    iqsort1(a, j);
    iqsort1(a+j+1, n-j-1);
}
迴圈不變式如下:

|P|     <=P       |     ?     |   >=P           |

  0                     i         j                     n-1

終止情況:

|     <=P                  | P|   >=P             |

                                  j    i

初始情況:

i在0處,j在n處(超出陣列末尾一位)。

迴圈不變式:

i左邊的元素都小於等於樞紐元,j右邊的元素都大於等於樞紐元。

i向右移,直至遇到大於樞紐元的元素;j向左移,直至遇到小於樞紐元的元素。交換上述兩個元素。

這輪迴圈過後,i左邊的元素仍然是小於等於樞紐元,j右邊的元素仍然是大於等於樞紐元。

還有一種情況,就是樞紐元大於等於整個陣列的元素,那麼i移至

終止:

i,j交錯的時候,整個陣列由i,j分割為小於等於和大於等於樞紐元的兩部分。最後把樞紐元和j指向的元素交換。

然後送入下一次快排的遞迴。

和第一個程式(或者是CLRS)上的快速排序有一些不同的是,Hoare和Sedgewick的迴圈不變式分割完成後的左右兩個子陣列都包含了等於樞紐元的元素,而CLRS的版本的快排中,所有等於樞紐元的元素在分割完成後都放在了右邊(大於等於)的子陣列中。

三、樞紐元的選取

上面兩個程式,在選取樞紐元的時候,都是選取陣列的第一個或者最後一個元素作為樞紐元的,但是如果陣列本身是有序的時候,這樣的話,對於這個陣列的排序就會退化為O(N^2)。這在一個大陣列的子陣列中可能會很常見。又如之前分析,只要樞紐元能把陣列分為兩個部分,那麼快排的複雜仍然是O(NlogN)。因此,現在的問題是避免選取到最壞的情形。

一個簡單的考慮就是用隨機數。即在上面程式的第4行和第5行中加入以下程式碼:

i = rand() % n;
swap(0, i, a);

用隨機數來選取樞紐元的位置使得多次出現樞紐元出現在均大於或者小於整個陣列的情況的可能性微乎其微。但是,獲取隨機數本身是一個時間代價很高的操作。於是這種方法只有理論上的可能但並不現實。

從另一個角度來看,樞紐元的選取最好的情況是陣列的中位數。但在整個陣列排序出來之前,中位數是不可知的。退而求次,我們取三個數的中位數,陣列的第一個、中間、最後一個數的中位數。這樣來避免最壞情況。

首先我們需要一個三數取中的程式。

static char *med3(char *a, char *b, char *c, int (*cmp)())
{ 
    return cmp(a, b) < 0 ?
            (cmp(b, c) < 0 ? b : cmp(a, c) < 0 ? c : a)
            : (cmp(b, c) > 0 ? b : cmp(a, c) > 0 ? c : a);
}

其中cmp可以根據不同資料型別而定義不同的的具體實現。

對於更大的陣列,可以通過遞迴地使用三數取中(兩次三數取中)來取樞紐元。

如下:

pm = a + (n/2); /* Small arrays, middle element */
if (n > 7) {
    pl = a;
    pn = a + (n-1);
    if (n > 40) { /* Big arrays, pseudomedian of 9 */
        s = n/8;
        pl = med3(pl, pl+s, pl+2*s, cmp);    
        pm = med3(pm-s, pm, pm+s, cmp);
        pn = med3(pn-2*s, pn-s, pn, cmp);
    }
    pm = med3(pl, pm, pn, cmp); /* Mid-size, med of 3 */
}
即在陣列元素個數小於等於7時,直接選取陣列中間的元素作為樞紐元。

在陣列8到40個元素時,用三數取中法。

在陣列多於40個元素時,用兩次三數取中法。

四、型別無關的排序

上面的例子基本以整型升序作為排序,現在把第二段中的程式擴充套件為型別無關的情況。也就是類似於c標準庫裡的qsort的呼叫方法。

void qsort1(char *a, int n, int es, int (*cmp)())
{
    int j;
    char *pi, *pj, *pn;
    if (n <= 1) return;
    pi = a;
    pj = pn = a + n * es;
    for (;;) {
        do pi += es; while (pi < pn && cmp(pi, a) < 0);
        do pj -= es; while (cmp(pj, a) > 0);
        if (pj < pi) break;
        swap(pi, pj, es);
    }
    swap(a, pj, es);
    j = (pj - a) / es;
    qsort1(a, j, es, cmp);
    qsort1(a + (j+1)*es, n-j-1, es, cmp);
}

其中es是步長,接收類似於sizeof(int)這樣形式的引數。

這個程式用於替換三中的程式的分割和遞迴部分,這樣就可以達到型別無關,升降序自定,且對不同大小的陣列可以用不同的方法選取樞紐元了。

五、總結

這裡只是對快速排序本身進行一些改進。

閱讀C++STL的原始碼,發現STL裡的sort是結合了快速排序、堆排序和插入排序的,優化已經用至極致。所以STL裡對於避免sort退化至O(N^2)已經是做了非常多的改進的,而不是僅僅對快排本身。有興趣還是去看看STL原始碼來得好。

————————————————————————————————————————————————————————

參考文獻

J. L. Bentley, Engineering a Sort function, SOFTWARE-PRACTICE AND EXPERIENCE, VOL.23(11),1249-1265(NOVEMBER 1993)

Mark allen Weiss, 資料結構與演算法分析(C語言描述)

Thomas H.Cormen, Charles E.Leiserson, Ronald L.Rivest, Clifford Stein, 演算法導論