1. 程式人生 > 程式設計 >基礎排序演演算法

基礎排序演演算法

1. 基於比較的排序(comparison-based sorting)

使用比較運運算元"<"和">",將相容的序放到輸入中,且除了賦值運運算元外,這兩種運算是僅有的允許對輸入資料進行的操作,在這些條件下的排序叫做“基於比較的排序”。本文介紹的除了桶式排序都是基於比較的排序。

2. 插入排序(insertion sort)

最簡單的排序演演算法之一是插入排序。插入排序由 N-1 趟(pass)排序組成。對於 P=1 趟到 P=N-1 趟,插入排序保證從位置 0 到位置 P 上的元素為已排序狀態。

void
InsertionSort(ElementType A[],int N)
{
    int j,P;

    ElementType Tmp;
    for
(P = 1; P < N; P++) { Tmp = A[P]; for (j = P; j > 0 && A[j - 1] > Tmp; j--) A[j] = A[j - 1]; A[j] = Tmp; } } 複製程式碼

執行時間上界為
\sum_{i=1}^{N-1}i = 1,2,\ldots,N-1 = \frac{1+N-1}{2} \times (N-1) = \frac{N^2}{2} - \frac{N}{2} ,去掉常係數項和低階項,時間複雜度為 O(N^2)

3. 一些簡單排序演演算法的下界

逆序(inversion)

成員存數的陣列的一個逆序是指陣列中具有性質i < jA[i] > A[j]的序偶(A[i],A[j])。例如陣列A=[34,8,64,51,32,21]有9個逆序,即(34,8)(34,32)

(34,21)(64,51)(64,32)(64,21)(51,32)(51,21)以及(32,21)。這正好是由插入排序執行的交換次數。情況總是這樣,因為交換兩個不按原序排列的相鄰元素恰好消除一個逆序,而一個排過序的陣列沒有逆序。

假設不存在重複元素,輸入資料是前N個整數的某個排列,並設所有的排列都是等可能的。有如下定理:

定理1

N個互異數的陣列的平均序數是N(N-1)/4

定理2

通過交換相鄰元素進行排序的任何演演算法平均需要\Omega(N^2)時間。

4. 希爾排序(shellsort)

希爾排序的名稱來源於它的發明者Donald Shell。該演演算法是衝破二次時間屏障的第一批演演算法之一,不過,自從它最初被發現,又過了若干年後才證明瞭它的亞二次時間界。它通過比較相距一定間隔的元素來工作;各趟比較所用的距離隨著演演算法的進行而減小,直到只比較相鄰元素的最後一趟排序為止。由於這個原因,希爾排序有時也叫縮小增量排序

(diminishing increment sort)。

希爾排序使用一個序列h_1,h_2,h_t,叫做增量序列(increment sequence)。只要h_1=1,任何增量序列都是可行的,不過有些增量序列比另外一些增量序列更好。

對於h_k-排序的一般做法是,對於h_k,h_{k+1},...,N-1中的每一個位置i,把其上的元素放到i,i-h_k,i-2h_k \ldots中間的正確位置上。

增量序列的一種流行(但是不好)的選擇是使用Shell建議的序列:h_t = \lfloor N / 2 \rfloorh_k = \lfloor h_{k_1}/2\rfloor。如下為使用這種增量序列的實現程式碼實現。

void
Shellsort(ElementType A[],int N)
{
    int i,j,Increment;
    ElementType Tmp;

    for (Increment = N/2; Increment > 0; Increment /=2)
        for (i = Increment; i < N; i++)
        {
            Tmp = A[i];
            for(j = i; j > Increment; j -= Increment)
                if(Tmp < A[j - Increment])
                    A[j] = A[j - Increment];
                else
                    break;
            A[j] = Tmp;
        }
}
複製程式碼

5. 堆排序(heapsort)

我們知道,優先佇列(堆)可以用於花費O(NlogN)時間的排序。基於該想法的演演算法叫做堆排序(heapsort)並給出我們至今所見到的最佳的大O執行時間。然而,在實踐中它卻慢於使用Sedgewick增量序列的希爾排序。

建立N個元素的二叉堆花費O(N)時間,然後執行NDeleteMin操作。按照順序,最小的元素先離開堆。通過將這些元素記錄到第二個陣列然後再將陣列拷貝回來,我們得到N個元素的排序。由於每個DeleteMin花費時間O(log N),因此總的執行時間是O(NlogN)。使用這樣的策略,在最後一次DeleteMin後,該陣列將以遞減的順序包含這些元素。如果想要這些元素排成更典型的遞增順序,那麼我們可以改變序的特性使得父親的關鍵字的值大於兒子的關鍵字的值。這樣就得到最大堆(max heap)

#define LeftChild(i) (2* (i) + 1)

void
PercDown(ElementType A[],int i,int N)
{
    int Child;
    ElementType Tmp;
    
    for(Tmp = A[i]; LeftChild(i) < N; i = Child)
    {
        Child = LeftChild(i);
        if(Child != N-1 && A[Child + 1] > A[Child])
            Child++;
        if(Tmp < A[Child])
            A[i] = A[Child];
        else
            break;
    }
    A[i] = Tmp;
}

void
Heapsort(ElementType A[],int N)
{
    int i;

    for(i = N/2; i >= 0; i--)   /* BuildHeap */
        PercDown(A,i,N);
    for(i = N-1; i > 0; i--)
    {
        Swap(&A[0],&A[i]);     /* DeleteMax */
        PercDown(A,0,i);
    }
}
複製程式碼

可以證明,堆排序總是使用至少NlogN - O(N)次比較,而且存在輸入資料能夠達到這個界。

6. 歸併排序(mergesort)

歸併排序以O(NlogN)最壞情形執行時間執行,而所使用的比較次數幾乎是最優的。它是遞迴演演算法一個很好的例項。

這個演演算法中基本的操作是合併兩個已排序的表。因為這兩個表是已經排序的。所以若將輸出放到第三個表中時則該演演算法可以通過對輸入資料一趟排序來完成。基本的合併演演算法是取兩個輸入陣列AB,一個輸出陣列C,以及三個計數器Aptr,Bptr,Cptr,它們初始置於對應陣列的開始端。A[Aptr]B[Bptr] 中的較小者被拷貝到C中的下一個位置,相關的計數器向前推進一步。當輸入表有一個用完的時候,則將另一個表中剩餘部分拷貝到C中。

合併兩個已排序的表的時間顯然是線性的,因為最多進行了N-1次比較,其中N是元素的總數。為了看清這一點,注意每次比較都是把一個元素加到C中,但最後的比較除外,它至少新增兩個元素。

/* Lpos = start of left half,Rpos = start of right half */
void
Merge(ElementType A[],ElementType TmpArray[],int Lpos,int Rpos,int RightEnd)
{
    int i,LeftEnd,NumElements,TmpPos;

    LeftEnd = Rpos - 1;
    TmpPos = Lpos;
    NumElements = RightEnd - Lpos + 1;

    /* main loop */
    while(Lpos <= LefEnd && Rpos <= RightEnd)
        if (A[Lpos] <= A[Rpos])
            TmpArray[TmpPos++] = A[Lpos++];
        else
            TmpArray[TmpPos++] = A[Rpos++];
    while(Lpos <= LeftEnd)
        TmpArray[TmpPos++] = A[Lpos++];
    while(Rpos <= RightEnd)
        TmpArray[TmpPos++] = A[Rpos++];

    /* copy TmpArray back */
    for (i = 0; i < NumElements; i++,RightEnd--)
        A[RightEnd] = TmpArray[RightEnd];
}

void
MSort(ElementType A[],int Left,int Right)
{
    int Center;

    if (Left < Right)
    {
        Center = (Left + Right) / 2;
        MSort(A,TmpArray,Left,Center);
        Msort(A,Center + 1,Right);
        Merge(A,Right);
    }
}

void
Mergesort(ElementType A[],int N)
{
    ElementType *TmpArray;

    TmpArray = malloc(N * sizeof(ElementType));
    if (TmpArray != NULL)
    {
        MSort(A,N-1);
        free(TmpArray);
    }
    else
        FatalError("No space for tmp array!!!");
}
複製程式碼

7. 快速排序(quicksort)

正如它的名字所標示的,快速排序是在實踐中最快的已知排序演演算法,它的平均執行時間是O(NlogN)。該演演算法之所以特別快,主要是由於非常精煉和高度優化的內部迴圈。它的最壞情形的效能為 O(N^2) ,但稍加努力就可以避免這種情形。雖然多年來快速排序演演算法被認為是理論上高度優化而在實踐中卻不可能正確程式設計的一種演演算法,但是如今該演演算法簡單易懂而且不難證明。像歸併排序一樣,快速排序也是一種分治的遞迴演演算法。

將陣列S排序的基本演演算法由下列簡單的四步組成:

  1. 如果S中 元素個數是0或者1,則返回。
  2. S中任一元素v,稱之為樞紐元(pivot)。
  3. S - \{v\}S中其餘元素)分成兩個不相交的集合:S_1 = \{x \in S - \{v\} | x \le v\}S_2 = \{ x \in S - \{v\} | x \ge v \}
  4. 返回\{ quicksort(S_1)後,繼隨 v,繼而 quicksort(S_2) \}

由於對那些等於樞紐元的處理,第(3)步分割的描述不是唯一的,因此這就成了一個設計上的決策。一部分好的實現是將這種情形儘可能有效地處理。直觀地看,我們希望把等於樞紐元的大約一半的關鍵字分到S_1中,而另外的一半分到S_2中,很像我們希望二叉查詢樹保持平衡一樣。

7.1 選取樞紐元

三數中值分割法(Median-of-Three Partitioning)。一組N個數的中值是第\lceil N / 2 \rceil個最大的數。樞紐元最好的選擇是陣列的中值。不幸的是,這很難算出,且明顯減慢快速排序的速度。這樣的中值的估計量可以通過隨機選取三個元素並用它們的中值作為樞紐元得到。事實上,隨機性並沒有多大的幫助,因此一般的做法是使用左端,右端和中心位置上的三個元素的中值作為樞紐元。例如,輸入為 8,1,4,9,6,3,5,7,0,它的左邊元素是8,右邊元素是0,中心位置\lfloor (Left + Right)/2 \rfloor = \lfloor (0 + 9)/2 \rfloor = 4 上的元素是6。於是樞紐元則是v=6

7.2 分割策略

有幾種分割策略用於實踐,這裡介紹一種比較好的(但它依然很容易做錯或產生低效)。該方法:

  1. 將樞紐元與最後的元素交換使得樞紐元離開要被分割的資料段。
  2. i從第一個元素開始,j從倒數第二個元素開始。
  3. 分割階段要做的就是把所有小元素移到陣列的左邊而把所有大元素移到陣列的右邊,小和大是相對於樞紐元而言的。
  4. ij的左邊時,將i右移,移過那些小於樞紐元的元素,並將j左移,移過那些大於樞紐元的元素。
  5. ij停止時,i指向一個大元素而j指向一個小元素。如果ij的左邊,那麼將這兩個元素互換(結果是小的元素左移,大的元素右移)。
  6. 重複(4)和(5),直到ij彼此交錯為止。此時,ij已經交錯,故不再互換。
  7. 分割的最後一步是將樞紐元i所指向的元素交換。

等於樞紐元的關鍵字處理:如果ij遇到等於樞紐元的關鍵字,那麼我們就讓ij都停止。對於這種輸入,這實際上是不花費二次時間的四種可能性中惟一的一種可能。

7.3 小陣列場景

對於很小的陣列(N\le20),快速排序不如插入排序好。不僅如此,因為快速排序是遞迴的,所以這樣的情形還經常發生。通常的解決方法是對於小的陣列不遞迴地使用快速排序,而代之以諸如插入排序這樣的對小陣列有效的排序演演算法。使用這樣的策略實際上可以節省大約\%15(相對於自始至終使用快速排序)的執行時間。

7.4 快速排序示例

void
Quicksort(ElementType A[],int N)
{
    Qsort(A,O,N - 1);
}

ElementType
Median3(ElementType A[],int Right)
{
    int Center = (Left + Right) / 2;

    if(A[Left] > A[Center])
        Swap(&A[Left],&A[Center]);
    if(A[Left] > A[Right])
        Swap(&A[Left],&A[Right]);
    if(A[Center] > A[Right])
        Swap(&A[Center],&A[Right]);

    /* Invariant: A[Left] <= A[Center] <= A[Right] */
    Swap(&A[Center],&A[Right - 1]);    /* Hide pivot */
    return A[Right - 1];                /* Return pivot */
}

#define Cutoff (3)

void
Qsort(ElementType A[],int Right)
{
    int i,j;

    ElementType Pivot;

    if (Left + Cutoff <= Right)
    {
        Pivot = Median3(A,Right);
        i = Left; j = Right - 1;
        for (;;)
        {
            while(A[++i] < Pivot) {}
            while(A[--j] > Pivot) {}
            if (i < j)
                Swap(&A[i]),&A[j];
            else
                break;
        }
        Swap(&A[i],&A[Right - 1]); /* Restore pivot */

        Qsort(A,i - 1);
        Qsort(A,i + 1,Right);
    }
    else /* Do an insertion sort on the subarray */
        InsertionSort(A + Left,Right - Left + 1);
}
複製程式碼

8. 大型結構的排序

目前為止,關於排序的全部討論,都假設要被排序的元素是一些簡單的整數。實際應用中,常常需要某個關鍵字對大型結構進行排序。例如,我們可能有些工資名單的記錄,每個記錄由姓名,地址,電話號碼,諸如工資這樣的財務資訊,以及稅務資訊組成。我們可能要通過一個特定的域,比如姓名,來對這些資訊進行排序。對於所有演演算法來說,基本的操作就是交換,不過這裡交換兩個結構可能是非常昂貴的操作,因為結構實際上很大。這種情況下,實際的解法是讓輸入陣列包含指向結構的指標。我們通過比較指標指向的關鍵字,並在必要時交換指標來進行排序。這意味著,所有的資料移動基本上就像我們對整數排序那樣進行。我們稱之為間接排序(indirect sorting));可以使用這種方法處理我們已經描述過的大部分資料結構。

9. 排序的一般下界

雖然我們得到一些O(NlogN)的排序演演算法,但是,尚不清楚我們是否還能做得更好。可以證明,任何只用到比較的演演算法在最壞的情況下需要\Omega(NlogN)次比較(從而\Omega(NlogN)的時間),因此歸併排序和堆排序在一個常數因子範圍內是最優的。

決策樹

決策樹(decision tree)是用於證明下界的抽象概念。它是一棵二叉樹,每個節點表示在元素之間一組可能的排序,它與已經進行的比較一致。比較的結果是樹的邊。通過只使用比較進行排序的每一種演演算法都可以用決策樹表示。當然,只有輸入資料非常少的情況下畫決策樹才是可行的。由排序演演算法所使用的比較次數等於最深的樹葉的深度

10. 桶式排序

雖然任何只使用比較的一般排序演演算法在最壞的情況下需要執行時間\Omega(NlogN),但是我們要記住,在某些特殊情況下以線性時間進行排序仍然是可能的。

一個簡單的例子就是桶式排序(bucket sort)。為使桶式排序能夠正常工作,必須要滿足一些額外的條件。輸入資料A_1,A_2,A_N必須只由小於M的正整陣列成。這種情況下,排序演演算法就很簡單:使用一個大小為M稱為Count的陣列,它被初始化為全0。於是,CountM個單元(或稱桶),這些桶初始化為空。當讀A_i時,Count[A_i]增1。在所有的輸入資料讀入後,掃描陣列Count,打印出排序後的表。該演演算法用時O(M + N)。如果MO(N),那麼總量就是O(N)

儘管桶式排序看似太平凡用處不大,但是實際上卻存在許多輸入只是一些小的整數的情況,使用像快速排序這樣的排序方法真的是小題大作了。

11. 外部排序

目前為止,我們講到過的所有演演算法都需要將輸入資料裝入記憶體。然後,存在一些應用程式,它們的輸入資料量太大裝不進記憶體。外部排序(external sorting)就是設計用來處理這樣很大的輸入資料的。

11.1 為什麼要設計一種新的演演算法

大部分記憶體排序演演算法都用到記憶體可直接定址的事實。希爾排序用一個時間單位比較元素A[i]A[i - h_k]堆排序用一個時間單位比較元素A[i]A[i*2 + 1]。使用三數中值分割法快速排序以常數個時間單位比較A[Left]A[Center]A[Right]。如果輸入資料在磁碟上,那麼所有這些操作就失去了它們的效率,因為磁碟上的元素只能被順序訪問。即使資料在一張磁碟上,由於轉動磁碟和移動磁碟所需的延遲,仍然存在實際上的效率損失。

11.2 外部排序模型

假設至少有三個磁碟驅動器進行排序工作。我們需要兩個驅動器執行有效的排序,而第三個驅動器進行簡化的工作。如果只有一個磁碟驅動器可用,那麼我們則不得不說:任何演演算法都將需要\Omega(N^2)次磁碟訪問。

11.3 簡單實現方法

基本的外部排序演演算法使用歸併排序中的Merge子程式。假設我們有四個磁碟,T_{a1},T_{a2},T_{b1},T_{b_2},它們是兩個輸入磁碟和兩個輸出磁碟。設資料最初在T_{a1}上,並設記憶體可以一次容納(和排序)M個記錄。一種自然的做法是第一步從輸入磁碟一次讀入M個記錄,在內部(記憶體)將這些記錄排序,然後再把這些排過序的記錄交替地寫到T_{b1}T_{b2}上。我們將把每組排過序的記錄叫做一個順串(run)。做完這些之後,我們倒回所有的磁碟。假設我們的輸入資料如下:

磁碟 資料
T_{a1} 81,94,11,96,12,35,17,99,28,58,41,75,15
T_{a2}
T_{b1}
T_{b2}

如果M = 3,那麼在順串構造以後,磁碟將包含如下所示的資料。

磁碟 順串 順串 順串
T_{a1}
T_{a2}
T_{b1} 11,81,94 17,99 15
T_{b2} 12,96 41,75

現在T_{b1}T_{b2}都包含一組順串。我們將每個磁碟的第一個順串取出並將二者合併,把結果寫到T_{a1}上,該結果是一個二倍長的順串。然後,我們再從每個磁碟取出下一個順串合併,並將結果寫到T_{a2}上。繼續這個過程,交替使用T_{a1}T_{a2},直到T_{b1}T_{b2}為空。此時,或者T_{b1}T_{b2}均為空,或者剩下一個順串。對於後者,我們把剩下的順串拷貝到適當的順串上。將四個磁碟倒回,並重復相同步驟,這一次用兩個a磁碟作輸入,兩個b磁碟作輸出,結果得到一些4M的順串。我們繼續這個過程直到得到長為N的一個順串

該演演算法將需要\lceil log(N/M) \rceil趟工作,外加一趟構造初始化的順串。例如,若我們有1000萬個記錄,每個記錄128個位元組,並有4兆位元組的記憶體,則第一趟將建立320個順串。此時我們再需要\lceil log320 \rceil = 9趟以完成排序。上面的例子則是\lceil log13/3 \rceil = 3趟。

第一趟:

磁碟 順串 順串
T_{a1} 11,96 15
T_{a2} 17,99
T_{b1}
T_{b2}

第二趟:

磁碟 順串
T_{a1}
T_{a2}
T_{b1} 11,99
T_{b2} 15

第三趟:

磁碟 順串
T_{a1} 11,15,99
T_{a2}
T_{b1}
T_{b2}

11.4 其他實現方法

  • 多路合併
  • 多相合並
  • 替換選擇

12. 總結

對於最一般的內部(記憶體)排序應用程式,選用的方法不是插入排序希爾排序,就是快速排序,它們的選用主要是根據輸入的大小來決定。下表顯示在一臺相對較慢的計算機上處理各種不同大小的檔案時的執行時間(單位:秒)。

N 插入排序
O(N^2)
希爾排序
O(N^{7/6})(?)
堆排序
O(NlogN)
快速排序
O(NlogN)
優化快速排序
O(NlogN)
10 0.00044 0.00041 0.00057 0.00052 0.00046
100 0.00675 0.00171 0.00420 0.00284 0.00244
1000 0.59564 0.02927 0.05565 0.03153 0.02587
10000 58.864 0.42998 0.71650 0.36765 0.31532
100000 NA 5.7298 8.8591 4.2298 3.5882
1000000 NA 71.164 104.68 47.065 41.282

這裡沒有包括歸併排序,因為它的效能對於在主存(記憶體)排序不如快速排序那麼好,而且它的程式設計一點也不省事。然而歸併(合併)卻是外部排序的中心思想。