演算法圖鑑
Sorting · 07 / 09

Counting Sort計數排序

數每個值出現幾次,不比較

用在:範圍小的整數,例如成績、年齡分布

時間複雜度O(n+k)
空間複雜度O(n+k)
難度進階
前置知識Array & Dynamic Array、Prefix Sum

01為什麼需要它

大考放榜:13 萬人依分數排名

一科考試有 13 萬名考生,分數是 0 到 100 的整數。放榜系統要依分數由高到低排出名次,同分的人維持報名序號的順序。

為什麼用它分數只有 101 種可能。開 101 個格子數每個分數有幾個人,再用前綴和算出每個分數在名單裡從哪一格開始,把每位考生直接放進去。總共大約 13 萬 + 101 次操作,而任何比較排序最壞都要十幾萬乘上 17 次左右的比較。由後往前放,同分者自然保持報名順序。

戶政資料依年齡分組

全國 2,300 萬筆戶籍資料要依年齡排序,順便產生每一歲有多少人的統計表。年齡是 0 到 120 的整數。

為什麼用它計數排序的第一步「數每個值出現幾次」本身就是那張統計表,排序只是把它展開。2,300 萬筆掃兩遍加上 121 格的前綴和,比 n log n ≈ 5 億次比較少一個數量級,而且一整批資料只需要循序讀寫。

照片的亮度中位數與基數排序的每一趟

一張 1,200 萬畫素的灰階照片,每個像素是 0 到 255 的亮度,要找出亮度的中位數來決定曝光補償;另一個場景是把 32 位元整數拆成 4 個位元組做基數排序。

為什麼用它值域只有 256 種:數完 256 個格子後,從暗往亮累加,累計超過一半的那一格就是中位數,不必真的排序 1,200 萬個數。基數排序每一趟要的正是「值域 256、而且穩定」的排序,也就是這一課的穩定版計數排序。

看到這些關鍵字就想到它:整數鍵、值域小(k 不比 n 大太多)、分數、年齡、位元組、直方圖、同值要保持原本順序、基數排序的每一趟、要比 O(n log n) 更快。

02核心概念

當要排的是值域很小的整數,不必比較任何兩個元素。開一個長度 k 的 count 陣列,掃一遍把 count[x] 加一,就知道每個值出現幾次;再從小到大把值 v 輸出 count[v] 次,結果就是有序的。值域不從 0 開始或有負數時,先求出最小值 lo,用 x − lo 當索引。這種做法能突破比較排序 Ω(n log n) 的下界,是因為那個下界只限制「靠比較取得資訊」的演算法;計數排序直接拿值當陣列索引,一次存取就知道它該去哪一區,不在那個模型裡(Sorting Lower Bound 那一篇會證明這條下界)。

只輸出數字時上面就夠了,但實務上排的常是物件:依分數排學生、依狀態排訂單,同一個鍵底下還帶著別的資料。穩定版多一步前綴累加count[v] += count[v−1] 之後,count[v] 是「鍵 ≤ v 的元素個數」,所以鍵為 v 的元素佔輸出的 count[v−1]count[v] − 1 這一段。接著由後往前掃輸入,每拿到一個鍵 v 的元素,先 count[v] −= 1 再放到 out[count[v]]。同鍵的元素裡,原本排在最後的先被放到這一段的最後一格,前一個放到倒數第二格,依此類推,相對順序和輸入完全一樣,這就是穩定。改成由前往後放、又沿用同樣的遞減寫法,同鍵元素就會整段顛倒。

複雜度:計數掃 n 個元素、前綴累加掃 k 格、放回再掃 n 個元素,時間 O(n + k),而且和資料的排列無關,最好、平均、最壞都一樣。空間是 count 的 O(k) 加上輸出陣列的 O(n),穩定版共 O(n + k);只排純整數時可以直接覆寫原陣列,只要 O(k)。關鍵在 k:值域是 0~100 時 k 可以忽略,值域是 0~10⁹ 時光 count 就要 4 GB,O(n + k) 被 k 主宰,這時就該換成把數字拆成幾位分別排的基數排序。經驗法則是 k = O(n) 才划算。

常見的坑有三個。忘了位移,遇到負數就越界;放回時由前往後掃卻用了「先減一再放」,結果仍然有序但不穩定,基數排序會因此出錯;值域是估的,測資裡出現一個超大值就讓記憶體爆掉,所以要先求 min 和 max。和鄰近的做法比:Hash Set / Map Patterns 那篇用雜湊表數次數,適合鍵的種類多但分散的情況;計數排序用陣列,前提是鍵本身就是小整數。它不是原地排序、只能用在離散的整數鍵,小數或字串要先轉成整數鍵,或改用下一篇的基數與桶排序。

03演算法步驟

  1. 1確認鍵是整數,求出最小值 lo 與最大值 hi,值域 k = hi − lo + 1 大約不超過 n 的幾倍才划算。
  2. 2開長度 k 的 count 陣列,掃一遍輸入,count[x − lo] += 1
  3. 3只排數字:v 從 0 到 k − 1,把 v + lo 輸出 count[v] 次,結束。
  4. 4排物件要穩定:前綴累加 count[v] += count[v − 1],現在 count[v] 是鍵 ≤ v 的元素個數。
  5. 5由後往前掃輸入:count[key] −= 1,把元素放到 out[count[key]]。掃完 out 就是穩定排好的結果。

04互動示範

共用陣列 [5, 2, 9, 1, 7, 3, 8, 4] 再補上 2 和 5 各一個,用 ᵃ、ᵇ 標出同值元素原本的先後。三個階段依序進行:計數、前綴累加、由後往前放回。藍色是正在處理的輸入元素,輸出列裡的藍色是它剛被放進去的位置;黃色是它對應的 count 格,綠色是已經放好的輸出。整個過程沒有任何兩個元素互相比較過,最後檢查 2ᵃ 是否仍在 2ᵇ 前面、5ᵃ 是否仍在 5ᵇ 前面。

開始共用陣列 + 兩個重複值 · 值域 0..9
輸入
5ᵃ2ᵃ9173842ᵇ5ᵇ
count(值 v 出現幾次)
00
10
20
30
40
50
60
70
80
90
輸出
··········
藍色是正在處理的元素(輸出裡是剛放進去的位置),黃色是它對應的 count 格,綠色是已放好的輸出
步驟 0/32值域是 0..9,開一個長度 10 的 count 陣列,全部歸零。整個過程不做任何兩兩比較。

05程式碼

兩個版本:只排整數的精簡版(用最小值位移,負數也能用),以及排物件的穩定版,範例用「依分數排學生、同分維持原順序」示範穩定性。C++ 的穩定版寫成模板,鍵函式與值域 k 由呼叫端提供,基數排序的每一趟就能直接套用。

# 計數排序:整數鍵、值域 [lo, hi],完全不做比較。O(n + k),k = hi - lo + 1
def counting_sort(a):
    if not a:
        return []
    lo, hi = min(a), max(a)
    count = [0] * (hi - lo + 1)
    for x in a:
        count[x - lo] += 1                   # 位移 lo,負數也能當索引
    out = []
    for v, c in enumerate(count):
        out.extend([v + lo] * c)             # 值 v + lo 出現 c 次就輸出 c 次
    return out


# 穩定版:排的是物件,key(x) 落在 0..k-1
def counting_sort_by_key(items, key, k):
    count = [0] * k
    for it in items:
        count[key(it)] += 1
    for v in range(1, k):
        count[v] += count[v - 1]             # 現在 count[v] = 鍵 <= v 的元素個數
    out = [None] * len(items)
    for it in reversed(items):               # 由後往前放,同鍵的先後才不會顛倒
        count[key(it)] -= 1
        out[count[key(it)]] = it
    return out


if __name__ == "__main__":
    print(counting_sort([5, 2, 9, 1, 7, 3, 8, 4, 2, 5]))   # [1, 2, 2, 3, 4, 5, 5, 7, 8, 9]
    print(counting_sort([3, -1, 0, -1, 2]))                # [-1, -1, 0, 2, 3]
    # 報名序號已排好,依分數排序後,同分的人仍照報名順序
    students = [("Amy", 88), ("Ben", 72), ("Cara", 88), ("Dan", 95), ("Eve", 72)]
    print(counting_sort_by_key(students, key=lambda s: s[1], k=101))
    # [('Ben', 72), ('Eve', 72), ('Amy', 88), ('Cara', 88), ('Dan', 95)]

06練習題

  • LeetCode 1051Height Checker(身高只有 1~100,計數後直接比對)Easy
  • LeetCode 1122Relative Sort Array(值域 0~1000 的計數)Easy
  • LeetCode 791Custom Sort String(26 個字母各數一次,照指定順序輸出)Medium
  • LeetCode 274H-Index(引用數超過 n 的都算 n,值域壓到 0~n)Medium
  • LeetCode 2785Sort Vowels in a String(只對母音做計數排序)Medium