1. 程式人生 > >排序演算法下——桶排序、計數排序和基數排序

排序演算法下——桶排序、計數排序和基數排序

桶排序、計數排序和基數排序這三種演算法的時間複雜度都為 $O(n)$,因此,它們也被叫作線性排序(Linear Sort)。之所以能做到線性,是因為這三個演算法是非基於比較的排序演算法,都不涉及元素之間的比較操作。

1. 桶排序(Bucket Sort)?

1.1. 桶排序原理

  • 桶排序,顧名思義,要用到“桶”。核心思想是將要排序的資料分到幾個有序的桶裡,每個桶的資料再單獨進行排序。桶內排完序後,再把每個桶裡的資料按照順序依次取出,組成的序列就是有序的了。

桶排序

1.2. 桶排序的時間複雜度分析

  • 如果要排序的資料有 $n$ 個,我們把它們均勻地劃分到 $m$ 個桶內,每個桶內就有 $k = \frac{n}{m}$ 個元素。對每個桶內的資料進行快速排序,時間複雜度為 $O(klogk)$。$m$ 個桶排序時間複雜度就為 $O(m * klogk) = O(n * log\frac{n}{m})$。當桶的個數接近資料個數時,$O(log\frac{n}{m})$ 就是一個非常小的數,這個時候通排序的時間複雜度接近於 $O(n)$。

1.3. 桶排序的適用條件

桶排序看起來很優秀,但事實上,桶排序對排序資料的要求是非常苛刻的。

  • 首先,要排序的資料需要很容易就能劃分為 $m$ 個桶,並且桶與桶之間有著天然的大小順序
  • 其次,資料在各個桶之間的分佈是比較均勻的
  • 桶排序比較適合用在外部排序中。所謂的外部排序就是資料儲存在外部磁碟中,資料比較大而記憶體有限,無法將資料全部載入到記憶體中去。

1.4. 一個桶排序的例項

假如我們有 10 GB 的訂單資料需要按照金額進行排序,但記憶體只有幾百 MB ,這時候該怎麼辦呢?

  • 我們先掃描一遍檔案,確定訂單金額的資料範圍。
  • 如果掃描後發現訂單金額處於 1 萬元到 10 萬元之間,我們將所有訂單按照金額劃分到 100 個桶內,第一個桶資料範圍為[1, 1000],第二個桶資料範圍為[1001, 2000]......,每個桶對應一個檔案,同時將檔案按照金額範圍的大小順序編號命名(如00、01、02...99)。

  • 如果訂單金額分佈均勻,則每個檔案包含大約 100 MB 的資料,我們可以將每個小檔案讀入到記憶體中,進行快速排序。然後,再按順序從各個小檔案讀取資料,寫入到另外一個檔案,即是排序好的資料了。

  • 如果訂單分佈不均,某一範圍內資料特別多無法一次讀入記憶體,則可以繼續對此區間再進行劃分,直到所有的檔案都可以讀入記憶體為止。


2. 計數排序(Counting Sort)?

2.1. 計數排序演算法實現

  • 計數排序可以看作是桶排序的一種特殊情況。當要排序的資料所處的範圍並不大時,比如最大值為 $K$,這時候,我們可以把資料分為 $K$ 個桶,每個桶內的資料都是相同的,省掉了桶內排序的時間。

  • 假設高考分數的範圍為 [0, 750],我們可以將考生的分數劃分到 751 個桶內,然後再依次從桶內取出資料,就可以實現對考生成績的排序了。因為只涉及到掃描遍歷操作,因此時間複雜度為 $O(n)$。

但這個排序演算法為什麼叫計數排序呢,這是由計數排序演算法的實現方法來決定的,我們來看一個簡單的例子。

  • 假設有 8 個考生,他們的分數範圍為 [0, 5]。這 8 個考生的成績我們放在一個數組中,A[8] = {2, 5, 3, 0, 2, 3, 0, 3}。

  • 我們用大小為 6 的陣列代表 6 個桶來統計考生的成績分佈情況,其中下標表示考生的分數,陣列內的值表示這個分數的考生個數。我們遍歷一遍陣列後,就可以得到 C[6] = {2, 0, 2, 3, 0, 1,},得 0 分的共有 2 人,得 3 分的共有 3 人。

  • 如下所示,成績為 3 的考生共有 3 個,小於 3 分的考生共有 4 個,所以在排好序的資料 R[8] 中,3 的位置應該為 4,5 和 6 。

R[8]

  • 而我們怎麼得到這個位置呢?只需要對 C[6] 陣列按順序累計求和即可,這時候,C[6] 陣列中的每個值就都表示小於等於它的值的個數了。

C[6]

  • 接下來,我們從後到前依次掃描陣列 A[8]。當掃描到 3 時,我們取出 C[3] 的值 7,說明小於等於 3 的個數為 7 個,那麼 3 就應該放在陣列 R[8] 的第 7 個位置,也就是下標為 6 的地方。當我們再次遇到 3 的時候,這時候小於等於 3 的元素個數就少了一個,也就是我們要把 C[3] 相應地減去 1 。

  • 之所以要從後到前依次掃描陣列,是因為這樣之前相同的元素就仍然會保持相同的順序,可以保證排序演算法的穩定性

  • 當我們掃描完整個陣列 A[8] 時,陣列 R[8] 中的資料也就從小到大排好序了,其詳細過程可參考下圖。

計數排序

  • 程式碼實現
// 假設陣列中儲存的都是非負整數
void Counting_Sort(int data[], int n)
{
    if (n <= 1)
    {
        return;
    }

    // 尋找陣列的最大值
    int max = data[0];
    for (int i = 1; i < n; i++)
    {
        if (data[i] > max)
        {
            max = data[i];
        }
    }

    // 定義一個計數陣列, 統計每個元素的個數
    int c[max+1] = {0};
    for (int i = 0; i < n; i++)
    {
        c[data[i]]++;
    }

    // 對計數陣列累計求和
    for (int i = 1; i <= max; i++)
    {
        c[i] = c[i] + c[i-1];
    }

    // 臨時存放排好序的資料
    int r[n] = {0};
    // 倒序遍歷陣列,將元素放入正確的位置
    for (int i = n-1; i >= 0; i--)
    {
        r[c[data[i]] - 1] = data[i];
        c[data[i]]--;
    }

    for (int i = 0; i < n; i++)
    {
        data[i] = r[i];
    }

}

2.2. 計數排序的適用範圍

  • 計數排序只適用於資料範圍不大的場景中,如果資料範圍 $K$ 比排序的資料 $n$ 大很多,就不適合用計數排序了。

  • 計數排序能給非負整數排序,如果資料是其他型別的,需要將其在不改變相對大小的情況下,轉化為非負整數。比如資料有一位小數,我們需要將資料都乘以 10;資料範圍為 [-1000, 1000],我們需要對每個資料加 1000。


3. 基數排序(Radix Sort)?

假設要對 10 萬個手機號碼進行排序,顯然桶排序和計數排序都不太適合,那怎樣才能做到時間複雜度為 $O(n)$ 呢?

1.1. 基數排序原理

  • 手機號碼有這樣的規律,假設要比較兩個手機號碼 $a, b$ 的大小,如果在前面幾位中,$a$ 手機號碼已經比 $b$ 大了,那後面幾位就不用看了。

  • 藉助穩定排序演算法,我們可以這麼實現。從手機號碼的最後一位開始,分別按照每一位的數字對手機號碼進行排序,依次往前進行,經過 11 次排序之後,手機號碼就都有序了。

  • 下面是一個字串的排序例項,和手機號碼類似。

基數排序

  • 根據每一位的排序,我們可以用剛才的桶排序或者計數排序來實現,它們的時間複雜度可以做到 $O(n)$。如果排序的資料有 $K$ 位,則總的時間複雜度為 $O(K * n)$,當 $K$ 不大時,基數排序的時間複雜度就近似為 $O(n)$。

  • 有時候,要排序的資料並不都是等長的,比如我們要對英文單詞進行排序。這時候,我們可以把所有單詞都補足到相同長度,位數不夠的在後面補 ’0‘,所有字母的 ASCII 碼都大於 ‘0’,因此不會影響原有的大小順序。

  • 基數排序需要資料可以分割出獨立的位出來,而且位之間有遞進的關係。除此之外,每一位的資料範圍都不能太大,要可以用線性排序演算法來進行排序


參考資料-極客時間專欄《資料結構與演算法之美》

獲取更多精彩,請關注「seniusen」!
seniusen