编码 · 算法
基数排序详解:原理、代码与时间复杂度
简短回答
基数排序是一种非比较排序算法,每次按一位数字(或一个字符)对整数或定长字符串排序。常见的最低位优先(LSD)版本先按最后一位排序,再按倒数第二位,直到最高位,每一趟都使用计数排序之类的稳定排序。位数为 d、基数为 b 时,它的时间是 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 基数排序则能处理长度不同的字符串。