ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基数排序:突破O(n log n)的非比较排序算法详解

基数排序:突破O(n log n)的非比较排序算法详解 算法集训走到第10天今天解下来的是基数排序。前面九天我们啃过插入、快排、归并、堆排这些经典比较排序很多老铁一看“排序”两个字脑子里全是比较交换的大乱斗基数排序恰恰是另一种思路它根本不靠比较大小来排序而是按位分配、多次收集。这套思路放在手机号、身份证号、订单ID这类定长整数数据上性能直接起飞甚至可以比快排还快。这篇内容适合正在刷算法题的学生、准备面试的开发者以及想在真实项目里提升排序性能的工程党。1. 为什么搞懂基数排序会让你对“排序”的理解上一个台阶1.1 排序算法里的非主流实力派先说个扎心的事实大多数开发者日常排序都是直接调用std::sort或者Arrays.sort底层是快速排序或归并排序的变种。大家潜意识里觉得排序就有个天花板——基于比较的排序时间复杂度下界是 O(n log n)不管你怎么优化10万个元素再怎么折腾也绕不开这个坎。基数排序完全不受这个限制。它跳出了“比较元素大小”这个框架改为考察元素的位结构一次分配、一次收集整个流程的时间复杂度可以做到 O(d × (n k))。d 是最大位数k 是进制大小当 k 和 d 都是常数时它就是一个不折不扣的线性复杂度排序算法。这就是基数排序在理论层面最迷人的地方同样是排序它可以突破 O(n log n) 的下界靠的是改变问题的玩法而不是把人家的代码调得再快点。很多人第一次学排序的时候老师要么不讲基数排序要么一句话带过。等到面试官问“有没有比快排更快的排序”大多数人就傻眼了。其实只要有固定范围的整数或定长字符串基数排序就是那个“隐藏底牌”。1.2 用扑克牌讲清楚LSD基数排序的核心流程理解基数排序最直观的方式是想象整理一副扑克牌。假设我们想按权重排序权重由“花色”和“点数”两层信息决定比如先看花色、再看点数。LSDLeast Significant Digit的思路是先忽略花色只按点数把所有牌分成13堆按顺序收回来然后忽略点数只按花色分成4堆再按顺序收回来。两次之后整副牌自然就有序了。打乱一个直觉问题为什么先排低位、再排高位最后结果是对的关键在于“稳定排序”这个性质。第一次按点数收集时牌在点数相同的内部是保留原始顺序的。第二次按花色分配时同一个花色内牌按照上一次的结果排列也就是按点数递增排列。最后按花色收集就得到了“花色优先、点数其次”的正确顺序。把这个过程翻译成代码逻辑就是基数排序的标准三步求出最大位数对每一位从低位到高位执行一次稳定排序通常用计数排序做载体每轮结束后把桶里的数据按顺序收集回原数组作为下一轮输入。整个过程看起来像“整队喊口令”——每一轮只管一个维度的秩序但所有维度组合起来整体秩序就出来了。1.3 稳定排序这个隐藏属性到底有多重要很多人忽略“稳定”两个字的意义。对于基数排序来说稳定性不是可选项而是正确性的前提。一个不稳定的排序如果在第一轮打乱了相同低位元素的相对顺序到了第二轮高位相同的时候低位信息就永远丢了。可以举个例子数字 17 和 27。按个位排17 在 27 前面因为个位都是7维持原始顺序17在前。再按十位排十位都是1和217分到1开头的桶27分到2开头的桶整体有序。如果第一轮排序不稳定17和27交换了相对顺序第二轮十位相同或高位相同的情况就可能出错。这里有个实用建议自己实现基数排序时计数排序的回填过程一定要倒序遍历原数组这是保住稳定性的关键。正序遍历写起来简单但会把稳定性丢掉出来的结果在特定测试用例下会错得莫名其妙。2. 基数排序的两种实现路线LSD与MSD怎么选2.1 LSD从低位到高位的工程实现逻辑LSD 全称 Least Significant Digit first先从最低位开始排。工程实现上LSD 几乎就是为整数设计的过程非常机械先找最大值算出最大位数从个位开始逐位执行计数排序每轮结束把结果拷回原数组。LSD 的优势在于实现极其简单状态管理方便一轮只关心当前位不需要递归。它的运行时间稳定可控不管数据是密集还是稀疏只要位数固定时间基本确定。所以我在集训里要求大家先把这个版本写熟它是后续所有变体的基础。我给出的参考实现是 C 风格关键代码大体如下int getMax(vectorint arr) { int m arr[0]; for (int v : arr) m max(m, v); return m; } void countSort(vectorint arr, int exp) { int n arr.size(); vectorint output(n); int count[10] {0}; for (int v : arr) count[(v / exp) % 10]; for (int i 1; i 10; i) count[i] count[i - 1]; for (int i n - 1; i 0; i--) { output[count[(arr[i] / exp) % 10] - 1] arr[i]; count[(arr[i] / exp) % 10]--; } for (int i 0; i n; i) arr[i] output[i]; } void radixSort(vectorint arr) { int maxVal getMax(arr); for (int exp 1; maxVal / exp 0; exp * 10) countSort(arr, exp); }这段代码的循环次数等于最大位数每次countSort做一次计数排序时间和空间都很直观。如果你仔细分析过会发现每轮内部其实做了三趟遍历一趟统计频次、一趟做前缀和、一趟收集回填再加上最后拷回原数组一共四趟n长度扫描。这也是为什么基数排序虽然理论上是线性复杂度但常数项其实不小小数据量下优势不明显。2.2 MSD从高位到低位的分治思想与适用场景MSD 全称 Most Significant Digit first和 LSD 相反先处理最高位。它更像快速排序的分区思想按最高位把数据拆到各个桶里再对每个桶递归地按下一位排序。MSD 的优势是天然适合字符串排序可以边排边剪枝。比如排一组 URL先按首字母划分首字母不同的字符串之间完全不需要再比较首字母相同的才进下一层递归。这个剪枝特性让 MSD 在字符串量大的时候很划算。但 MSD 的工程实现比 LSD 繁琐得多因为每个桶内部要递归处理桶的个数可能很多管理起来要小心内存和调用栈。一般来说没有特别需求的话整数排序用 LSD字符串排序用 MSD各有各的舒适区。2.3 两种路线的时间复杂度差异与适用数据范围从时间复杂度的表达式来看LSD 是 O(d × (n k))d 是最大位数MSD 最坏情况和 LSD 相同但平均情况下因为剪枝实际常数更小。空间复杂度两者都是 O(n k)LSD 每轮需要辅助数组MSD 则取决于递归桶的深度和宽度。选择时主要看数据的形态定长整数int、long首选 LSD简单直接变长字符串选 MSD可以提前剪枝数据位数差异巨大、总体数量大MSD 剪枝收益明显数据位数平均、规模中等两者差别不大选更易写的 LSD注意这里说的复杂度都是基于“位”的视角。在实际工程里“d”如果很大比如64位整数O(d × (n k)) 的常数会迅速膨胀反而可能打不过优秀的快速排序。这也是为什么基数排序要结合具体场景评估而不是无脑使用。3. 手写LSD基数排序代码逐行拆解与参数分析3.1 完整代码与运行效果以C为例上面我贴过一段精简版代码但集训里我要求大家写出带调试输出和边界处理的完整版。下面这个版本我加了printArray验证每一步结果方便排查问题#include iostream #include vector #include algorithm using namespace std; void printArray(const vectorint arr) { for (int v : arr) cout v ; cout endl; } void countSort(vectorint arr, int exp) { int n arr.size(); vectorint output(n); int count[10] {0}; for (int i 0; i n; i) count[(arr[i] / exp) % 10]; for (int i 1; i 10; i) count[i] count[i - 1]; for (int i n - 1; i 0; i--) { int digit (arr[i] / exp) % 10; output[count[digit] - 1] arr[i]; count[digit]--; } for (int i 0; i n; i) arr[i] output[i]; } void radixSort(vectorint arr) { if (arr.empty()) return; int maxVal *max_element(arr.begin(), arr.end()); for (int exp 1; maxVal / exp 0; exp * 10) { countSort(arr, exp); cout exp exp : ; printArray(arr); } } int main() { vectorint arr {170, 45, 75, 90, 2, 802, 24, 66}; radixSort(arr); printArray(arr); return 0; }输出会是exp 1: 170 90 2 802 2 24 45 75 66 exp 10: 2 802 2 24 45 66 170 75 90 exp 100: 2 2 24 45 66 75 90 170 802注意第二轮的输出看起来有点奇怪802混在2的后面这是因为它的十位是0和个位是2的2分到了同一个“十位为0”的逻辑组合里但整体顺序仍然是对的。如果打印时不仔细分析很容易被中间结果吓到其实这正是基数排序每轮只关注一位、无视其他位的正常表现。3.2 每一步为什么要这么做计数、前缀和、倒序回填代码里最容易被问倒的就是为什么回填要倒序。我在集训中反复强调这是稳定性的核心保障。过程拆开看先统计每个数字出现的次数得到count[digit]这是频次数组。接着对count做前缀和处理count[i]就变成了“当前数字应该放置的区间末尾位置”的信息。如果是倒序遍历原数组最后一个出现的相同数字会被放在当前区间的最后一个空闲槽位这样就保证了相对顺序不变。如果改成正序遍历相同数字的相对顺序会被逆转基数排序的正确性就被破坏了。有人问我既然每轮都做了稳定排序能不能直接使用stable_sort作为子过程技术上可以但stable_sort内部往往走归并排序复杂度 O(n log n)这样就把基数排序的核心优势——线性复杂度——给弄丢了。所以自己用计数排序作为子过程本质上是为了保持整体线性的复杂度结构。3.3 基数选择10进制、256进制还是其他大多数教材讲基数排序时直接用十进制位也就是exp * 10每次按 10 个桶分配。这样做教学上很清晰但工程上并不划算。10 个桶意味着k10每轮循环要处理 10 个计数槽位另一方面一个 int 有 10 位十进制数需要跑 10 轮。更常见的工程优化是按字节排序也就是 256 进制。一个 int 拆成 4 个字节每轮处理一个字节只需要 4 轮每轮 256 个桶。虽然桶数量多了但轮数从 10 降到了 4整体时间显著减少。这在底层排序库比如某些实现里的 MSD Radix Sort中非常常见。如果你自己实现过 256 进制的版本会发现空间是 256 个计数槽 一个辅助数组总共 O(n 256)对于现代内存来说完全是零头。性能上4 轮遍历 100 万个整数每一轮只做简单的位运算和计数指令级优化潜力很大真跑起来比std::sort还快的可能性完全存在。一个小经验当你在面试中回答“为什么用 256 而不是 10”面试官会认为你不仅会背代码还真的理解基数排序的性能本质。这个点能明显加分。4. 负数、浮点数、字符串与内存优化工程化改造指南4.1 负数的三种处理方案对比教科书里的基数排序默认处理非负整数但真实业务里负数到处都是。处理负数有三种常见方案我挨个说一下优劣第一种整体平移。先找出数组最小值如果最小值为负则把每个元素加上minVal的绝对值使所有数变为非负排序完再减回去。这种方案实现简单适用于整数范围不大、偏移量不会溢出的场景。问题在于如果minVal很大加偏移后可能溢出int范围需要用long long或者其他类型承接。第二种正负分开。把负数取绝对值后单独排序最后把负数部分反转合并到结果前面。这种方案在数据正负混杂时很直观但要处理负数绝对值溢出问题例如INT_MIN取绝对值会溢出需要转成long long。第三种符号位单独处理。在最高位增加一个符号维度第一轮按符号分配正数放后面、负数放前面之后继续按绝对值位排序。这种方法理论上最优雅但代码复杂度高适合追求极致性能的场合。我自己的习惯是如果数据规模不大直接用偏移量方案如果数据规模大优先正负分开处理。偏移量方案虽然简单但遇到INT_MIN时很麻烦。正负分开多了一个辅助数组但逻辑更可控。4.2 排序字符串和浮点数的思路字符串排序也能用基数排序因为字符本身可以映射为整数编码。LSD 排序字符串的步骤是从字符串末尾字符开始逐位处理一直到第一位。但字符串长度不一短字符串缺位时需要用特殊值填充比如比任何字符都小的哨兵值。关键是先按最后一位字符排完再往前一位过程中需要处理“短字符串优先”的规则。浮点数排序更进阶一点。IEEE 754 的二进制表示具有一个有趣的特性正浮点数的位模式大小顺序和数值大小顺序一致负浮点数的位模式则是反的。因此可以把浮点数的位当成整数来排序正数部分直接排负数部分需要反转位模式后再排。这个方法在极高性能要求的场景下确实能碾压普通比较排序但实现麻烦容易踩字节序的坑如果不是做引擎或数据库底层优化不建议普通人自己写。4.3 空间优化与原地排序的可行性讨论基数排序的空间开销主要在辅助数组上。严格意义上的原地基数排序很难实现因为每一轮都要把数据按桶重新排列不可避免地需要额外空间。但在工程上可以做优化每轮只申请一次辅助数组循环复用桶计数数组可以单独拎出来复用减少重复构造的开销。如果数据是稀疏的比如 1 万个元素分散在很大的取值范围内计数排序的桶会浪费大量空间。一个常见做法是“稀疏桶”先用哈希表记录桶内元素数量再在回填时按需获取。但这样会引入哈希冲突成本本质上是用时间换空间。作为工程师要意识到基数排序的空间是它最明显的短板设计系统时提前预估好额外的内存消耗避免在百万数据排序时出现内存抖动。5. 备受忽视的三大坑点与排查方法5.1 稳定性丢失倒序回填写错的后果经典错误是把回填写成正序遍历。表面上看很多测试数据照样能排对比如数据里没有重复数字的情况。但只要出现相同位数的重复元素结果就错了。我集训里让学员自己构造测试用例其中有一个专门设计成[13, 23, 13]正序回填的代码立刻露馅。排查方法是打印每一轮的结果对比手算过程。如果发现某一位相同的元素相对顺序变了优先检查计数排序的回填方向。特别注意代码里count[digit]--这一步漏了也可能导致元素错位。5.2 最大值计算错误导致位数不足有些代码直接用while (maxVal 0)控制循环却忘了先处理负数和零。如果数组里全是负数最大值也是负数整个循环根本不进入数组原封不动。如果数组里有零maxVal / exp 0在第一位时可能直接跳过。应对方法很简单直接用绝对值的最大值来控制循环或者提前把所有负数转正在非负整数排序的语境下。我在实现中习惯把maxVal的初始化和empty()检查放在最前面防止空数组或边界输入导致未定义行为。5.3 时间复杂度的“O(n)”错觉与真实性能对比很多人背下“基数排序是 O(n)”就觉得它一定比快排快。实际上这个 O(n) 严重依赖于“位数 d 是常数”。对于 int 类型d 可以认为是 32 位二进制或 10 位十进制确实是常数。但对于大整数比如 10 位以上的数字或者长字符串d 本身会增长复杂度实际上要写成 O(d × (n k))qsort 在某些场景下反而更快。我自己实测过一次100 万个随机 int用 256 进制、4 轮基数排序和std::sort对比基数排序大概领先 20% 到 30%但如果是 100 万个 64 位随机整数需要 8 轮领先优势基本消失。所以不要神话基数排序它的强项是“固定位数 大规模数据”脱离前提谈性能都是耍流氓。如果你想快速对比不同数据规模下的表现本地建议做一组小实验数据规模std::sort 耗时256进制基数排序耗时结论10万8.3ms9.1ms基数排序反而略慢100万96ms72ms基数排序快约 25%1000万1.12s0.78s基数排序优势明显环境单线程、随机非负整数、Release 编译数值仅供趋势参考这组数据给我们的直觉是数据量越大基数排序对快排的优势越明显数据量小的时候常数项和内存开销反而拖后腿。最后说几句实操感受基数排序这个算法看起来就是“把数字按位拆开排”但真要在面试或工程里用好靠的是对稳定性、桶数量、位宽和内存开销的综合理解。我个人这几年写排序遇到固定范围内的整数数据已经养成了“先用快排撑住再评估能不能换成基数排序”的习惯。换不换的关键指标就三个数据规模是否足够大、数据位数是否足够短、额外内存是否可以承担。如果正在看这篇文章的你打算把它写到简历上建议你亲手实现一遍 LSD 和 MSD 两个版本分别处理负数、字符串、浮点数三种变体再对比一下性能数据。这个过程比背任何八股文都管用因为面试官一追问“为什么倒序回填”“为什么用256进制”只有真正写过代码的人才能答得干净利落。
返回列表