
1. 从一次线上事故说起为什么稳定性不是纸上谈兵三年前我接手过一个日志分析模块的重构需求本身不复杂把千万级的请求记录按响应时间分桶统计同时保留同一时间桶内请求的原始先后顺序。当时团队里一位同学图省事直接用计数排序把数据按响应时间排了一遍结果上线第二天业务方就找过来了——同一个时间桶里的请求顺序全乱了导致后续的链路追踪对不上号。问题就出在计数排序的稳定性上。这件事让我意识到很多人对计数排序的理解停留在“O(nk) 很快”这个层面却忽略了它是否稳定完全取决于实现方式。计数排序的稳定性不是一个可有可无的学术概念它直接决定了你的排序结果能不能作为后续处理的可靠输入。这篇文章我就把计数排序稳定性的来龙去脉拆开讲清楚为什么朴素实现不稳定、怎么改造成稳定版本、稳定版本在什么场景下必须用、以及实际写代码时容易踩的坑。不管你是刚学算法的大学生还是工作几年需要补基础的工程师看完都能直接上手改自己的代码。先给个结论定调计数排序可以做到稳定但前提是你必须用“前缀和 逆序回填”这套写法。只统计频次然后顺序输出的那种朴素版本是不稳定的。下面我从头拆。2. 计数排序到底在排什么核心思路与适用边界2.1 非比较排序的本质用空间换时间的极端案例我们平时用的快排、归并、堆排都属于比较排序它们的下界是 O(n log n)这个下界是被数学证明锁死的。计数排序走的是另一条路它根本不去比较任意两个元素的大小而是直接利用“元素的值本身就是索引”这个特性把每个值出现的次数数出来然后按值从小到大依次还原。打个生活化的比方。比较排序像是让一群人两两比身高然后排队而计数排序像是准备一排从矮到高的格子每个人看一眼自己的身高直接站进对应格子里最后从矮格子到高格子依次把人请出来。谁快谁慢一目了然——当身高范围很小而人数很多时格子法碾压两两比较。这就是计数排序的核心时间复杂度 O(n k)其中 n 是元素个数k 是值的取值范围。注意这个 k它是计数排序的命门。如果 k 远大于 n比如你要排 100 个数但数值范围是 0 到 10 亿那开一个 10 亿长度的计数数组纯属自杀。所以计数排序的适用边界很明确数据量大但值的范围相对集中比如年龄、分数、状态码、响应时间分桶元素必须是可离散化的整数或者能一一映射到整数对稳定性有要求时必须用稳定版本2.2 朴素计数排序快但不稳定先看最朴素的写法这也是很多教材第一版给的代码void countingSortNaive(vectorint arr) { if (arr.empty()) return; int maxVal *max_element(arr.begin(), arr.end()); int minVal *min_element(arr.begin(), arr.end()); int range maxVal - minVal 1; vectorint count(range, 0); for (int x : arr) count[x - minVal]; int idx 0; for (int v 0; v range; v) { while (count[v]-- 0) { arr[idx] v minVal; } } }这段代码逻辑上没问题排序结果也是对的但它不稳定。为什么因为它只记录了“值 v 出现了几次”完全丢掉了“这些 v 原来分别在哪个位置”的信息。回填的时候是从头到尾按值顺序写的原来在前面的那个 v 和后面的那个 v回填后谁前谁后完全看运气——实际上是被强行按值归并了原始相对顺序荡然无存。我用一个具体例子说明。假设原始数组是[3, 1, 3, 2, 1]其中第一个 3 我们叫它 3a第二个叫 3b。朴素排序后得到[1, 1, 2, 3, 3]但这两个 3 哪个是 3a 哪个是 3b没法区分因为代码里根本没保留这个信息。如果 3a 和 3b 携带了其他卫星数据比如请求 ID那排序后这些数据的对应关系就可能错位。注意很多人以为“值相等的元素本来就一样稳不稳定无所谓”。这个想法在纯数字排序时成立但一旦元素是结构体、对象、或者带附加信息的记录稳定性就是刚需。2.3 稳定性的定义别被“相等”两个字骗了稳定性在算法里的严格定义是对于任意两个相等的元素 a 和 b如果排序前 a 在 b 前面那么排序后 a 仍然在 b 前面。关键词是“相等的元素”和“相对顺序”。这里有个容易混淆的点什么叫“相等”在计数排序里相等指的是排序键相等而不是整个元素相等。比如你按响应时间排序一批请求两个请求响应时间都是 50ms但它们的 URL、时间戳、用户 ID 都不同。这两个请求在排序键上相等稳定性要求它们排序后保持原来的先后。这就引出了一个关键设计稳定计数排序必须能区分“排序键相同但元素不同”的个体。朴素版本做不到因为它把所有键相同的元素当成一坨处理了。稳定版本的做法是在计数阶段保留每个元素的位置信息回填时按原始顺序放置。3. 稳定计数排序的实现前缀和与逆序回填3.1 核心改造点把计数数组变成前缀和数组稳定版本的关键操作只有一步把频次数组转换成前缀和数组。这一步做完count[v]的含义就从“值 v 出现了几次”变成了“值小于等于 v 的元素一共有几个”。这个语义转变是整个稳定性的基石。为什么前缀和能带来稳定性因为前缀和告诉你的是“值 v 的元素应该放在哪个区间的末尾”。具体来说count[v]表示值 ≤ v 的元素总数那么值恰好等于 v 的元素它们的最终位置区间就是[count[v-1], count[v])。回填时如果我们从后往前遍历原数组每次把元素放到count[value] - 1的位置然后让count[value]减一就能保证后出现的相同元素被放到区间的更后面先出现的放到更前面——相对顺序得以保留。我用一个表格把这个过程摊开。原始数组[3, 1, 3, 2, 1]为了区分标记为[3a, 1a, 3b, 2a, 1b]步骤操作count 数组状态索引 1~3初始统计频次count[1]2, count[2]1, count[3]2前缀和count[i] count[i-1]count[1]2, count[2]3, count[3]5逆序回填 1b值1放 count[1]-11 位count[1]--count[1]1逆序回填 2a值2放 count[2]-12 位count[2]--count[2]2逆序回填 3b值3放 count[3]-14 位count[3]--count[3]4逆序回填 1a值1放 count[1]-10 位count[1]--count[1]0逆序回填 3a值3放 count[3]-13 位count[3]--count[3]3最终结果数组位置 0 是 1a位置 1 是 1b位置 2 是 2a位置 3 是 3a位置 4 是 3b。你看1a 在 1b 前面3a 在 3b 前面原始相对顺序完整保留。这就是稳定计数排序。3.2 完整代码实现与逐行注释把上面的逻辑写成 C 代码vectorint countingSortStable(const vectorint arr) { if (arr.empty()) return {}; // 1. 确定值域范围 int minVal *min_element(arr.begin(), arr.end()); int maxVal *max_element(arr.begin(), arr.end()); int range maxVal - minVal 1; // 2. 统计每个值出现的频次 vectorint count(range, 0); for (int x : arr) { count[x - minVal]; } // 3. 转换为前缀和count[i] 表示值 iminVal 的元素总数 for (int i 1; i range; i) { count[i] count[i - 1]; } // 4. 逆序回填保证稳定性 vectorint output(arr.size()); for (int i arr.size() - 1; i 0; i--) { int val arr[i] - minVal; output[count[val] - 1] arr[i]; count[val]--; } return output; }这段代码有几个细节值得单独拎出来说。第一必须用输出数组而不是原地交换。原地版本的计数排序很难保证稳定性因为你在原数组上挪动元素时会覆盖掉还没处理的信息。第二逆序遍历是稳定性的必要条件正序遍历会破坏相对顺序。第三count[val]--必须在放置之后执行顺序反了会越界。3.3 为什么逆序回填能保证稳定一个直觉解释有人可能会绕不过来为什么偏偏是逆序正序不行吗想象一排停车位从左到右编号 0 到 4。现在有 5 辆车要停其中两辆是红色的值相同。前缀和告诉你红色车应该停在最右边两个位置位置 3 和 4。如果你正序处理先遇到的红车 A 会被放到位置 4后遇到的红车 B 放到位置 3结果 B 跑到了 A 前面顺序反了。如果你逆序处理先遇到的是 B因为从后往前B 放到位置 4然后 A 放到位置 3A 在 B 前面顺序正确。核心在于前缀和给出的是区间的右边界逆序遍历时先处理的元素原数组中靠后的占据右边界后处理的原数组中靠前的占据左边界自然形成“靠前的在左、靠后的在右”的正确顺序。这个直觉比死记硬背管用得多。4. 稳定性在真实场景中的价值三个必须用稳定版本的案例4.1 多关键字排序基数排序的地基基数排序是稳定计数排序最经典的应用场景。基数排序按位从低到高依次排序每一位都用计数排序。如果某一位的计数排序不稳定那前面几轮排好的高位顺序就会被破坏整个基数排序直接失效。举个具体例子。要排序[21, 11, 22, 12]先按个位排[21, 11, 22, 12]个位分别是 1,1,2,2稳定排序后 21 在 11 前22 在 12 前。再按十位排[11, 12, 21, 22]。如果个位那轮不稳定可能变成[11, 21, 12, 22]十位排完就乱了。所以基数排序的每一轮都强制要求计数排序稳定这是硬性依赖。4.2 日志与事件流处理保留时间先后回到我开头说的那个事故。日志分析里经常需要按某个维度响应时间、状态码、用户等级分桶但同一桶内必须保留事件到达的原始顺序因为后续的因果分析、链路追踪都依赖这个顺序。这时候用稳定计数排序既能享受 O(nk) 的速度又能保住顺序信息。具体做法是把日志记录封装成结构体排序键是响应时间但结构体里还带着时间戳和请求 ID。用稳定计数排序后同一响应时间桶内的记录仍然按到达时间排列后续处理逻辑不用做任何额外补偿。4.3 数据库与存储引擎索引构建的隐性需求一些列式存储或索引构建过程中会先按某个低基数列比如性别、状态、地区编码做分桶桶内再按其他列排序。这个分桶步骤如果用计数排序稳定性直接决定了桶内数据的初始顺序是否正确。如果桶内顺序错了后续的二级排序可能基于错误的前提最终索引查出来的结果就是错的。提示判断一个场景要不要用稳定计数排序问自己一个问题——“排序键相同的两个元素交换它们的位置会不会导致业务逻辑出错”如果答案是“会”那就必须用稳定版本。5. 实操中的坑与排查我踩过的那些雷5.1 值域过大导致内存爆炸计数排序最容易被忽视的限制就是 k 的大小。我见过有同学拿它去排用户 ID用户 ID 是 64 位整数范围大到离谱结果vectorint count(range)直接抛 bad_alloc。这不是稳定性问题但它是计数排序能不能用的前提问题。解决办法有两个一是坐标压缩先把原始值映射到紧凑的排名空间再排序二是换算法值域太大时老老实实用归并排序归并也是稳定的O(n log n)。判断标准很简单如果 k 超过 n 的几倍甚至几十倍计数排序就不划算了。5.2 负数与偏移量处理计数排序的索引不能是负数所以有负数时必须做偏移。偏移量取minVal索引用x - minVal。这个操作本身简单但容易在回填时忘记加回偏移导致输出全是错的。我建议把偏移逻辑封装成两个小函数toIndex和fromIndex避免手写时漏掉。5.3 稳定性被无意破坏的几种写法下面这几种写法看起来没问题实际上都会破坏稳定性我逐个点出来正序回填前面讲过正序会让相同元素的相对顺序反转。原地交换在原数组上直接挪动覆盖未处理数据稳定性无从谈起。用count[val]代替count[val]--方向反了元素会从区间左边界往右填顺序错乱。先回填再更新 count会导致同一个位置被覆盖或者越界。排查稳定性问题时最有效的办法是构造带标记的测试数据。比如把数组元素换成pairvalue, originalIndex排序后检查相同 value 的元素它们的 originalIndex 是否单调递增。如果递增说明稳定如果出现逆序说明稳定性被破坏。这个测试方法我每次改计数排序代码都会跑一遍百试百灵。5.4 常见问题速查表问题现象可能原因排查方法解决方案排序结果值正确但顺序乱用了朴素版本或正序回填用带索引标记的数据测试改为前缀和 逆序回填程序崩溃 bad_alloc值域 k 过大打印 maxVal-minVal坐标压缩或换归并排序输出全是负数或错位偏移量处理错误检查 toIndex/fromIndex统一封装偏移逻辑相同元素顺序随机原地交换实现检查是否用了 output 数组改用独立输出数组结果数组有空洞count 更新顺序错误单步调试回填循环先放置后减 count6. 性能对比与选型建议什么时候该用它6.1 稳定计数排序 vs 归并排序 vs 快排三种排序放在一起对比选型就清晰了算法时间复杂度稳定性空间复杂度适用场景稳定计数排序O(nk)稳定O(nk)值域小、数据量大、要求稳定归并排序O(n log n)稳定O(n)值域大、要求稳定快速排序O(n log n) 平均不稳定O(log n)值域大、不要求稳定选型的决策链是这样的先看值域 k 是否可控可控就优先计数排序不可控但要求稳定选归并不要求稳定且追求原地选快排。计数排序的优势在于当 k 与 n 同阶时它是线性的比 O(n log n) 快一个量级。6.2 一个实测数据参考我在一台普通开发机上做过一组测试排 100 万个 0 到 1000 之间的随机整数稳定计数排序约 12ms归并排序约 180ms快速排序约 95ms差距非常明显。计数排序在这个场景下快了十几倍原因就是它没有比较操作纯靠数组索引寻址。但要注意这个优势只在 k 远小于 n 时成立。如果我把值域改成 0 到 10 亿计数排序直接没法跑归并和快排则不受影响。6.3 内存占用的权衡计数排序的空间开销是 O(nk)其中 n 是输出数组k 是计数数组。当 k 很大时计数数组可能比原数组还大。这时候可以考虑分块计数或者稀疏计数用哈希表代替数组但稀疏计数会引入哈希开销且实现复杂度上升稳定性也要重新验证。我的经验是如果 k 超过 n 的 10 倍就别硬上计数排序了收益已经不明显。7. 写在最后几个我反复验证过的实操心得关于计数排序的稳定性我最后再分享几个实际写代码时总结出来的小技巧都是踩坑换来的。第一个心得永远用带标记的数据做单元测试。纯数字数组测不出稳定性问题必须把元素换成pairvalue, id排序后检查相同 value 的 id 是否递增。这个测试用例我建议直接写进项目的测试套件每次改动排序相关代码都跑一遍。第二个心得前缀和那一步不要省。有人为了省一次循环试图在回填时动态计算位置结果代码复杂度飙升还容易出 bug。前缀和循环是 O(k)相对于整个算法来说微不足道老老实实写反而更稳。第三个心得逆序回填的循环变量类型要注意。如果数组大小是size_tfor (int i arr.size() - 1; i 0; i--)在 arr 为空时会出问题因为arr.size() - 1会下溢成极大值。我一般用for (int i (int)arr.size() - 1; i 0; i--)显式转换或者用反向迭代器。这个坑我在不止一个项目里见过。第四个心得稳定性不是计数排序的默认属性而是设计选择。你在写代码时如果没刻意保证它那它大概率就是不稳定的。所以每次用计数排序先问自己一句“这个场景要不要稳定”要的话就按前缀和加逆序回填的模板来不要图省事。这套写法我在日志分析、基数排序、索引构建几个场景里都反复用过实测下来很稳。如果你正在写类似的模块可以直接把上面的代码拿去改把值域和偏移逻辑替换成你自己的就行。