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

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

開始 http 數字 基於 分析 數據存儲 線性 尋找 排好序

桶排序、計數排序和基數排序這三種算法的時間復雜度都為 $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 。

技術分享圖片

  • 而我們怎麽得到這個位置呢?只需要對 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」!
技術分享圖片

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