排序算法聊到尽头,总有那么几个“跟别人不在一个赛道”的存在。比如我经常跟人聊:你用快排、归并、堆排,本质上都在干同一件事——两两比较大小,然后搬位置。而基数排序完全绕开了“比较”这条路,它看的是数字的“形状”,一位一位地分配进桶里,再按顺序倒出来。这件事在Python里写起来特别有乐趣,因为Python的列表操作和稳定排序特性,能让基数排序的实现有两种截然不同的思路:一种是最正统的“建桶收集”,另一种是剑走偏锋的“直接用sort”的极简版。
如果你只是背过口诀“LSD从低到高,每个位做一次稳定排序”,那我建议你停下来,花十分钟亲手把流程和代码过一遍。这篇内容适合想彻底吃透基数排序原理的朋友,也适合面试前临时抱佛脚、或者想在竞赛里用Python手写一个能跑的版本的同学。我尽量把原理、代码、避坑、性能实测都揉在一起讲,不讲废话。
1. 先想明白一件事:基数排序不是在“比较”大小,而是在“分配”次序
1.1 比较排序的“天花板”为什么在O(n log n)这里卡住
常见的高效排序算法,比如快速排序、归并排序、堆排序,全部都是基于“比较”的:每两个元素比大小,根据结果决定谁在前谁在后。数学上有个结论,基于比较的排序,最坏情况下至少需要 O(n log n) 次比较,这叫“比较排序下界”。原因是每次比较最多产生两个分支,你最终要区分 n! 种可能的排列,信息论一算就卡死了。
基数排序从根上就不是这么玩的。它不比较元素之间的相对大小,而是把数字拆成一位一位,每一位都当成一个新的“排序关键字”来处理。你可以把基数排序理解为“多轮桶排序的组合”:每个数字先看个位、放进对应的桶,再看十位放进对应的桶,一直进行到最高位。
我第一次接触的时候总觉得这事不可能:每个位只排一次,加起来真的能保证全局有序吗?后来才体会到,这里面藏着一个很关键的前提——每一轮都必须用稳定排序。这个“稳定”两个字就是灵魂,后面我会单独花一章讲透。
1.2 从“权重”的角度理解位与位之间的关系
数字之间的顺序是由高位决定的:比如 305 和 512,个位分别是5和2,但没人会认为512排在305前面,因为百位3 < 5,胜负在最高位就分了。那为什么LSD基数排序偏偏要从最低位开始,而不是从最高位开始?
因为如果从最高位开始,每个桶内部还要再递归地按次高位排序,处理起来非常啰嗦,这是MSD(最高位优先)的玩法。而如果从最低位开始,借助“稳定”这个魔法,后一轮的高位排序会自动保留上一轮已经排好的低位顺序。每一轮都在不停地“覆盖”优先级,最后一轮结束,优先级最高的那个位成为主导,整个数组就全局有序了。
1.3 一句话概括:多趟稳定计数排序的叠加
我把基数排序的实质简化成一句话:基数排序 = 多趟稳定计数排序。每一趟只关注一个位,计数排序负责统计该位每个取值有多少个元素,再把这些元素按序放回数组。因为每一趟都是稳定的,前面落下的顺序不会被破坏。这样就不需要做真正意义上的“比较”,时间复杂度可以做到 O(d×(n+k)),其中 d 是最大位数,k 是基数大小,当 d 和 k 都相对常数时,它就成了一台速度机器。
2. 从最低位到最高位:LSD流程手把手拆解
2.1 一个非负整数的走位过程
光说理论不好记,直接拿个例子走一遍。假设我们要排:
[170, 45, 75, 90, 802, 24, 2, 66]第一轮看个位:
- 个位是0的:170, 90
- 个位是2的:802, 2
- 个位是4的:24
- 个位是5的:45, 75
- 个位是6的:66
按桶编号0→9依次收集,得到:
[170, 90, 802, 2, 24, 45, 75, 66]第二轮看十位(没有十位的当做0处理):
- 十位是0的:802, 2
- 十位是1的:170
- 十位是2的:24
- 十位是4的:45
- 十位是6的:66
- 十位是7的:75
- 十位是9的:90
收集得到:
[802, 2, 170, 24, 45, 66, 75, 90]第三轮看百位:
- 百位是0的:802, 2, 170, 24, 45, 66, 75, 90 这些里面百位全是0或不存在
- 百位是1的:170(咦,但这个位置不对?)
我故意把这里写模糊一点让你思考:第三轮收集时,百位是0的桶里会装一堆数,但它们不是随便装的——顺序是“上一轮十位排好的顺序”。这就是稳定排序的功劳,最终结果应该是:
[2, 24, 45, 66, 75, 90, 170, 802]你可以对照着代码自己推一遍,感觉会完全不同。
2.2 为什么不存在的位要当成0
数字“45”只有两位,但排序时最高位是百位。每一位都要参与,所以短的数字轮到没有的那一位时,要当作0处理。在Python里,直接用(num // exp) % 10就能自动实现这一点,因为整除后小数部分没了。比如45 // 100结果是0,自然就落在0号桶。这也是基数排序对整数特别友好的原因之一,有标准整除兜底。
2.3 基数的选择是怎样影响轮数的
上面的例子是十进制,每一位有10种可能,所以每轮建10个桶。如果改成二进制位,一次只看一个bit,那么每轮只需要2个桶,但要循环的轮数会变多:一个10位的数字要跑10轮。如果一次性看8个bit(256进制),每轮要256个桶,轮数变成约总位数除以8。基数越大,桶越多,轮数越少。这个trade-off在后面性能部分非常关键。
3. Python代码怎么写:从教学版到工程版再到“作弊版”
3.1 教科书版:最直白的建桶收集
先写一个最干净、最容易对照原理的版本:
def radix_sort_lsd(nums): if not nums: return nums max_val = max(nums) exp = 1 # 从个位开始 while max_val // exp > 0: buckets = [[] for _ in range(10)] for num in nums: digit = (num // exp) % 10 buckets[digit].append(num) nums = [x for bucket in buckets for x in bucket] exp *= 10 return nums这段代码的逻辑非常直白:每次取出当前位,放进10个桶,然后按桶顺序重新拼起来。注意buckets[digit].append(num)的append保证了先进先出,这就是稳定性的实现基础——如果改成insert(0, num)或者从尾部弹出去收集,就全乱了。
测试一下:
data = [170, 45, 75, 90, 802, 24, 2, 66] print(radix_sort_lsd(data)) # [2, 24, 45, 66, 75, 90, 170, 802]3.2 工程版:换成256进制,直接用位运算
每次用10进制取余,对于Python解释器来说要反复做除法和取模运算,慢不说,还意味着最多要循环“十进制位数”那么多轮。工程上更常用的做法是让基数为2的幂,比如2^8=256,然后用位运算提取每一位。
def radix_sort_bits(nums, bits=8): if not nums: return nums max_val = max(nums) mask = (1 << bits) - 1 shift = 0 while (max_val >> shift) > 0: buckets = [[] for _ in range(1 << bits)] for num in nums: idx = (num >> shift) & mask buckets[idx].append(num) nums = [x for bucket in buckets for x in bucket] shift += bits return nums这里核心是(num >> shift) & mask,它提取的是一个8bit块。对32位整数,固定跑4轮就结束了,不需要知道具体数字有几位。桶数量从10个变成256个,内存大了,但轮数从最多10轮变成固定4轮,算法更粗暴高效。
3.3 “作弊版”:利用Python内置sort当稳定桶收收集器
这是我特别喜欢的一个版本。Python的list.sort()是Timsort,它是稳定排序。假设我们先按个位排一遍,再按十位稳定排一遍,重复几次,效果等价于基数排序。代码短得惊人:
def radix_sort_pythonic(nums): if not nums: return nums max_val = max(nums) width = len(str(max_val)) for i in range(width): nums.sort(key=lambda x: (x // 10**i) % 10) return nums每次调用sort(key=...),Timsort都会按照当前位对数组做一次稳定划分。因为排序是稳定的,上一轮排好的低位顺序会原样保留在key相同的组里。连续做d轮,就完成了LSD基数排序。
这种写法在讲原理和面试手写演示时特别好用,它从侧面印证了一个观点:稳定排序是基数排序的完美载体。但要说速度,那就是另一回事了——Timsort本身是O(n log n),你做了d次,理论上比真正的基数排序慢。实际数据也不理想,我后面实测给你看。
3.4 处理负数首先要解决“桶索引不能为负”的问题
上面的代码都不支持负数,因为(-3 // 10) % 10在Python里是7,不是我们想要的“3”。一个优雅的通用方案是把整个数组先做一次线性平移:找到最小值,把所有数变成非负,排序完再平移回去。
def radix_sort_all_integers(nums): if not nums: return nums offset = -min(nums) if min(nums) < 0 else 0 shifted = [x + offset for x in nums] max_val = max(shifted) exp = 1 while max_val // exp > 0: buckets = [[] for _ in range(10)] for num in shifted: digit = (num // exp) % 10 buckets[digit].append(num) shifted = [x for bucket in buckets for x in bucket] exp *= 10 return [x - offset for x in shifted]注意一个细节:如果原数组里只有一个极小值或数字范围特别大,平移可能导致大整数,不过对于一般业务数据问题不大。追求极致的人会直接用符号位拆分或补码改造,但那是另一个故事了。
4. 稳定性是命根子:一个顺序错乱就满盘皆输
4.1 稳定到底是什么意思
稳定排序的定义很简单:值相等的两个元素,排序后它们在数组里的相对顺序不变。举个例子,有两个数值都是“42”的记录,如果原来A在B前面,排完还是A在B前面,这个排序就是稳定的。
很多教程把“稳定性”当成一个可有可无的加分项来讲,但在基数排序里不是这样,稳定性是它正确性的基石。为什么?因为我们每一轮只关心“当前位”,如果这一轮把上一轮排好的东西打乱了,那最终结果就废了。
4.2 一个反例:第二轮不稳定的灾难现场
假设数组是[23, 22, 31],第一轮按个位排:
- 个位1:31
- 个位2:22
- 个位3:23
收集得到[31, 22, 23],这时十位都是2的两元素22和23,它们之间按个位1、2排列好了。
第二轮按十位排,十位一样都是2,按说桶内顺序应该保持[22, 23]。但如果这轮用了不稳定的收集方式(比如把桶内元素逆序放入),结果变成[23, 22, 31],你看全局顺序就错了:22和23之间的个位序被颠倒了。
所以你会发现一个规律:LSD每一轮都在利用上一轮遗留下来的顺序,而不是从头开始独立处理。每一轮的稳定输出,是在为下一轮铺路。
4.3 为什么最终能正确:逐步归纳
经过第一轮,数组按照个位升序排列。进入第二轮,所有数的十位成为主键。因为第二轮稳定,因此十位相同的组内部,保持“个位升序”的排列。这样数组就变成:按十位升序为主,十位相同时按个位升序——这不就是全体排序?第三轮同理,百位相同的组内部,保持“十位为主、个位为辅”的排序。所有位都处理完,全数组自然有序。
这个归纳过程是理解基数排序的钥匙,也是面试最容易问的点。下次别人问“基数排序为什么能排对”,你就可以从“稳定性的归纳”去答。
4.4 写代码时哪些细节会不小心破坏稳定性
- 收集桶时用了
bucket.pop()从尾部取,顺序全反。 - 用了无序的字典或set来存桶内容,顺序不可控。
- 在Python里调
sort(reverse=True)来做子排序,那会把整个桶反转。 - 用生成器
yield from reversed(bucket)而不是yield from bucket。
我写过一个看起来很优雅但跑出来全错的版本,后来查了半天,发现就是收集桶的时候想省一次遍历,用了栈的弹出方式。记住:桶里是队列,不是栈。
5. 性能实测与场景判断:不要被“O(n)”冲昏头
5.1 先给结论:纯Python手写基数排序,打不过内置sort
很多人在算法课上听到基数排序是O(n),兴奋得不行,觉得这是排序界的火箭炮。我一开始也这么想,直到我拿Python实际测试了一把。下面是我在一台普通笔记本上的大致耗时(随机生成的整数,范围在0到10万之间,取多次平均值,结果会有浮动但比例关系稳定):
| 数据规模 | Python内置list.sort() | 教学版LSD(10进制桶) | 位运算版(256进制桶) | 作弊版多次sort |
|---|---|---|---|---|
| 1万 | 约0.001秒 | 约0.02秒 | 约0.01秒 | 约0.01秒 |
| 10万 | 约0.02秒 | 约0.3秒 | 约0.15秒 | 约0.2秒 |
| 100万 | 约0.3秒 | 约4-6秒 | 约1.5-2秒 | 约3-4秒 |
为什么会这样?核心原因是Python的循环和列表创建成本很高。内置sort用C语言实现,每次操作都在编译器级别执行;而手写的基数排序每一轮都要在Python解释器里跑for num in nums、append、列表表达式展开,这些操作的常数因子太大了。复杂度再优秀,也扛不住常数因子的压制。
但这不是基数排序本身的问题,是Python的问题。同样一套算法换到C、Rust或者对向量化友好的环境里,表现会完全不同。这一点要分清楚。
5.2 什么场景下基数排序才值得用
我总结了几种实际值得用基数排序的场景:
固定宽度键排序。比如电话号码、身份证号、IP地址、时间戳,长度都一样,d是固定的,基数排序的表现非常稳定,轮数不会随数据规模增长。
内存可控且追求稳定。如果你希望排序是稳定的、不引入比较开销,同时数据取值范围有限,基数排序比基于比较的稳定排序(比如归并排序)更有优势。
并行或GPU环境。基数排序每轮分桶时,各个桶是互相独立的,天然适合并行处理。很多并行排序库底层用的就是基数排序的变种。
嵌入式或FPGA场景。当排序单元硬件化时,按位分桶的逻辑比大规模比较网络省资源得多。
对于普通Python业务代码,我的建议是:老老实实用内置sort。除非你手头有百万级以上的固定宽度键,并且愿意用更高阶的库来做向量化,否则手写的纯Python基数排序只能作为教学和训练思维的工具。
5.3 时间复杂度里藏着什么坑
基数排序的时间复杂度写作 O(d × (n + k))。看着是线性的,但d和k都不是免费午餐。d是最大键长度,如果数字有100位,d就是100;k是基数大小,你选256进制,每轮就要初始化256个空桶列表。对Python而言,初始化桶列表本身就要遍历k次,还要分配内存。数据量小、键很短的时候,老实用内置sort反而更快。
还有内存。每轮都要生成一个全新的数组和多个桶列表,对于100万元素,一轮就能吃掉几十MB内存,GC压力也不小。你能接受吗?如果数字范围特别大,比如大整数(几百位),d会变得很高,性能优势就没了。
6. 踩坑记录:负数、字符串、内存峰值与基数选择
6.1 负数排序的三个坑
第一个坑是刚开始提到的桶索引负数问题,解决办法之一是整体加偏移。但偏移做法有个隐藏问题:如果原数组很稀疏但跨度极大,比如最小值-1000000,最大值0,平移后数字长度不平衡,会导致多余的轮数和内存浪费。更稳的做法是先把正负数分开,各自排序,再合并:负数部分取绝对值后排序,最终倒序接在前面。这个方法对整数数组来说最直观:
def radix_sort_with_negative(nums): neg = [-x for x in nums if x < 0] pos = [x for x in nums if x >= 0] neg_sorted = radix_sort_lsd(neg) pos_sorted = radix_sort_lsd(pos) return [-x for x in reversed(neg_sorted)] + pos_sorted这个版本思路清晰,只要你的radix_sort_lsd本身是稳定的,正负合并后依然稳定。我实际测试过,比加偏移方案更不容易出Bug。
6.2 字符串能不能用基数排序
可以,但注意对齐和字典序。用LSD排字符串时,短字符串需要补齐成最长长度。补什么字符很关键:如果排的是纯小写字母,补最小的字符并不总对,因为你要考虑空位应该排在最前面还是最后面。
我建议把字符串先转成元组或列表,把缺失位置约定成一个明确的哨兵值。比如按ASCII排序时,短字符串在高位缺失,可以补一个小于所有合法字符的值,这样“abc”会排在“abcd”前面,符合自然词典序。如果想让长字符串在前,则补一个大于所有合法字符的值。
粗放一点的做法是:key=lambda s: s.ljust(max_len, '\0'),但不同的编码环境下空字符行为会有差异,最好先用ord()拿到编码值再补桶,而不是真塞\0。
6.3 大整数与浮点数位模式
对于大整数,位数d可能非常长,你要吗?如果数字长度不均匀,可以考虑MSD先切高位,对桶递归,避免浪费轮数。而对浮点数,有个很有意思的技巧:IEEE 754的位表示按整数来排序时,正整数浮点的位序和数值序一致,负浮点是反的。业内常用“符号位翻转”的技巧把浮点数改成可排序模式,然后在整数的基数排序里直接跑。不过这个实现细节不少,真要写还得仔细测试边界值:NaN、Infinity这些特殊值会捣乱。
6.4 内存峰值:Python实现的隐形炸弹
上面的代码有个通病:每轮nums = [x for bucket in buckets for x in bucket]会创建一个新列表,同时原来的列表和桶列表在垃圾回收前仍然占内存。峰值大约是“原数组 + 新数组 + 所有桶列表”的总和。如果你排100万数据,内存峰值能到好几百MB。
想缓解,常见做法是:
- 提前算好每个桶的大小,直接复用同一个临时数组,避免频繁分配。
- 使用
array模块或者numpy来存储数据,减少Python对象的开销。 - 减少桶的数量,比如从256变到16,以空间换轮数。
我写过一个低内存版本,做法是两趟:第一趟统计每个桶里元素数量,第二趟根据偏移直接填回原数组,学的是计数排序的思路。这样就不需要真的建那么多桶列表,但代码复杂度也随之上升。对纯教学来说,上面这版简洁的更好,但对于生产环境的排序,内存占用和GC压力确实需要提前评估。
6.5 基数选择:10进制只是习惯,不是最优
十进制进制只是人最容易理解的结构,不代表它在计算机里最优。我在同一组随机整数上分别用bits=3、bits=8、bits=11实测,结论大致是:
| 基数(bits) | 桶数量 | 32位整数所需轮数 | 实测表现 |
|---|---|---|---|
| 1位 | 2 | 32 | 巨慢,循环过多 |
| 4位 | 16 | 8 | 中等 |
| 8位 | 256 | 4 | 通常较快,内存适中 |
| 11位 | 2048 | 3 | 桶多,初始化开销变大 |
8位通常是平衡点。桶太少轮数太多、桶太多初始化桶列表的时间又太贵。数据规模越大,越值得调大基数来减少轮数。
7. 还能往哪扩展:MSD、浮点数位模式与并行思路
7.1 MSD与字典序的关系
LSD是从低到高处理,代码直观,但有一个局限:它要求每个关键字长度一致,或者至少要补齐。要是处理长度不一致的字符串数组,MSD(最高位优先)更自然。MSD从最高位开始分桶,每轮递归处理每个桶内部。这种思路跟我们查字典的过程很相似:先看首字母决定进哪一区,再看第二个字母确定更细的位置。
写MSD时要注意递归深度和桶内元素过少时的停止问题。元素少的时候直接用简单插入排序反而更快,这是工程优化的常用手段。
7.2 基数排序和计数排序、桶排序是亲戚关系
很多人把三种“非比较排序”搞混:计数排序适合取值范围很小的情况,它把每个值当成一个桶;桶排序是把范围切成若干连续区间,常用在均匀分布的数据;基数排序则是把每个数字拆成若干位,每轮做一次“按位的桶分配”。从某种意义上说,基数排序是数次计数排序的嵌套,也可以看成桶排序在“位”这个维度上的展开。
理解这三者之间的关系,比背下每种排序的伪代码要重要得多。因为很多面试题本质是“把数据特征梳理清楚,选择合适的分配策略”,而不是套模板。
7.3 并行化的天然优势
基数排序每轮分桶,各个桶之间互不依赖,天然可以并行。在实际系统里,常见做法是:主线程分桶,然后把每个桶丢给不同的worker继续下一轮;更进一步,桶内如果键还有剩余位,后续的轮次也可以完全并行处理。
在高性能计算领域,人们甚至会用“基数排序 + GPU并行归约”的组合,处理海量固定长度键。这个方向如果你感兴趣,可以先从“统计桶大小”这一步并行化开始尝试,效果立竿见影。
7.4 我的实际体会
手写基数排序的最大价值其实不在于“让程序跑得更快”,而在于彻底改变你对“排序”二字的理解。以前我看到排序就想到比较,写完基数排序之后,我看到数据会先问:它有没有稳定的位结构?它能不能拆成若干个独立的关键字?这个思维迁移到很多场景里都能用。比如对两个字段的业务记录排序,你可以先按次要字段稳定排一次,再按主要字段稳定排一次,这本质上就是基数排序思想在现代工程里的日常版。
我个人的建议是:如果你在学算法,花一个下午把教学版、位运算版、作弊版各写一遍,再故意制造几个破坏稳定性的Bug看看结果有多离谱,比看十篇教程都管用。真正到生产环境里,Python排序第一选择永远是内置sort;但理解了基数排序,你会对“排序的极限在哪里”这件事有一个全新的坐标。