资讯动态

基数排序实战:LSD/MSD、计数排序与千万级性能优化

发布时间:2026/10/27 4:35:31 来源:尧图企业网站定制
1. 定长键排序的隐形战场基数排序真正的位置基数排序这个名字很多人第一次听到是在数据结构课本的最后一章翻过去就忘了。它不像快排那样天天出现在面试题里也不像归并排序那样被当成稳定排序的标准答案。但我这几年做数据处理的活儿只要碰到键是定长整数或定长字符串、且数据量上到千万级的场景第一个想到的往往就是它。原因很朴素比较类排序的下界是 O(n log n)而基数排序走的是另一条路——它压根不比大小只做分配和收集理论复杂度能压到 O(d·n)d 是键的位数。这不是理论上的花活儿。我印象比较深的一次是给一批设备上报的埋点日志按毫秒时间戳做全量排序单个文件四千多万条每条记录里时间戳是 int64。当时图省事直接用语言标准库的通用排序跑了二十秒出头机器风扇呼呼转。后来换成按字节分桶的基数排序八趟分配收集六秒多就跑完了而且 CPU 占用很平稳。当然这个对比不完全公平——标准库排序要处理比较函数回调、要照顾任意类型而基数排序是针对 int64 定制的。但结论是清楚的在键的结构已知且固定的时候通用排序做的大量比较其实是浪费。1.1 为什么日常我们感受不到它的存在大部分业务代码里排序的数据量在几千到几万之间标准库的排序通常是内省排序快排 堆排 插入排序的混合体已经足够快而且它对数据分布不敏感、不需要额外内存、不用你操心键的类型。基数排序在这个规模上反而容易吃亏分配辅助数组、初始化桶计数器、多趟扫描这些固定开销在小数据量下会盖过它省下来的比较成本。真正让它显形的是数据量上到百万级、千万级并且键是紧密排列的定长类型。比如用户 IDint64、时间戳int64、IPv4 地址uint32、固定长度的编码6 位数字的订单号、哈希值的前若干位。这些键有个共同特征每一位的取值范围有限且可枚举。这就是基数排序的入场券。1.2 三条硬约束缺一条就别硬上我一般会用下面三条来快速判断要不要用基数排序任何一条不满足我基本就退回标准库排序了键可分解为有限位。整数按进制位分字符串按字符位分浮点数要经过位模式变换之后按位分。如果键是任意对象 自定义比较逻辑先看看能不能提取出一个定长的排序键提取不出来就别用。子排序必须稳定。这是基数排序正确性的根基单趟分配如果用不稳定排序前面几趟的成果会被打乱。这一点后面会展开讲。能接受 O(n k) 的额外空间。k 是桶的数量。按字节分桶时桶很小256 个计数器但辅助数组要和原数组同大小。内存紧张的场景要提前算账。提示如果你的数据量在十万以下或者键的类型不固定别为了用上高级算法而选基数排序收益大概率是负的。2. 按位拆解LSD 与 MSD 这两条路线的分歧基数排序的实现有两大流派最低位优先Least Significant DigitLSD和最高位优先Most Significant DigitMSD。名字听着抽象用生活场景类比就很好懂LSD 像整理一叠卡片先按个位数分十堆按顺序摞起来再按十位数分十堆再摞起来一直到最高位分完整叠卡片自然就有序了。MSD 则是先按最高位分成大堆然后每个堆内部再递归地按次高位继续分分到堆里只剩一个元素为止。2.1 LSD一趟一趟扫到底代码短、循环规整LSD 的执行链条非常线性没有任何递归确定所有键的最大位数 d比如最大数是 9999d 就等于 4按十进制要跑 4 趟。从最低位个位开始用稳定的计数排序把整个数组按当前位分配到桶里再按桶的顺序收集回原数组。位数指针向高位移动重复第 2 步。最高位处理完之后数组有序。它的好处是循环结构固定没有分支预测失败的问题CPU 流水线跑得很顺每一趟的处理逻辑完全一样方便向量化遍数等于位数可预测性强。坏处是必须把 d 趟跑完哪怕数据的高位全都一样。比如你要排一批值域在 0 到 255 之间的数用 32 位整数的方式去排会白跑三趟全零的高位。这个浪费可以用先算出实际最大值的位数来规避但如果数据里混着一个特别大的值比如 1 个 10 位数和 999 万个 2 位数LSD 就被这个离群值绑住了需要跑 10 趟。这是 LSD 最典型的软肋。2.2 MSD递归分治早停能力强但结构复杂MSD 从最高位开始分桶然后把每个桶当成一个子问题递归处理。它的优势是早停如果某一层分完之后某个桶里只剩一个元素这个元素就已经到位了不用再往下走。对于分布不均、位长差异大的数据MSD 的实际工作量会明显小于 LSD。但代价也很明显。递归调用本身有开销桶多的时候比如按 8 位分256 个桶会产生大量空桶和极小的桶如果对每个桶都递归函数调用次数会爆炸。工程上通常会在桶内元素少于某个阈值比如 16 或 32时改用插入排序收尾这和快排的小数组优化是一个思路。另外 MSD 需要额外记录每个桶的起止位置递归层次深的时候栈空间也是考虑项。我的经验是整数排序基本都用 LSD字符串排序尤其是变长字符串才考虑 MSD 或者它的变体比如三路基数快排把字符分成小于、等于、大于三路。2.3 从低位开始凭什么最后是有序的这是初学者最容易卡住的地方我当时也在这里绕了半天。关键点在于稳定性加上权重的传递。假设我们只用两位十进制数来想。第一趟按个位分桶收集后数组里的数按个位有序了但整体显然还乱。这时候第二趟按十位分桶注意分桶的时候我们是顺序扫描数组、按顺序放进桶里的所以十位相同的那些数在桶里的相对顺序和它们在数组里的相对顺序一致——而它们在数组里的相对顺序恰好就是第一趟按个位排好的顺序。于是收集回来之后任意两个十位相同的数个位小的在前面十位不同的数十位小的在前面。两位都比完了整体有序。把这个推理往上归纳d 趟之后就是按 d 位有序。这里有个隐含前提每一趟的分桶收集必须稳定。如果某一趟把两个个位相同的数的相对顺序打乱了那么上一趟辛苦建立的局部有序就被破坏了最终结果一定错。这也是为什么基数排序的每一趟几乎都用计数排序而不用快排——快排不稳定。注意很多资料只强调基数排序是稳定的但没说清稳定性到底用在哪。它不是最终结果的附加属性而是算法正确性的必要条件。理解这一点后面处理负数和浮点数时才不会发懵。3. 引擎是计数排序单趟分配的三个细节每一趟的核心动作本质上是按当前位的值把元素分到 k 个桶里再按桶的顺序铺回去。这个动作如果要求稳定标准做法就是计数排序。别被名字吓到它的逻辑非常简单先数每个桶里有多少个元素用前缀和算出每个桶在结果数组里的起始下标然后从后往前扫原数组把元素放到对应的位置上放一个就把该桶的下标往后挪一格。3.1 稳定版计数排序的骨架长什么样写成伪代码大概是这样这一段值得背下来因为几乎所有基数排序的实现都是它的复读count[0..k-1] 0 for v in arr: count[digit(v)] 1 // 第一遍计数 sum 0 for i in 0..k-1: // 第二遍前缀和转起始下标 c count[i]; count[i] sum; sum c for v in arr: // 第三遍从前往后填 dst[count[digit(v)]] v注意第三遍我写的是从前往后遍历原数组配合的是起始下标语义。如果你把第二遍写成后缀和每个桶的结束下标第三遍就要从后往前遍历。两种写法都对但必须配对混着写就会出现元素被覆盖或者顺序倒置的 bug。我第一次手写的时候就犯过这个错排查了半天以为是位数算错了。Python 那种写法我习惯用后缀和 反向遍历因为 Python 里cnt[d] - 1; out[cnt[d]] v这行写起来短C 里我习惯用前缀和 正向遍历因为可以顺手用指针。两种都对看你顺手。3.2 桶宽怎么定4 位、8 位、11 位、16 位的取舍桶的数量决定了位数和趟数的乘积。设键的总位宽是 W比如 int32 是 32 位每趟处理 r 位那么趟数 d W / r桶数 k 2^r。总的工作量近似是 d·n 次桶访问加上 d·k 次计数器清零。这里有一个明显的权衡每趟位宽 r桶数 kint32 趟数特点4 位168 趟桶极少、缓存友好但趟数多适合小值域8 位2564 趟最常用的平衡点计数器 1KB能塞进 L111 位20483 趟趟数减少但计数组 8KB接近 L1 上限16 位655362 趟趟数最少但计数组 256KB落在 L2清零成本高实测下来8 位和 11 位在大多数机器上表现接近16 位在数据量很大几千万以上时偶尔能赢但小数据量下因为清零开销反而更慢。我默认选 8 位一是计数器小到能整体驻留在一级缓存里二是 256 个桶的分配过程对写带宽友好三是在不同的 CPU 上表现稳定不用针对机器调参。3.3 前缀和这一步为什么不能省有人会想我能不能直接两遍扫描第一遍数数第二遍再数一次边数边放不行。因为你必须知道每个桶的起始写入位置否则你不知道第一个属于某个桶的元素应该放在结果数组的哪个下标。前缀和就是在算这个东西。更深的理由是前缀和把分散的计数转换成了连续区间的边界这一步让后续的写入变成了纯顺序的、位置可预测的操作对缓存极其友好。这也是基数排序比比较排序快的重要原因之一——它的内存访问模式几乎全是顺序的而快排的分区过程是随机跳跃的。4. 三份能直接跑通的实现光看伪代码容易产生我会了的错觉真正手写一遍才会暴露细节问题。下面三份代码我都尽量写成可直接复制运行的形态注释里标了容易出错的地方。4.1 Python 版本先写对再谈快def radix_sort(arr): LSD 基数排序处理非负整数原地修改并返回 arr. n len(arr) if n 2: return arr max_val max(arr) exp 1 # 当前处理的位权1, 10, 100 ... out [0] * n while max_val // exp 0: # 最大值的当前位还没处理完就继续 cnt [0] * 10 for v in arr: # 第一遍统计当前位 cnt[(v // exp) % 10] 1 for i in range(1, 10): # 第二遍前缀和 cnt[i] cnt[i - 1] for v in reversed(arr): # 第三遍反向遍历 后缀位置语义 d (v // exp) % 10 cnt[d] - 1 out[cnt[d]] v arr, out out, arr # 交换复用缓冲区避免每趟新建 exp * 10 return arr这段代码里有三个细节值得说。第一while max_val // exp 0是控制趟数的正确写法不要写成while max_val 0: max_val // 10因为后者会破坏 max_val后面如果还要用就麻烦了。第二arr, out out, arr这行保证了缓冲区复用每趟结束后 out 指向的是上一轮的旧数组内容会被完全覆盖不需要清空。第三函数最后返回 arr因为交换次数可能是偶数也可能是奇数但无论奇偶最后一趟的结果一定落在 arr 上——这个结论成立的前提是每趟都把结果写进了另一个数组然后交换。Python 这个版本在纯 Python 层面比sorted()慢得多因为解释器开销盖过了一切。它的价值在于理解流程以及在做数据处理时配合 NumPy 用向量化重写。4.2 C 版本按字节分桶加符号位翻转#include vector #include cstdint #include algorithm // 无符号 32 位整数的 LSD 基数排序按 8 位分桶跑 4 趟 static void radix_sort_u32(std::vectoruint32_t a) { constexpr int BITS 8; constexpr int RADIX 1 BITS; // 256 constexpr int MASK RADIX - 1; constexpr int PASSES 4; const size_t n a.size(); if (n 2) return; std::vectoruint32_t buf(n); std::vectorsize_t cnt(RADIX); // 复用同一块计数器内存 std::vectoruint32_t* src a; std::vectoruint32_t* dst buf; for (int p 0; p PASSES; p) { const int shift p * BITS; std::fill(cnt.begin(), cnt.end(), 0); for (uint32_t v : *src) cnt[(v shift) MASK] 1; size_t sum 0; for (int i 0; i RADIX; i) { // 前缀和 - 起始下标 size_t c cnt[i]; cnt[i] sum; sum c; } for (uint32_t v : *src) (*dst)[cnt[(v shift) MASK]] v; std::swap(src, dst); } if (src ! a) a *src; // 奇偶趟数不同结果可能在 buf 里 } // 有符号 int32 版本翻转符号位后转成无符号处理 void radix_sort(std::vectorint a) { std::vectoruint32_t u(a.size()); for (size_t i 0; i a.size(); i) u[i] static_castuint32_t(a[i]) ^ 0x80000000u; radix_sort_u32(u); for (size_t i 0; i a.size(); i) a[i] static_castint(u[i] ^ 0x80000000u); }符号位翻转是这段代码的灵魂。补码表示的有符号整数负数的最高位是 1直接当成无符号看会排到最大去。把最高位取反之后有符号的数值顺序就和无符号的顺序完全一致了最小值 -2^31 翻转后变成 0最大值 2^31-1 翻转后变成 0xFFFFFFFF。这个变换是可逆的排完再翻回来就行。if (src ! a) a *src;这一行也不能漏。4 趟是偶数理论上每趟交换后 src 会回到 a但如果你把 PASSES 改成 3 或者按 16 位跑 2 趟结果就会落在 buf 里不拷贝回去就是错的。我写这段的时候特意保留了判断就是为了以后改参数不踩坑。4.3 Java 版本long 与无符号右移public static void radixSort(long[] a) { final int BITS 8, RADIX 1 BITS, MASK RADIX - 1, PASSES 8; int n a.length; if (n 2) return; long[] buf new long[n]; int[] cnt new int[RADIX]; for (int i 0; i n; i) a[i] ^ Long.MIN_VALUE; // 符号位翻转原地做 long[] src a, dst buf; for (int p 0; p PASSES; p) { int shift p * BITS; java.util.Arrays.fill(cnt, 0); for (long v : src) cnt[(int) ((v shift) MASK)]; int sum 0; for (int i 0; i RADIX; i) { int c cnt[i]; cnt[i] sum; sum c; } for (long v : src) dst[cnt[(int) ((v shift) MASK)]] v; long[] t src; src dst; dst t; } if (src ! a) System.arraycopy(src, 0, a, 0, n); for (int i 0; i n; i) a[i] ^ Long.MIN_VALUE; // 翻回来 }Java 这里有两个坑。第一必须用无符号右移而不是否则负数右移会补符号位取出来的字节全是 0xFF。第二a[i] ^ Long.MIN_VALUE这个翻转是原地做的排完必须翻回来否则调用方拿到的是被篡改的数据。我在第一次写的时候忘了翻回来下游算出来的平均值全是负数排查了好一阵。5. 负数、浮点数、字符串键不是非负整数怎么办课本上的基数排序例子永远是一堆非负整数但真实数据里负数、浮点数、字符串一个都不少。这三种情况各有各的处理套路。5.1 负数偏移量法和符号位分离法偏移量法的思路很直白先找出最小值 min把所有数减去 min 变成非负排完再加回来。优点是逻辑简单、不用理解补码缺点是如果 min 很小而 max 很大比如 -2^31 到 2^31-1 之间都有减去 min 之后值域被撑满位数不变但加法可能溢出需要用更宽的类型。用 int64 中转能解决溢出但多了一次全量加法的开销。符号位分离法就是上面 C 和 Java 代码里的做法把有符号值映射为无符号值排序后再映射回来。这个变换在补码机器上是精确的一一对应没有精度损失也不需要额外的全量算术运算只多了一次异或扫描。我推荐这个尤其是对 int32 和 int64。还有一个更粗糙但也够用的做法把数组按正负分成两段负数段取绝对值排好序再反转并取负正数段直接排最后拼接。这个方案不依赖补码假设可移植性好代价是需要额外遍历和两段拼接而且要求排序过程不能丢掉元素顺序信息。三个方案对比下来符号位翻转的性能最好分区法的可读性最好。5.2 浮点数IEEE 754 位模式变换浮点数不能直接按位排因为 IEEE 754 的编码里符号位和指数位的组合方式和数值大小不是单调对应的。但有一个流传很广的变换技巧只用位运算就能把浮点数映射成单调的无符号整数uint64_t f2u(double x) { uint64_t b; std::memcpy(b, x, sizeof(b)); // 按位复制不要用强制类型转换 return (b 0x8000000000000000ULL) ? ~b // 负数全位取反 : (b | 0x8000000000000000ULL); // 正数置符号位 } double u2f(uint64_t u) { uint64_t b (u 0x8000000000000000ULL) ? (u 0x8000000000000000ULL) // 判断走哪条路 : 0; b (u 0x8000000000000000ULL) ? (u ^ 0x8000000000000000ULL) : ~u; // 上面这行逻辑要仔细核对实际操作中建议分成两个分支写清楚 double x; std::memcpy(x, b, sizeof(x)); return x; }这段还原函数我写得有点绕实际项目里建议直接分成正负两个分支写可读性远胜于试图一行搞定。变换的原理是正数的符号位是 0置成 1 之后排在了上半区负数的符号位是 1全位取反之后绝对值越大的负数取反结果越小正好符合越小的负数排越前的要求。这个变换对 float 和 double 都适用只要把位宽改一下。需要特别注意的有三点-0.0 和 0.0 变换后不相等分别是 0x7FFF... 和 0x8000...如果你的业务里这两个要视为相同需要预处理NaN 会排到正数区的末尾如果你不想要这个行为得提前过滤必须用 memcpy 而不是指针强制转换后者在严格别名规则下是未定义行为开高优化等级会出现玄学问题。5.3 定长字符串与自定义键提取字符串排序是基数排序真正发光的地方之一。定长字符串比如固定 6 位的编码、定长 18 位的证件号、UUID 去掉横线后的 32 位十六进制可以直接按字符从右往左跑 LSD每个字符位 256 个桶或者按实际字符集缩到 37 或 64 个桶。变长字符串就麻烦一些LSD 需要先补齐到最大长度用最小字符填充这会浪费空间更常用的方案是 MSD 变体逐字符分桶后对每个桶递归桶内元素少时切插入排序。这就是所谓三路基数快排的思路很多语言的标准库在排字符串数组时用的就是类似策略。如果是结构体数组我一般会先做一次键提取把排序键单独收集成一个定长整数数组排序时只移动索引而不是整个结构体。移动一个 8 字节的索引比移动一个 128 字节的结构体快一个数量级这在千万级数据上差距非常明显。排完索引之后再按索引重排原数组这一次重排是随机的会有点缓存不友好但总体还是划算。6. 实测数据什么规模才值得换算法纸上谈复杂度容易误导人。O(n) 的算法前面的常数如果很大在中低数据量下可能输得很惨。我在一台普通的开发机8 核、32G 内存上做过一组对照数据是随机生成的 int32重复跑 5 次取中位数。6.1 测试方法和数据构造数据用固定种子的伪随机生成保证每次跑的是同一批。测试前先做一次预热避免首次分配内存的抖动影响第一轮计时用单调时钟只统计排序函数本身的耗时不含数据生成和校验。正确性用排序后相邻元素非降序来校验另外用一个简单的哈希累加确认元素没有丢失或重复。参与对比的有四个语言标准库的通用排序内省排序、手写的三路快排、自底向上归并排序、8 位分桶的 LSD 基数排序。归并用了额外缓冲所以和基数排序的空间开销可比。数据量标准库排序手写快排归并排序8 位 LSD 基数排序10 万9 ms11 ms14 ms18 ms100 万112 ms138 ms165 ms121 ms500 万640 ms780 ms910 ms480 ms2000 万3.1 s3.6 s4.2 s1.9 s5000 万8.4 s9.7 s11.5 s4.9 s可以看到拐点大概在 100 万到 500 万之间。10 万这个量级基数排序因为要分配缓冲区、初始化 256 个计数器、跑满 4 趟直接被标准库按在地上摩擦。到了 5000 万差距拉开到接近一倍。6.2 数据分布对结果的影响上面用的是均匀随机分布。如果数据已经有部分有序标准库的内省排序会有明显优势它能检测到接近有序的段并走插入排序而基数排序完全不受数据分布影响——它的耗时只跟位数和元素个数有关。这个特性在某些场景下反而是优点延迟可预测不会出现某批数据特别慢的抖动。如果键的值域很窄比如都集中在 0 到 65535 之间那么用 16 位分桶跑两趟就够了速度还能再提一截。反过来如果键是 int64 且高位高度分散8 趟跑下来收益就会被摊薄。6.3 缓存和内存带宽才是真正的分水岭为什么同样是 O(n) 级的扫描基数排序能比归并快出这么多我觉得核心不在算法复杂度而在内存访问模式。归并排序做合并的时候需要来回比较两个子数组的头部指针在两个位置之间跳跃快排的分区更是典型的随机访问。而基数排序的每一趟写操作都是严格按桶的顺序、在连续区间内推进的读操作是顺序扫描。这种模式下硬件预取器几乎不失误写合并缓冲也能充分利用。反过来说当数据量超过内存容量、开始发生换页的时候基数排序的优势会缩小甚至反转因为它要维持一个和原数组等大的辅助数组内存占用是双份的。我踩过一次在一台内存吃紧的机器上排 80GB 数据基数排序因为触发换页比原地快排慢了将近三倍。后来改成按块处理每次读 1GB 进内存排序、写回磁盘、再做多路归并问题才解决。提示选算法之前先看数据有多大、内存够不够。算法复杂度是理论内存带宽和缓存命中率才是实测。7. 五个翻车现场每个都耗费过我半天以上下面这几个坑我或者同事都真实踩过写出来是为了让后来的人少走弯路。排错的关键不是记住答案而是掌握定位思路。7.1 稳定性被破坏结果看着就差一点现象排序结果大部分是对的但总有几个位置的元素顺序错乱而且换个数据集错的位置就变。这种看起来快对了的 bug 十有八九是稳定性问题。排查链路先缩小到最小复现集找出两个本该保持相对顺序却被交换的元素打印它们的键。如果两个元素的当前位相同但更高位不同那基本坐实了是稳定性被破坏。接着检查单趟分配的实现重点看是不是用了不稳定排序、是不是在收集阶段做了额外的交换、是不是用了两遍计数边数边放这种取巧写法。我那次最后定位到的是为了省一次前缀和我直接用了桶计数递减的方式写入但遍历原数组时用了正向语义和下标方向反了导致同桶内顺序被倒置。7.2 每趟都重新分配缓冲区这个坑不会让结果错但会让性能莫名其妙地差。表现是基数排序跑起来比快排还慢用性能分析工具一看时间大头在内存分配和清零上。原因是每一趟都new一个新数组当目标。正确的做法是只分配一次两趟之间交换指针让上一趟的目标变成下一趟的源。计数器数组同理用fill(0)复用比重新分配便宜得多。这个优化我实测能让 5000 万数据的耗时下降 15% 左右。7.3 位数算错高位漏排用十进制实现时趟数靠while (max / exp 0)控制。如果 max 是 0全零数组这个循环一次都不进数组本来就有序没问题。但如果 max 是负数在没做符号处理的情况下循环同样一次都不进结果就是完全没排。更隐蔽的情况是exp * 10写在了循环体开头而不是结尾或者位数从 0 开始而不是从 1 开始。这类 bug 的特征是小数据对、大数据错因为小数据的位数少少跑一趟可能碰巧还是有序的。定位方法是构造一组必须跑满所有位的数据比如 1、10、100 这种跨度大的加上断言输出每一趟之后的中间状态肉眼比对。7.4 负数进得去出不来前面讲过符号位翻转但实际踩坑的形式往往更隐蔽。有一种情况是排序函数本身正确处理了负数但调用方传进来的数组里混了INT_MIN翻转之后变成 0恰好是最小值看起来正确但如果用的是取绝对值再排的方案abs(INT_MIN)是未定义行为会返回一个负数整个排序直接崩。还有一种情况是多处代码共用同一个排序函数某处调用忘了翻回去导致后续计算全错。我的应对办法是对外暴露的函数一律接收有符号类型翻转和还原都封装在函数内部绝不把中间态暴露给调用方。7.5 值域爆炸导致计数器内存吃紧按 11 位分桶时计数器是 2048 个元素按 16 位是 65536 个每个计数器如果是 8 字节的 size_t那就是 512KB。这个数组每趟都要清零一次四趟就是 2MB 的写入量在小数据量下完全得不偿失。更极端的是有人尝试一趟搞定直接把桶数设成值域大小——如果数据是 int32 且值域分散那就是 40 亿个桶。这其实就是计数排序而不是基数排序了只在值域很窄的时候才可行。我一般会在函数入口加一个判断如果值域范围小于元素个数的某个倍数比如 2 倍直接走计数排序否则走分桶基数排序。这个判断能覆盖掉大部分值域窄的实际数据。8. 落地判据到底该不该在自己的项目里用它聊了这么多原理和坑最后还是得回答一个务实的问题手上这个需求要不要用基数排序。我给自己的判断清单是这样的你可以直接拿去改。8.1 一份可以直接对照的判据清单判断项倾向用基数排序倾向用标准库排序数据量100 万以上10 万以下键类型定长整数、定长字符串、可提取定长键任意对象、比较逻辑复杂值域位宽固定不依赖实际分布值域极宽且只有少量元素稳定性要求需要稳定无所谓内存有空余的等大缓冲区内存紧张要求原地延迟要求需要可预测的耗时对偶尔变慢不敏感这张表里我最看重的是键是否定长和内存是否够。这两条不满足其他条件再好也白搭。数据量那条其实是可以商量的因为我见过一些场景虽然只有几十万数据但排序要重复几万次比如每个时间窗口都排一次这时候单次的算法开销会被放大基数排序的稳定耗时反而有价值。8.2 和标准库混用比全盘替换更实际大部分项目里我不会把排序全部换成基数排序而是只在对性能敏感的那几个热点路径上替换。典型做法是先用性能分析工具找出真正耗时的排序调用点确认它的数据量和键类型适合再针对性地替换成基数排序其他地方的排序保持不动。这样做的好处是风险可控。基数排序的代码比一行sort()复杂得多引入的 bug 面也大只替换几个点测试和回滚的成本都低。而且标准库的排序经过大量场景验证边界处理空数组、单元素、含特殊值都很完善没必要为了统一风格去重写。我自己维护的一个小工具库里基数排序就封装成三个函数排 int32 的、排 int64 的、排定长字节串的。每个函数入口先做规模判断小于阈值直接转调标准库排序大于阈值才走基数路径。这个小数据量降级的处理让我省掉了很多小数组反而变慢的疑虑。最后分享一个我在实际操作中体会最深的小技巧给你的基数排序实现加一个校验开关。在调试模式下排序完成后跑一遍相邻元素比较和元素总数核对做一次简单的哈希校验在发布模式下把这段关掉。基数排序的 bug 往往是大部分正确的那种很难靠肉眼发现一个开销极小的校验能帮你把这些隐蔽问题挡在测试阶段。我现在的习惯是任何手写的非常规排序都配一个慢但绝对可靠的对照实现用随机数据跑个几千轮对拍跑通了才敢往生产环境的路径上放。这个过程花的时间远比事后从一堆错乱数据里反推原因要少。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑