基数排序 (Radix Sort):按位多轮计数排序
摘要:本文从"计数排序只能排小范围整数,大整数怎么办"的问题出发,详解基数排序如何通过"将大整数分解为多个位,每位独立计数排序"的思路,将非比较排序的适用范围扩展到任意位数的整数甚至字符串。给出了 LSD(最低位优先)、MSD(最高位优先)、含负数版、字符串版共四个 Python 完整实现,图解了逐位计数排序的稳定传递过程,分析了基数选择对性能的影响。最后结合后缀数组、字符串匹配等工程场景讨论其设计哲学与面试高频考点。
本文属于专栏《算法》系列 1 第 18 篇 | 上一篇:桶排序 (Bucket Sort) | 下一篇:(待更新)
文章目录
- [基数排序 (Radix Sort):按位多轮计数排序](#基数排序 (Radix Sort):按位多轮计数排序)
-
- 一、问题引入
- 二、算法原理图解
-
- 核心思想
- [LSD 基数排序图解](#LSD 基数排序图解)
- 为什么低位排序的结果能保持?
- [MSD 基数排序图解](#MSD 基数排序图解)
- 基数的选择
- 三、代码实现
- 四、复杂度分析
- 五、横向对比
- 六、工程实战
-
- 场景一:后缀数组构造
- 场景二:大整数排序
- [场景三:IP 地址排序](#场景三:IP 地址排序)
- 七、常见误区与面试题
- 八、总结
一、问题引入
上一篇我们讨论了桶排序------一种利用"数据分布"的非比较排序,平均时间 O(n)。但桶排序有一个局限:性能依赖数据分布的均匀性。如果数据集中在一个桶里,就退化为 O(n²)。
有没有一种非比较排序,不依赖数据分布,只要数据是"可按位分解"的整数就能稳定地做到线性时间?
答案是:基数排序。
计数排序的局限
计数排序只能处理取值范围 k 较小的数据。如果数据是 32 位整数(范围 0~2³²),计数数组需要 4GB 内存------完全不可行。
但如果换个角度思考:一个 32 位整数可以看作 8 个 4 位十六进制数,或 10 个十进制数字。如果按每一位独立做计数排序,每位的范围只有 10(十进制)或 16(十六进制),计数数组只需要 10 或 16 个位置。
这就是基数排序的核心思路:将"排大整数"分解为"排多次小范围的位"。
一个直觉例子
要对 [170, 45, 75, 90, 802, 24, 2, 66] 排序,不需要计数排序处理整个数值范围(0~802),而是:
- 按个位排序:0, 0, 2, 2, 4, 5, 5, 6
- 按十位排序:0, 2, 4, 6, 7, 7, 9, ?
- 按百位排序:0, 0, 0, 0, 0, 0, 1, 8
每轮只用 10 个桶(十进制 0~9),三轮就排完了。这就是基数排序------多轮计数排序的稳定传递。
| 算法 | 核心思路 | 适用数据 | 时间复杂度 |
|---|---|---|---|
| 计数排序 | 统计每个值的次数 | 小范围整数 | O(n + k) |
| 桶排序 | 按区间分桶,桶内排序 | 均匀分布数值 | 平均 O(n) |
| 基数排序 | 按位多轮计数排序 | 整数、字符串 | O(d·(n + base)) |
问题定义:
- 输入 :含 n 个整数的数组
arr(或字符串数组) - 输出:按升序(或降序)排列的数组
- 核心约束:数据可按位分解(每一位的范围有限)
- 核心操作:逐位计数排序,从低位到高位(LSD)或从高位到低位(MSD)
二、算法原理图解
核心思想
基数排序(Radix Sort)的核心是将多"位"数据的排序分解为单"位"排序的序列。每位用稳定的计数排序处理,低位排序结果在高位排序时通过稳定性保持不变。
基数排序有两种策略:
- LSD(Least Significant Digit):从最低位开始,逐位向高位推进
- MSD(Most Significant Digit):从最高位开始,按当前位分桶后递归处理低位
LSD 基数排序图解
以数组 [170, 045, 075, 090, 802, 024, 002, 066] 升序排序为例:
第 1 轮:按个位排序
原始: [170, 045, 075, 090, 802, 024, 002, 066]
个位: 0 5 5 0 2 4 2 6
计数排序(按个位)→ 稳定排序 →
第1轮: [170, 090, 802, 002, 024, 045, 075, 066]
个位: 0 0 2 2 4 5 5 6
↑---------------↑↑-----↑↑-----↑
个位0个位2个位4个位5个位6,组内保持原始顺序
第 2 轮:按十位排序
当前: [170, 090, 802, 002, 024, 045, 075, 066]
十位: 7 9 0 0 2 4 7 6
计数排序(按十位)→ 稳定排序 →
第2轮: [802, 002, 024, 045, 066, 170, 075, 090]
十位: 0 0 2 4 6 7 7 9
↑-----↑↑---↑↑---↑↑---↑↑-----↑↑---↑
十位0 十位2 十位4 十位6 十位7 十位9
第 3 轮:按百位排序
当前: [802, 002, 024, 045, 066, 170, 075, 090]
百位: 8 0 0 0 0 1 0 0
计数排序(按百位)→ 稳定排序 →
第3轮: [002, 024, 045, 066, 075, 090, 170, 802]
百位: 0 0 0 0 0 0 1 8
↑---------------------↑↑-----↑↑-----↑
百位0(6个) 百位1 百位8
完成! 结果 [2, 24, 45, 66, 75, 90, 170, 802] 完全有序。
为什么低位排序的结果能保持?
这是基数排序最核心的问题:为什么从低位开始排序,最终结果是正确的?
关键在于计数排序的稳定性。以第 1 轮和第 2 轮为例:
第1轮结果(按个位排序后):
[170, 090, 802, 002, 024, 045, 075, 066]
第2轮按十位排序时,170 和 075 的十位都是 7:
170 的十位 = 7
075 的十位 = 7
因为计数排序是稳定的,所以 170(在第1轮结果中排在 075 前面)
在第2轮排序后仍然排在 075 前面。
而 170 的个位是 0,075 的个位是 5。
个位 0 < 个位 5 → 170 应该在 075 前面 ✓
所以:低位排序的相对顺序通过稳定性被保留,
高位排序只决定"大区间"的顺序,小区间内由低位排序结果决定。
总结 :LSD 基数排序的正确性依赖于一个关键性质------如果两个元素的高位相同,它们的相对顺序由低位排序结果决定。计数排序的稳定性恰好保证了这一点。
MSD 基数排序图解
MSD(最高位优先)的思路完全不同------从最高位开始分桶,每个桶递归处理低位:
原始: [170, 045, 075, 090, 802, 024, 002, 066]
第1轮(按百位分桶):
桶0: [045, 075, 090, 024, 002, 066] ← 百位=0
桶1: [170] ← 百位=1
桶8: [802] ← 百位=8
桶0递归(按十位分桶):
桶0: [002] ← 十位=0
桶2: [024] ← 十位=2
桶4: [045] ← 十位=4
桶6: [066] ← 十位=6
桶7: [075] ← 十位=7
桶9: [090] ← 十位=9
(桶1、桶8只有一个元素,无需递归)
桶0桶0递归(按个位):只有 [002],无需递归。
最终按桶顺序拼接:
[002, 024, 045, 066, 075, 090, 170, 802]
LSD vs MSD 对比:
| 维度 | LSD | MSD |
|---|---|---|
| 方向 | 低位 → 高位 | 高位 → 低位 |
| 实现 | 迭代(无需递归) | 递归 |
| 位数 | 所有位都要处理 | 可以提前终止(单元素桶) |
| 适用场景 | 等长数据(整数) | 变长数据(字符串) |
| 缓存友好 | 更好(顺序访问) | 较差(递归跳转) |
基数的选择
基数(base)是基数排序的关键参数------它决定了"每位有多少种值"以及"需要多少轮排序"。
以 32 位整数为例:
| 基数 | 每位范围 | 轮数 d | 每轮桶数 | 总操作量 |
|---|---|---|---|---|
| 2 | 0~1 | 32 | 2 | 32×(n+2) |
| 10 | 0~9 | 10 | 10 | 10×(n+10) |
| 16 | 0~15 | 8 | 16 | 8×(n+16) |
| 256 | 0~255 | 4 | 256 | 4×(n+256) |
| 65536 | 0~65535 | 2 | 65536 | 2×(n+65536) |
基数的选择是一个权衡:
- 基数大:轮数少(d 小),但每轮的桶多(base 大),空间开销大
- 基数小:桶少(空间省),但轮数多(d 大),每轮都要遍历整个数组
理论上存在一个最优基数,使 d × (n + base) 最小。实际中,base = 256 或 base = 65536(按字节/双字节处理)在 C/C++ 中很常见。
三、代码实现
完整代码
通过网盘分享的文件:算法
链接: https://pan.baidu.com/s/1DTJt1X2Is_IQeH5fAXvtZg?pwd=yyqf 提取码: yyqf
--来自百度网盘超级会员v4的分享
LSD 版:标准基数排序
python
def radix_sort_lsd(arr, ascending=True, base=10):
"""
基数排序(LSD:最低位优先):从最低位开始,逐位用计数排序排序。
核心思想:
1. 将整数按基数(如十进制 base=10)分解为各位数字
2. 从最低位(个位)开始,用稳定计数排序按当前位排序
3. 逐位向高位推进,直到处理完最高位
4. 由于计数排序是稳定的,低位排序结果在高位排序时保持不变
时间复杂度:O(d·(n + base)) d 为最大位数 | 空间复杂度:O(n + base) | 稳定排序
"""
if len(arr) <= 1:
return list(arr)
if any(v < 0 for v in arr):
return radix_sort_signed(arr, ascending, base)
max_val = max(arr)
if max_val == 0:
return list(arr)
result = list(arr)
# 逐位排序:exp = 1(个位), base(十位), base²(百位)...
exp = 1
while max_val // exp > 0:
result = _counting_sort_by_digit(result, exp, base, ascending)
exp *= base
return result
六个关键设计解析
设计1:逐位提取数字
python
digit = (val // exp) % base
如何提取第 exp 位的数字? 这行代码做了两步操作:
val // exp:右移 exp 位,使目标位变成最低位% base:取最低位数字
例如 val = 802, exp = 100, base = 10:
802 // 100 = 8→ 右移两位,百位变成个位8 % 10 = 8→ 取出百位数字 8
这是基数排序最核心的操作------从任意位置提取一个"位"的值。
设计2:轮数由最大值决定
python
max_val = max(arr)
exp = 1
while max_val // exp > 0:
result = _counting_sort_by_digit(result, exp, base, ascending)
exp *= base
为什么要找最大值? 基数排序需要处理到"最高非零位"。最大值决定了最高位在哪------如果有 n 位数,就需要 n 轮排序。max_val // exp > 0 的含义是"max_val 还有未处理的位"。
设计3:每轮用稳定计数排序
python
def _counting_sort_by_digit(arr, exp, base, ascending):
"""计数排序:按第 exp 位数字排序。"""
n = len(arr)
result = [None] * n
count = [0] * base
for val in arr:
digit = (val // exp) % base
count[digit] += 1
if ascending:
for i in range(1, base):
count[i] += count[i - 1]
else:
for i in range(base - 2, -1, -1):
count[i] += count[i + 1]
# 从后往前放置(保证稳定性)
for i in range(n - 1, -1, -1):
val = arr[i]
digit = (val // exp) % base
count[digit] -= 1
result[count[digit]] = val
return result
为什么必须用稳定的计数排序? 如果计数排序不稳定,高位排序时相同"位值"的元素的相对顺序会被打乱,低位排序的结果就白费了。稳定性是基数排序正确性的根基。
计数排序的稳定性来自"从后往前遍历 + 前缀和右边界"的组合------上一篇计数排序文章已详细分析。
设计4:LSD 用迭代而非递归
python
exp = 1
while max_val // exp > 0:
result = _counting_sort_by_digit(result, exp, base, ascending)
exp *= base
LSD 为什么不需要递归? LSD 从最低位开始,每一位的排序都是对整个数组的完整计数排序。每轮的结果是下一轮的输入,不需要像 MSD 那样"分桶后递归处理每个桶"。这使得 LSD 的实现更简洁,且缓存更友好(每轮顺序扫描整个数组)。
设计5:MSD 用递归分桶
python
def _msd_sort_recursive(arr, exp, base, ascending):
if exp == 0 or len(arr) <= 1:
return arr
# 按 exp 位分桶
buckets = [[] for _ in range(base)]
for val in arr:
digit = (val // exp) % base
buckets[digit].append(val)
# 递归处理每个桶的低位
result = []
order = range(base) if ascending else range(base - 1, -1, -1)
for d in order:
if len(buckets[d]) > 1:
buckets[d] = _msd_sort_recursive(buckets[d], exp // base, base, ascending)
result.extend(buckets[d])
return result
MSD 为什么要递归? MSD 从最高位开始分桶。分桶后,同一个桶内的元素高位相同,但低位可能不同------需要对每个桶递归处理低位。这种"分桶 → 递归"的结构天然适合递归实现。
MSD 的提前终止优势:如果某个桶只有一个元素,可以立即返回------不需要处理所有位数。对于有大量重复前缀的数据(如字符串),MSD 可以比 LSD 少处理很多位。
设计6:负数的处理策略
python
def radix_sort_signed(arr, ascending=True, base=10):
"""将负数和正数分开处理。"""
negatives = [-v for v in arr if v < 0]
positives = [v for v in arr if v >= 0]
negatives = radix_sort_lsd(negatives, ascending=True, base=base)
positives = radix_sort_lsd(positives, ascending=True, base=base)
if ascending:
# 升序:负数(绝对值大的在前 → 反转后取负)+ 正数
return [-v for v in reversed(negatives)] + positives
else:
return list(reversed(positives)) + [-v for v in negatives]
为什么不能直接对负数做基数排序? 负数的二进制表示中,符号位为 1,按位排序会产生错误结果(负数会被排到正数后面)。
解决策略:将负数取绝对值,变成正数后排好序,最后再取负数并反转。升序时,绝对值大的负数更小(-8 < -2),所以负数部分反转后取负;正数部分直接拼接。
MSD 版
python
def radix_sort_msd(arr, ascending=True, base=10):
"""基数排序(MSD:最高位优先):从最高位开始,递归分桶排序。"""
if len(arr) <= 1:
return list(arr)
if any(v < 0 for v in arr):
return radix_sort_signed(arr, ascending, base)
max_val = max(arr)
if max_val == 0:
return list(arr)
# 计算最高位的指数
max_exp = 1
while max_val // (max_exp * base) > 0:
max_exp *= base
return _msd_sort_recursive(list(arr), max_exp, base, ascending)
字符串版
python
def radix_sort_string(arr, ascending=True):
"""基数排序(字符串版):从最后一个字符开始,逐字符用计数排序。"""
if len(arr) <= 1:
return list(arr)
max_len = max(len(s) for s in arr)
padded = [s.ljust(max_len, '\0') for s in arr]
result = list(padded)
# 从最后一个字符开始,逐字符计数排序
for pos in range(max_len - 1, -1, -1):
result = _counting_sort_by_char(result, pos, ascending)
return [s.rstrip('\0') for s in result]
字符串版说明:字符串排序是 LSD 基数排序的经典应用。不等长的字符串在末尾补 '\0'(ASCII 最小),从最后一个字符开始逐字符计数排序。'\0' 是最小的字符,所以短字符串会排在前面------这符合字典序。
运行验证
python
if __name__ == "__main__":
data = [170, 45, 75, 90, 802, 24, 2, 66]
print(f"排序前: {data}")
print(f"升序(LSD): {radix_sort_lsd(data[:])}")
print(f"降序(LSD): {radix_sort_lsd(data[:], ascending=False)}")
print(f"升序(MSD): {radix_sort_msd(data[:])}")
print(f"降序(MSD): {radix_sort_msd(data[:], ascending=False)}")
# 含负数
neg_data = [-5, 3, -2, 0, 7, -1, 3, -8]
print(f"含负数升序: {radix_sort_signed(neg_data[:])}")
# 字符串排序
str_data = ["banana", "apple", "cherry", "date", "fig", "grape"]
print(f"字符串升序: {radix_sort_string(str_data[:])}")
输出:
排序前: [170, 45, 75, 90, 802, 24, 2, 66]
升序(LSD): [2, 24, 45, 66, 75, 90, 170, 802]
降序(LSD): [802, 170, 90, 75, 66, 45, 24, 2]
升序(MSD): [2, 24, 45, 66, 75, 90, 170, 802]
降序(MSD): [802, 170, 90, 75, 66, 45, 24, 2]
含负数升序: [-8, -5, -2, -1, 0, 3, 3, 7]
字符串升序: ['apple', 'banana', 'cherry', 'date', 'fig', 'grape']
验证说明:以上输出确认了 LSD 和 MSD 两种策略结果一致,负数排序正确,字符串按字典序排序正确。50 轮随机测试(含负数)全部通过,验证了算法的正确性。
四、复杂度分析
时间复杂度
| 情况 | 复杂度 | 说明 |
|---|---|---|
| 最好 | O(d·(n + base)) | 与数据分布无关,总是 d 轮计数排序 |
| 平均 | O(d·(n + base)) | 稳定的线性时间 |
| 最坏 | O(d·(n + base)) | 没有最坏情况(非比较排序) |
其中 d 为最大位数,base 为基数。
推导过程:
LSD 基数排序:
轮数:d = ⌈log_base(max_val)⌉ + 1
每轮计数排序:O(n + base)
总计:d × O(n + base) = O(d·(n + base))
当 base 是常数(如 10)时:
d ≈ log₁₀(max_val) ≈ log₁₀(n) (假设 max_val 和 n 同阶)
总计:O(log n × n) = O(n log n)
当 base = n 时:
d ≈ log_n(max_val) ≈ 常数(通常 2~3)
总计:O(常数 × (n + n)) = O(n) ← 真正的线性时间
关键结论:当基数 base 与 n 同阶时,基数排序是真正的线性时间 O(n)。但代价是空间 O(n + base) = O(n)。这是用空间换时间的典型案例。
空间复杂度
| 部分 | 空间 | 说明 |
|---|---|---|
| 计数数组 | O(base) | 每轮的桶计数 |
| 输出数组 | O(n) | 每轮的排序结果 |
| 总计 | O(n + base) | 非原地排序 |
稳定性
稳定排序。基数排序的稳定性来自计数排序的稳定性。每一轮的计数排序都是稳定的,所以低位排序的相对顺序在高位排序中被保留。
稳定性的传递链:
- 第 1 轮(个位):计数排序稳定 → 相同个位的元素保持原始顺序
- 第 2 轮(十位):计数排序稳定 → 相同十位的元素保持第 1 轮的顺序
- ...
- 第 d 轮(最高位):计数排序稳定 → 相同最高位的元素保持第 d-1 轮的顺序
如果任意一轮的排序不稳定,整个基数排序就不正确。
基数排序 vs 计数排序 vs 桶排序
| 维度 | 计数排序 | 桶排序 | 基数排序 |
|---|---|---|---|
| 思路 | 统计每个值次数 | 按区间分桶 | 按位多轮计数 |
| 适用数据 | 整数、范围小 | 数值、均匀分布 | 整数、字符串 |
| 时间 | O(n + k) | 平均O(n) 最坏O(n²) | O(d·(n+base)) |
| 最坏退化 | 不退化 | 退化为O(n²) | 不退化 |
| 空间 | O(n + k) | O(n + k) | O(n + base) |
| 稳定性 | 稳定 | 可稳定 | 稳定 |
| 数据分布依赖 | 不依赖 | 严重依赖 | 不依赖 |
三者关系:计数排序是桶排序的极端情况(每桶一个值),基数排序是多轮计数排序的串联。它们同属非比较排序家族,但各有适用场景。
五、横向对比
性能对比验证
python
import time
import random
print("--- 性能对比 (n=10000, 范围0~99999) ---")
perf_data = [random.randint(0, 99999) for _ in range(10000)]
start = time.time()
radix_sort_lsd(perf_data[:])
print(f"基数排序(LSD, base=10): {time.time() - start:.4f}s")
start = time.time()
radix_sort_lsd(perf_data[:], base=100)
print(f"基数排序(LSD, base=100): {time.time() - start:.4f}s")
start = time.time()
radix_sort_lsd(perf_data[:], base=1000)
print(f"基数排序(LSD, base=1000): {time.time() - start:.4f}s")
start = time.time()
sorted(perf_data[:])
print(f"内置sorted: {time.time() - start:.4f}s")
典型输出:
--- 性能对比 (n=10000, 范围0~99999) ---
基数排序(LSD, base=10): 0.0103s
基数排序(LSD, base=100): 0.0062s
基数排序(LSD, base=1000): 0.0045s
内置sorted: 0.0012s
结果分析:
- base=10:需要 5 轮(99999 是 5 位数),每轮 10 个桶
- base=100:需要 3 轮,每轮 100 个桶 → 轮数减少 40%,速度提升 40%
- base=1000:需要 2 轮,每轮 1000 个桶 → 最优平衡,速度最快
- 更大基数:虽然只有 1~2 轮,但每轮的桶管理开销和空间增大,不再有优势
不同基数的理论分析
| 基数 | 轮数 d | 每轮操作 | 总操作量 | 适用场景 |
|---|---|---|---|---|
| 10 | 5 | n+10 | 5n+50 | 简单易懂,教学 |
| 100 | 3 | n+100 | 3n+300 | 较优,Python 实用 |
| 1000 | 2 | n+1000 | 2n+2000 | Python 最优 |
| 65536 | 1~2 | n+65536 | ~2n+131072 | C/C++(按双字节处理) |
选型建议
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 大范围整数排序 | 基数排序 | 不退化,稳定线性 |
| 字符串排序 | 基数排序(MSD/LSD) | 按字符逐位排序 |
| 小范围整数 | 计数排序 | 更简单直接 |
| 浮点数 | 桶排序 | 基数排序不适合浮点数 |
| 通用排序 | TimSort / Introsort | 适用性最广 |
| 需要稳定 + 大整数 | 基数排序 | 唯一稳定且线性的选择 |
六、工程实战
场景一:后缀数组构造
后缀数组是字符串算法的核心数据结构,在生物信息学、数据压缩等领域有广泛应用。一种经典的后缀数组构造方法就是基于基数排序的倍增法:
python
# 伪代码:后缀数组的倍增构造法
def build_suffix_array(s):
"""
用基数排序 + 倍增法构造后缀数组。
核心思路:
1. 初始按单个字符排序
2. 每轮倍增比较长度,用基数排序按"双关键字"排序
3. 直到所有后缀的排名都唯一
时间复杂度:O(n log n),其中每轮用基数排序 O(n)
"""
n = len(s)
# 初始:按单个字符排序
rank = [ord(c) for c in s]
sa = list(range(n))
k = 1
while k < n:
# 按 (rank[i], rank[i+k]) 双关键字排序
# 第一关键字 rank[i+k] 先用计数排序
# 第二关键字 rank[i] 再用计数排序
# 基数排序的稳定性保证正确
sa = _radix_sort_double_key(sa, rank, k, n)
# 更新 rank
new_rank = [0] * n
new_rank[sa[0]] = 0
for i in range(1, n):
new_rank[sa[i]] = new_rank[sa[i-1]]
if _compare(s, sa[i], sa[i-1], k, n) != 0:
new_rank[sa[i]] += 1
rank = new_rank
if rank[sa[-1]] == n - 1:
break # 所有排名唯一,完成
k *= 2
return sa
这个算法的核心是用基数排序按双关键字排序------先按第二关键字排序,再按第一关键字排序,利用稳定性得到正确的双关键字排序结果。这正是基数排序的典型应用。
场景二:大整数排序
当整数范围非常大(如 64 位整数)时,比较排序需要 O(n log n),而基数排序可以用较大的基数做到接近线性:
python
# 排序 100 万个 64 位随机整数
import random
data = [random.randint(0, 2**63 - 1) for _ in range(1000000)]
# 基数排序 base=2^16(按双字节处理)
# 64位整数 = 4 个 16 位段 → 只需 4 轮
sorted_data = radix_sort_lsd(data, base=65536)
在 C/C++ 中,按字节(base=256)或双字节(base=65536)处理 64 位整数只需要 4~8 轮,每轮都是 O(n) 的线性扫描。对于大规模整数排序,基数排序在工程上有实际优势。
场景三:IP 地址排序
IP 地址(如 192.168.1.100)天然可以看作 4 个"位"的基数排序:
python
def sort_ip_addresses(ips):
"""用基数排序对 IP 地址排序。"""
# 将 IP 转换为 4 字节整数
def ip_to_int(ip):
parts = ip.split('.')
return (int(parts[0]) << 24) + (int(parts[1]) << 16) + \
(int(parts[2]) << 8) + int(parts[3])
nums = [ip_to_int(ip) for ip in ips]
sorted_nums = radix_sort_lsd(nums, base=256) # 4 轮
def int_to_ip(n):
return f"{(n >> 24) & 255}.{(n >> 16) & 255}.{(n >> 8) & 255}.{n & 255}"
return [int_to_ip(n) for n in sorted_nums]
IP 地址本质是一个 32 位无符号整数,用基数排序只需 4 轮(base=256),每轮 256 个桶,非常适合。
七、常见误区与面试题
高频面试题
Q1:基数排序的时间复杂度是多少?d 和 base 分别是什么?
基数排序的时间复杂度是 O(d · (n + base)),其中:
- d 是数据的最大位数(如 5 位十进制数,d = 5)
- base 是基数(如十进制 base=10,每个位有 0~9 共 10 种值)
- n 是元素个数
当 base 是常数时,d ≈ log_base(max_val),总时间 O(n log n)。当 base = n 时,d ≈ 常数,总时间 O(n)。所以基数排序在基数选择合理时可以做到线性时间。
Q2:为什么 LSD 基数排序必须从最低位开始?能不能从最高位开始?
LSD 必须 从最低位开始。原因是:如果从高位开始且不做递归处理,高位排序会被低位排序打乱。
从低位开始时,每轮排序利用了稳定性------高位相同的元素,其相对顺序由低位排序结果决定。这恰好是我们想要的:高位决定大区间顺序,低位决定小区间内顺序。
如果从高位开始,需要像 MSD 那样递归处理每个桶的低位------因为高位分桶后,桶内元素的低位尚未排序,需要递归。
Q3:基数排序是稳定的吗?为什么?
稳定排序 。稳定性的来源是每轮计数排序的稳定性。
稳定性的传递链:第 1 轮按个位排序,相同个位的元素保持原始顺序;第 2 轮按十位排序,相同十位的元素保持第 1 轮的顺序;...;第 d 轮按最高位排序,相同最高位的元素保持第 d-1 轮的顺序。
如果任意一轮的排序不稳定,低位排序的相对顺序就会丢失,整个排序就不正确了。
Q4:基数排序和计数排序有什么关系?
基数排序是多轮计数排序的串联。每一轮按一个"位"做计数排序,多轮组合起来就完成了对整个数值的排序。
计数排序是基数排序的"原子操作"------没有计数排序(或任何稳定的按位排序算法),基数排序就无法工作。反过来说,计数排序也可以看作基数排序的退化情况:当数据只有 1 位(即取值范围等于基数)时,基数排序退化为单轮计数排序。
Q5:基数排序能排浮点数吗?
理论上可以,但需要特殊处理。浮点数的 IEEE 754 表示有一个特性:正浮点数的二进制表示按字典序排列就是升序排列(因为指数在前,尾数在后,且都是偏移编码)。
所以可以将浮点数的二进制字节直接作为"位"来排序。但负浮点数需要额外处理(符号位取反等),实现较复杂。实际中,浮点数通常用桶排序而非基数排序。
常见实现错误
| 错误 | 说明 | 修正 |
|---|---|---|
| LSD 从高位开始 | 低位排序结果被高位覆盖 | 必须从最低位开始 |
| 计数排序不稳定 | 低位排序结果丢失 | 用从后往前遍历的稳定版本 |
| 轮数不够 | 高位没处理完,排序不完整 | while max_val // exp > 0 |
| 位提取错误 | val % exp 而非 (val // exp) % base |
先除后取余 |
| 负数直接排序 | 符号位导致排序错误 | 分离正负数分别处理 |
| 字符串不等长 | 补位字符用错(如补空格) | 补 '\0'(ASCII 最小) |
八、总结
核心要点
- 按位分解------将大整数排序分解为多轮小范围计数排序
- 稳定传递------每轮计数排序的稳定性保证低位结果不被打乱
- LSD 从低到高------迭代实现,简单且缓存友好
- MSD 从高到低------递归实现,支持提前终止,适合变长数据
- 不依赖分布------与桶排序不同,基数排序不退化
适用边界与限制
| 维度 | 适用条件 | 不适用条件 |
|---|---|---|
| 数据类型 | 整数、字符串(可按位分解) | 浮点数(需特殊处理) |
| 位数 | 位数有限(d 较小) | 位数极多(如超长字符串) |
| 空间 | 能接受 O(n + base) 额外空间 | 空间极度受限 |
| 稳定性 | 需要稳定排序 | 不需要稳定性(可用快排) |
| 数据范围 | 范围大但位数少 | 范围小(用计数排序更简单) |
设计哲学
基数排序的设计哲学是:将大问题分解为多个小问题的串联,用稳定性保证串联的正确性。
排序一个大整数(范围 0~2³²)是一个"大问题"------直接用计数排序需要 4GB 空间。但如果我们把这个大问题分解为"按字节排序"的小问题------每个字节只有 256 种值,计数排序只需 256 个桶------4 轮小问题的串联就完成了大问题的求解。
这种"分而治之"的思路与归并排序的分治不同。归并排序的"分治"是把数组分成两半,各自排序后合并------子问题是"排序半个数组"。基数排序的"分治"是把排序条件分解------按第 1 位排序 + 按第 2 位排序 + ... + 按第 d 位排序------子问题是"按某一个位排序"。
稳定性的角色:稳定性是连接这些子问题的"粘合剂"。每一轮排序的输出是下一轮排序的输入,稳定性保证了前面的工作不会被后面的工作"推翻"。没有稳定性,串联就失去了意义------每一轮都在破坏前面轮次的结果。
这个思想在计算机科学中非常通用:当一个大问题可以分解为多个有序的子问题,且子问题之间的关系可以通过某种"不变量"(如稳定性)维持时,就能用串联的方式求解。基数排序是这一思想的经典范例。
📌 专栏导航 :算法
⬅️ 上一篇 :桶排序 (Bucket Sort) ➡️ 下一篇:(待更新)
如果这篇文章对你有帮助,欢迎 点赞、收藏、关注,支持专栏持续更新!