基礎排序算法

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

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

2. 插入排序(insertion sort)

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

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)。這正好是由插入排序執行的交換次數。狀況老是這樣,由於交換兩個不按原序排列的相鄰元素剛好消除一個逆序,而一個排過序的數組沒有逆序。markdown

假設不存在重複元素,輸入數據是前N個整數的某個排列,並設全部的排列都是等可能的。有以下定理:數據結構

定理1ide

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

定理2性能

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

4. 希爾排序(shellsort)

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

希爾排序使用一個序列h_1, h_2, \ldots, 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[], ElementType TmpArray[], int Left, int Right) {
    int Center;

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

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

    TmpArray = malloc(N * sizeof(ElementType));
    if (TmpArray != NULL)
    {
        MSort(A, TmpArray, 0, 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, 2, 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 Left, 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 Left, int Right) {
    int i, j;

    ElementType Pivot;

    if (Left + Cutoff <= Right)
    {
        Pivot = Median3(A, Left, 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, Left, 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, \ldots, 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, 28, 99 15
T_{b2} 12, 35, 96 41, 58, 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, 12, 35, 81, 94, 96 15
T_{a2} 17, 28, 41, 58, 75, 99
T_{b1}
T_{b2}

第二趟:

磁盤 順串
T_{a1}
T_{a2}
T_{b1} 11, 12, 17, 28, 35, 51, 58, 75, 81, 94, 96, 99
T_{b2} 15

第三趟:

磁盤 順串
T_{a1} 11, 12, 15, 17, 28, 35, 51, 58, 75, 81, 94, 96, 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

這裏沒有包括歸併排序,由於它的性能對於在主存(內存)排序不如快速排序那麼好,並且它的編程一點也不省事。然而歸併(合併)倒是外部排序的中心思想。

相關文章
相關標籤/搜索