編碼 · 演算法
基數排序法教學:原理、程式碼與時間複雜度
簡答
基數排序法是一種非比較式排序演算法,一次處理一個位數(或字元)來排序整數或固定長度的字串。常見的由最低位數優先(LSD)版本,先依最後一位排序,再依下一位,一路到最高位,每一回合都使用計數排序之類的穩定排序。對基底為 b、共 d 位的鍵值,時間為 O(d · (n + b)),額外空間為 O(n + b)。由於每一回合都是穩定的,最終結果完全有序,而且是穩定排序。
開啟行排序基數排序解決什麼問題
計數排序是線性的,但需要與整個鍵值範圍一樣大的計數陣列,這對 32 位元或 64 位元整數是不可能的。基數排序把每個鍵值拆成小的位數,每個位數執行一次計數排序,所以每一回合只需要 b 個項目的計數陣列(十進位位數為 10,位元組為 256)。
任何能拆成固定長度小符號序列的鍵值都適用:非負整數、寫成 YYYYMMDD 的日期、郵遞區號之類的固定長度代碼、IP 位址,以及固定寬度的字串。
範例:十進位的 LSD 基數排序
將 [170, 45, 75, 90, 802, 24, 2, 66] 排序。最大的數有 3 位,所以要進行 3 回合。每一回合都是依單一位數做穩定排序:位數相同的元素保持上一回合的順序。
| 回合 | 排序依據 | 結果 |
|---|---|---|
| 開始 | [170, 45, 75, 90, 802, 24, 2, 66] | |
| 1 | 個位數 | [170, 90, 802, 2, 24, 45, 75, 66] |
| 2 | 十位數 | [802, 2, 24, 45, 66, 170, 75, 90] |
| 3 | 百位數 | [2, 24, 45, 66, 75, 90, 170, 802] |
第 2 回合後,數字已依最後兩位排好序。在第 3 回合,2、24、45、66、75 和 90 的百位數都是 0,穩定性讓它們維持第 2 回合產生的順序。這就是每一回合都必須穩定的原因。
Python 實作
def counting_pass(nums, exp, base):
"""Stable counting sort of nums by the digit at exp."""
count = [0] * base
for x in nums:
count[(x // exp) % base] += 1
for d in range(1, base):
count[d] += count[d - 1]
out = [0] * len(nums)
for x in reversed(nums):
d = (x // exp) % base
count[d] -= 1
out[count[d]] = x
return out
def radix_sort(nums, base=10):
"""LSD radix sort for non-negative integers."""
if not nums:
return []
exp, biggest = 1, max(nums)
while biggest // exp > 0:
nums = counting_pass(nums, exp, base)
exp *= base
return nums
print(radix_sort([170, 45, 75, 90, 802, 24, 2, 66]))
# [2, 24, 45, 66, 75, 90, 170, 802]
print(radix_sort([3, 0, 1, 0], base=256)) # [0, 0, 1, 3]使用基底 256,表示每一回合看一個位元組,所以 32 位元整數只需 4 回合。若要包含負數,可以把負數與非負數分開排序再接起來,或把所有鍵值減去最小值做偏移。對固定長度的字串,則以位置 i 的字元碼作為位數,並從最後一個位置往前處理。
時間與空間複雜度
| 指標 | 數值 | 原因 |
|---|---|---|
| 時間(任何情況) | O(d · (n + b)) | d 回合,每回合對 n 個鍵值與 b 種位數值做計數排序 |
| 額外空間 | O(n + b) | 每回合一個輸出緩衝區與一個計數陣列 |
| 32 位元鍵值的回合數 | 基底 256 時為 4 | 32 位元 ÷ 每位數 8 位元 |
| 比較次數 | 0 | 位數被當作陣列索引使用 |
當 d 是常數(固定寬度的鍵值),且 b 選在接近 n 或固定值(如 256)時,時間為 O(n)。對 n 個互不相同的鍵值,d 至少要有 log_b n,所以對任意大小的鍵值,基數排序並不會勝過 O(n log n);它之所以快,是因為每一步都是單純的陣列操作。
基數排序與其他排序法的比較
| 演算法 | 最佳 | 平均 | 最差 | 額外空間 | 穩定 |
|---|---|---|---|---|---|
| 基數排序(LSD) | O(d · (n + b)) | O(d · (n + b)) | O(d · (n + b)) | O(n + b) | 是 |
| 計數排序 | O(n + k) | O(n + k) | O(n + k) | O(n + k) | 是 |
| 合併排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 否 |
| 堆積排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 否 |
Timsort(Python 的 sorted) | O(n) | O(n log n) | O(n log n) | O(n) | 是 |
表中 n 是鍵值個數,k 是計數排序的鍵值範圍,d 是位數,b 是基底。只要每一回合的位數排序都是穩定的,LSD 基數排序就是穩定的。它需要結構固定的鍵值,而合併排序、快速排序這類比較式排序可以處理任何排序依據,包括自訂的比較函式。
LSD 與 MSD,以及常見錯誤
| LSD(最低位數優先) | MSD(最高位數優先) | |
|---|---|---|
| 回合順序 | 從最後一位到第一位 | 從第一位到最後一位,對每個桶遞迴進行 |
| 最適合 | 固定長度的整數與字串 | 可變長度的字串,遇到唯一前綴可提早結束 |
| 實作 | 簡單的迴圈 | 遞迴;小的桶通常會改用插入排序 |
| 穩定 | 是,搭配穩定的回合 | 是,搭配穩定的分桶 |
何時使用基數排序
基數排序很適合處理大量固定寬度的整數或鍵值,例如 32 位元 ID、時間戳記、IPv4 位址或固定長度字串,尤其是在 GPU 與高效能函式庫中,它規律的記憶體存取很划算。它用於後綴陣列的建構,也用在某些資料庫與圖形運算工作中。對一般的 Python 串列,sorted() 在實務上幾乎總是更快,因為它跑的是最佳化過的 C 程式碼。面試中,被問到如何以線性時間排序時會提到基數排序,例如 Maximum Gap 這道題。
常見問題
基數排序是穩定的嗎?
當每一回合的位數排序都使用計數排序之類的穩定排序時,LSD 基數排序是穩定的。演算法實際上也依賴這份穩定性才能正確。
基數排序的時間複雜度是多少?
O(d · (n + b)),其中 d 是位數,n 是鍵值個數,b 是基底。對固定寬度的鍵值,d 與 b 都是常數,所以對 n 是線性的。
基數排序比快速排序快嗎?
對大量固定寬度的整數,可能會更快,因為它不做比較,而且以規律的模式存取記憶體。對一般資料、小型陣列或自訂排序依據,快速排序或內建的排序通常更好。
LSD 與 MSD 基數排序有什麼不同?
LSD 從最低位數排到最高位數,每個位數只需一次簡單的回合。MSD 從最高位數開始,遞迴地排序每個桶,適合可變長度的字串。
基數排序能排序字串嗎?
可以。把每個字元當作一個位數。LSD 可直接處理等長的字串;MSD 基數排序則可以處理不同長度的字串。