☰
基数排序原理与Python实现:从稳定排序到高效分桶
2026/10/8 2:45:07 网站建设 项目流程

1. 基数排序是怎么一回事

如果你刷过排序算法,大概率会觉得“快排、归并、堆排”已经够用了,为什么还要专门搞一个基数排序?我第一次接触它时也是这种想法。后来遇到一批固定位数的整数排序,数据量几百万级,快排虽然也还行,但基数排序那种“用空间换轮次、避开比较”的思路确实眼前一亮。更重要的是,它背后“稳定排序 + 桶分配”的组合思想,在很多实战场景里都藏得很深,比如数据库的排序实现、后缀数组的构建,甚至在某些高性能计算库里都能看到影子。

基数排序属于典型的非比较排序,它不靠两两元素之间比大小来决定顺序,而是按“位”来处理。你可以把它理解成整理一副扑克牌:先按花色分成四堆,再在每个花色里按点数排好;或者反过来,先按点数分成十三堆,再按花色排。无论哪种顺序,只要每轮分组是稳定的,最终都能得到有序结果。

1.1 比较排序与非比较排序的本质区别

常规排序算法,比如快速排序、归并排序,核心操作是比较两个元素的大小,单次比较的时间复杂度是 O(1),所以总复杂度下界是 O(n log n)。这里有一个信息论的解释:n 个元素的排列有 n! 种可能,每次比较只能得到“大于/小于/等于”三种结果,因此需要至少 log2(n!) 次比较,约等于 n log2 n。

基数排序绕开了比较。它假设待排序的数据可以拆分成若干个“位”,每一位的取值是有限集合。比如十进制整数每一位只有 0~9 十种情况,那么就可以准备 10 个桶。每一轮按某一位把元素放进对应桶里,再按桶的顺序收集回来。整个过程没有比较,只有“分配”和“收集”,所以时间复杂度可以直接写成 O(k·n),其中 k 是位数或者说轮数。

1.2 LSD 与 MSD:两种完全不同的拆解路径

基数排序有两条路线:从最低有效位开始排的叫 LSD(Least Significant Digit),从最高有效位开始的叫 MSD(Most Significant Digit)。

LSD 的实现符合直觉:先按个位排序,再按十位排序,依此类推。它的优势是每轮都用稳定排序,最终结果自然全局有序,不需要递归,逻辑非常线性。

MSD 则复杂一些:先按最高位分桶,高位相同的元素会落到同一个桶里,然后对每个桶递归地按次高位分桶。它适合处理字符串排序,尤其是可变长字符串,因为可以先比较首字母,首字母不同的单词根本不需要看后续字符,能省掉大量浪费。

实际工程里,LSD 更常见,实现也简单。Python 里手写基数排序,我基本都推荐从 LSD 入手。

1.3 时间复杂度与空间复杂度:别被 O(nk) 骗了

很多人背过“基数排序时间复杂度 O(nk)”,但这里的 k 不是常数,它取决于数据的范围和进制选择。比如排序范围是 0 到 99999 的整数,十进制需要 5 轮,二进制需要 17 轮。轮数 k = log_r(max_value),r 是基数大小。所以时间复杂度更严谨地说是 O(n · log_r(max))。

空间复杂度方面,每轮需要额外的桶空间。如果用链表桶,需要 O(n) 存储元素本身,加上桶头部指针,总空间 O(n + r)。如果用计数排序来实现稳定桶,还需要两个辅助数组,一个计频次,一个定位元素输出位置,总空间同样是 O(n + r)。r 通常远小于 n,可以粗略认为空间 O(n)。

需要注意,当 max 非常大时,比如排序 64 位整数,用二进制要排 64 轮,每轮都要重新分配和收集,整体效率不一定比优化过的快排强。所以基数排序不是银弹,它适合“值域有限、位数有限”的场景。

2. 稳定排序在基数排序中的重要性

我一直觉得,理解“为什么基数排序要求稳定排序”比写代码本身更重要。这个问题也是面试官最爱挖的坑。

先做一个简单实验:假设有一组两位数 [23, 21, 31] 按个位排,得到序列 [21, 31, 23]。注意,21 和 31 个位都是 1,它们的相对顺序是原先 [21, 31] 中的顺序。如果这里桶收集时不保持稳定,比如桶内用了不稳定排序,那么十位排序时原本靠前的 21 可能跑到 31 后面,最终结果就错了。

换句话说,LSD 的每一轮实际上是在前面低位已经有序的前提下,对当前位做“改进”。如果不能保持前一轮的相对顺序,那么低位信息在更高位排序时就会丢失,整个排序逻辑就崩塌了。

2.1 用计数排序实现稳定桶

实现稳定桶最简单有效的方法是计数排序。Python 里可以用一个长度等于基数(通常是 10)的 count 数组,先统计当前位每个数字出现的次数,再算前缀和,这个前缀和直接决定了每个元素在输出数组中的目标位置。只要从后往前遍历原始数组,就能保证相同数字的元素按照原始顺序进入输出数组,从而保持稳定性。

为什么不直接用列表当桶?理论上当然可以:遍历元素,按当前位 append 到对应的子列表,再依次拼接。这种方法写起来直观,但 Python 里频繁创建子列表、拼接列表的开销非常大,而且每轮都要新建 10 个空列表,内存碎片化严重。计数排序方式使用预先分配的数组,一次遍历两次循环搞定,性能和稳定性都更好。

2.2 获取指定位的经典方法

拿到一个整数 xx,怎么取其第 k 位?如果从最低位开始,第 0 位是个位,第 1 位是十位,通用公式是:

digit = (x // (base ** k)) % base

不过每次算 base 的幂开销不小。工程上更常见的做法是每轮维护一个 divisor,初值为 1,每轮结束后乘以 base,然后通过(x // divisor) % base获取当前位。这样避免了重复计算幂。

还有一个小技巧:如果你知道数据范围不超过某个值,可以直接用位运算,比如按 2 进制分组时,(x >> shift) & mask比取模快得多。在 Python 里,整数除法加取模其实不慢,但如果你追求极致性能,可以考虑用位掩码方式。

2.3 负数的处理:一个必须提前想清楚的坑

很多网上的基础版实现根本不处理负数。如果你直接拿负数套公式,(-123) % 10在 Python 里结果是 7 而不是 3,因为 Python 的取模运算遵循“结果符号与除数相同”的规则。这会导致排序完全错乱。

处理负数有几种方案:

  1. 分离正负数,分别排序再合并。负数部分可以取绝对值后加一个偏移量,或者专门按符号位处理。
  2. 全体加偏移量。先找到最小值,把所有数加上-min_value,转成非负数,排完再减回来。代价是多一轮遍历,但逻辑最简单。
  3. 按补码思想处理。二进制位运算下,负数参与移位和掩码也能得到正确结果,但涉及 Python 整数无限精度的问题,处理起来有点绕。

我常用方案二,因为它通用性强,不依赖进制。具体操作是:第一遍遍历找最小值,第二遍遍历生成新数组num + offset,排序后再把 offset 减掉。注意这一步会在“数值大小相等”时保持稳定性吗?其实影响不大,因为所有元素加了同一个常数,相对大小完全不变。

2.4 基数选择:10 进制是最优解吗?

很多人默认十进制,因为生活中最熟悉。但在计算机里,二进制或十六进制往往更快。假设数据范围是 0 到 10 万,十进制需要 5 轮,二进制需要 17 轮,十六进制只需要 4 轮(因为 10 万小于 0xFFFF,但 4 位十六进制能表示到 65535,不够,得用 5 轮)。所以十六进制在轮数上并不占优。

更实际的选择是 256 进制,也就是按字节排序。一个 32 位整数拆成 4 个字节,只排 4 轮。每轮基数 r=256,计数数组长度 256,也很小。Python 中可以通过x & 0xFF、(x >> 8) & 0xFF这类位运算取字节,速度极快。

如果目标平台内存很紧张,用链表桶方式、基数取 10 也许能省内存,但 Python 本身就不是省内存的语言,不如干脆用数组。

2.5 字符串能不能用基数排序?

可以,而且很合适。字符串本质上可以看作一个字符序列,每个字符都有对应的 Unicode 码点,基数可以取 256 或更大。但需要注意长度不一致的情况,短字符串需要补一种排在所有字符之前的“空字符”。

LSD 对字符串排序时,需要先统一长度,从最后一个有效字符往前排。这里有一个麻烦:如果某条字符串长度不足,它在对应位应视为“最小”,但直接取码点会造成越界。常见的做法是把字符串逆序后按字符码点排序,或者在每轮判断索引是否越界,越界按 -1 处理。

MSD 处理变长字符串其实更自然,但从工程角度看,如果只是普通需求,Python 内置的sorted已经优化得很好了,手写基数排序来排字符串更多是面试和学习用途。

3. Python 实现:从零开始写一个可用的 LSD 基数排序

有了前面原理铺垫,代码写起来就顺畅了。我先给一个最基础、适用于非负整数的版本,然后逐步加强。所有代码基于 Python 3.8+。

3.1 基础版:非负整数排序

def radix_sort_base(nums): if not nums: return nums max_num = max(nums) base = 10 divisor = 1 while max_num // divisor > 0: # 计数数组,存储 0~base-1 每个数字出现次数 count = [0] * base for x in nums: digit = (x // divisor) % base count[digit] += 1 # 前缀和,转换为每个数字最后一个位置+1 for i in range(1, base): count[i] += count[i - 1] # 从后往前遍历,保持稳定性 output = [0] * len(nums) for x in reversed(nums): digit = (x // divisor) % base count[digit] -= 1 output[count[digit]] = x nums = output divisor *= base return nums

注意这里reversed(nums)不是颠倒原列表,而是返回反向迭代器,不会创建新列表,空间性能友好。如果你用的是普通列表遍历再手动倒序,也能达到同样效果,但会多一次 O(n) 的复制。

3.2 支持负数的通用版本

在基础版上加入偏移量处理。我们先找到最小值,如果最小值小于 0,就把所有元素加上-min_value。排序完成后还需要再把偏移减回去。为了保证函数返回值与输入列表相互独立,我建议生成新列表,而不是原地修改。

def radix_sort_with_negative(nums): if not nums: return nums min_val = min(nums) if min_val < 0: offset = -min_val arr = [x + offset for x in nums] else: offset = 0 arr = list(nums) max_val = max(arr) base = 10 divisor = 1 while max_val // divisor > 0: count = [0] * base for x in arr: digit = (x // divisor) % base count[digit] += 1 for i in range(1, base): count[i] += count[i - 1] output = [0] * len(arr) for x in reversed(arr): digit = (x // divisor) % base count[digit] -= 1 output[count[digit]] = x arr = output divisor *= base if offset: return [x - offset for x in arr] return arr

这个版本能覆盖绝大多数整数排序需求,包括全负数、正负混合、重复值等情况。

3.3 按字节优化的版本

如果你排序的整数都是 Python int,并且范围不大,但数量很大,我们可以用字节位运算来做。假设只考虑非负 32 位整数,基数取 256,需要 4 轮。

def radix_sort_byte(nums): if not nums: return nums arr = list(nums) # 先处理非负情况,负数后面再讨论 for shift in (0, 8, 16, 24): count = [0] * 256 for x in arr: byte = (x >> shift) & 0xFF count[byte] += 1 for i in range(1, 256): count[i] += count[i - 1] output = [0] * len(arr) for x in reversed(arr): byte = (x >> shift) & 0xFF count[byte] -= 1 output[count[byte]] = x arr = output return arr

这里 shift 从 0 开始,每次加 8,4 轮之后 32 位整数全部处理完。如果数据范围只用得到低两个字节,可以只跑两轮,提前结束。

注意:这个版本不能直接处理负数。如果输入包含负数,需要先加偏移量,或者采用符号位调整。在 Python 中,负数的右移是带符号的,(-1 >> 8) & 0xFF不会得到你期望的 255,而是因为 Python 整型无限长,得到 0xFFFFFFFF。所以更稳妥的方案还是偏移量法。

3.4 正确性测试与随机验证

写完排序函数,务必要用随机数据验证。我通常会写一个小测试脚本,对比sorted()的结果:

import random def test_radix_sort(sort_func, n=10000, max_abs=100000, trials=20): for _ in range(trials): data = [random.randint(-max_abs, max_abs) for _ in range(n)] expected = sorted(data) got = sort_func(data) if got != expected: print("Error on trial", _) print(data[:20]) print(got[:20]) print(expected[:20]) return False return True print(test_radix_sort(radix_sort_with_negative))

这个测试代码能快速排除绝大多数实现错误。我第一次写基数排序时,就是栽在负数取模上,后来加了偏移量才通过。

3.5 性能对比:基数排序 vs Python 内置排序

很多初学者会好奇,手写基数排序能不能打得过sorted()。直接说结论:在 Python 中,纯手写基数排序在大多数情况下都打不过内置的 Timsort。

Timsort 是 Python 排序的底层算法,它结合了归并排序和插入排序,针对现实数据做了大量优化,而且在 C 语言层实现,常数极小。基数排序虽然在渐进时间复杂度上看着更好,但 Python 层的循环开销、数组分配开销会吃掉优势。

我做过一个小实验:随机生成 100 万个 0 到 10 万的整数,内置sorted()大约耗时 0.2 秒,我的十进制基数排序版本大约耗时 0.6 秒。换成按字节优化的版本,能降到 0.4 秒左右,但仍然比不上内置。

那基数排序是不是没用?不是。它在某些特定场景下有意义,比如硬件层面实现、C/C++ 里处理固定宽度数据、GPU 并行计算,或者需要对大规模整数做稳定排序且位数固定时。在 Python 里,学习基数排序的意义更多在于理解算法思想,以及应对面试中的手写代码题。

3.6 一个优化思路:提前终止轮次

如果你的数据最大值的位数很小,可以提前结束。比如最大数是 999,十进制只需 3 轮。基础版用while max_num // divisor > 0已经实现了这一点。

但注意,如果数据包含负数偏移之后的最大值位数变大了怎么办?比如原数据范围是 [-100000, 100000],加上偏移后变成 [0, 200000],位数反而增加一轮。这是一种取舍,可以用按字节版本减轮数,但负数的存在确实让基数排序在 Python 里稍显笨拙。

4. 实际应用场景与避坑经验总结

4.1 哪些场景值得用基数排序

在 C/C++ 或者底层开发中,基数排序常见的应用包括:

  • 大规模整数排序:内存中几十亿个 64 位整数,按位排序比快排更容易做数据局部性优化,容易向量化。
  • GPU 并行排序:基数排序天然适合并行,因为每轮的分桶可以分成“统计频次”和“全局定位”两个阶段,很适合 SIMD 或 SIMT 架构。
  • 数据库索引构建:有些数据库在长整数键排序时会使用基数排序的思想,尤其是排序键是复合码(多个字段拼接成一个大整数)的情况。
  • 文本处理和后缀数组:构建后缀数组时,对字符串后缀按字典序排序,常用倍增 + 基数排序来优化复杂度。

如果你在 Python 业务代码里想排序整数,建议直接用sorted(),除非你正在学习或面试,否则手写基数排序没有实际收益。这一点必须坦白讲清楚,避免读者掉进“自己造轮子”的坑。

4.2 踩坑记录:内存与可变对象问题

基数排序的输入类型最好是普通整数或固定长度字符串,避免传递可变对象。如果元素是自定义对象,按某个字段排序,输出时需要把整个对象一起移动,稳定性依然保持,但内存使用双倍。如果对象很大,内存开销可能很吓人。

另外,不要试图修改输入列表再返回它,否则很容易出现 bug。我通常返回一个新列表,让原列表保持不变,这样更容易测试和调试。

4.3 面试中常见的追问

面试官问“请实现基数排序”时,通常还会追加几个问题:

  • 为什么要从最低位开始?答:LSD 每轮借用稳定排序,不需要递归,简单直接;MSD 需要递归分桶。
  • 基数排序是稳定的吗?答:取决于每轮桶内排序是否稳定。用计数排序实现时天然稳定。
  • 如何处理负数?答:偏移量方案或者正负数分开排。
  • 时间复杂度的 k 怎么理解?答:k 是位数,与数据范围和基数有关,不是常数。
  • 为什么基数排序不能替代快排?答:需要额外的空间,并且对数据类型有限制。

面试手写时,我推荐写最精简的非负整数版本,然后在注释里说明负数处理方案。不要一上来写几百行复杂版本,面试官主要看思路。

4.4 与计数排序、桶排序的关系

基数排序可以看作是迭代执行“计数排序”的过程。计数排序适合值域小的情况,比如成绩 0~100,直接开一个 101 大小的数组记录频次,再展开。但如果值域达到 1e9,计数排序就废了。基数排序把大值域拆成多个小块,每块用一次计数排序,用轮次换取可控的空间。

桶排序则是把数据均匀分布到若干桶里,然后对每个桶单独排序,如果数据分布不均匀,复杂度会退化。基数排序的桶是“按位强制分配的”,不管数据分布如何,每轮桶大小总和都为 n,不存在某个桶过大的问题(只要不是所有元素同一位相同,但这种情况下一轮只是保持原序,不会退化到 O(n²))。

4.5 常见错误速查表

错误现象原因解决办法
负数排序结果错乱Python 取模规则导致负数取余异常整体平移偏移量到非负区间
结果不稳定收集时从前向后遍历导致相同数字顺序反转必须从后往前遍历原数组
内存爆炸直接对每个数字创建桶列表使用计数数组一次定位
结果包含前导零干扰字符串或进制处理不规范补最小占位符或严格按位截取
排序速度比sorted()慢Python 层循环开销大放弃手写,使用内置排序,或改用位运算优化

5. 扩展思路:从 LSD 到 MSD 再到并行

如果你已经理解了 LSD 实现,可以进一步拓展自己的知识边界。

5.1 MSD 递归实现的基本框架

MSD 按最高位分桶后,递归处理每个桶。伪代码如下:

def msd_sort(arr, digit): if len(arr) <= 1 or digit < 0: return arr buckets = [[] for _ in range(base)] for x in arr: d = (x // (base ** digit)) % base buckets[d].append(x) result = [] for bucket in buckets: if len(bucket) > 1: result.extend(msd_sort(bucket, digit - 1)) else: result.extend(bucket) return result

这个版本比 LSD 更容易实现字符串排序,但递归深度和数据相关。最坏情况所有元素高位都相同,递归深度就是位数,可能超过 Python 默认递归限制,需要手工处理。

5.2 并行化:统计频次阶段可以拆开

在大数据处理中,基数排序非常适合分块并行。第一遍把数据拆分到多个 worker,各自统计每个 bucket 的频次,然后汇总,计算每个 bucket 的全局偏移,第二遍把数据重新映射到正确位置。这种两阶段“各统计各的 + 汇集写入”的模式非常类似 MapReduce。

Python 里用 multiprocessing 做这种并行会损失一部分性能,但思想上值得了解。如果你用 Cython 或 Numba 实现,加快循环速度后,基数排序的威力才能真正体现出来。

5.3 外部排序场景

如果需要排序的数据量超过内存,基数排序可以结合分块处理。假设你有 10 亿个整数,完全放不进内存,可以先读入若干固定大小的块,对每块按基数排序后写到临时文件,最后做多路归并。虽然基数排序本身不是外部排序算法,但它可以作为一种每块内部的快速排序方案。

这里要注意,外部归并阶段需要稳定合并,如果各个块内部有序,归并时只需比较当前块的最小元素即可。如果多个块的最小元素相等,需要维护稳定性,不过大多数场合下这不是硬性要求。

6. 写在最后:我在实际测试中的体会

对基数排序,我个人的体会是:不要只在教科书里理解它,一定要亲手写一遍、测一遍、踩一遍坑。第一次写出来被负数搞挂、被稳定性搞错,这些都是宝贵的记忆,比看十遍原理都深刻。Python 里做实验很合适,因为代码量小、调试直观,你可以在五分种内验证自己对稳定排序的理解。

如果你真的想在 Python 里追求排序性能,老老实实用内置sorted()。但如果你要深入理解底层排序、后续准备学习 C/C++ 或 GPU 算法,那么基数排序是一个绕不开的经典案例。从十进制基础版,到按字节优化版,再到 MSD 递归版,每往前一步,你对“数据如何移动”的认知都会更透彻。

最后再分享一个小技巧:调试基数排序时,我习惯打印每一轮“分配前”和“收集后”的数组,在数据量不超过 20 个时非常直观。不要只依赖最终正确性验证,过程可视化能让你快速定位是哪一轮出错,节省大量排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询