基数排序:打破比较下界的正确姿势

0 阅读34分钟

每个学过算法的人都听过这样一句话:基于比较的排序算法,时间复杂度的下界是 O(n log n)。这个结论在理论上无懈可击,在实践中也被反复验证。然而,基数排序(Radix Sort)的存在似乎在挑战这个结论——它可以在 O(nk) 的时间内完成排序,其中 k 是键的位数。

这不是魔法,也不是悖论。理解基数排序为什么能「打破」下界,以及它在什么场景下真正有价值,是区分算法初学者和工程实践者的一道分水岭。本文将从决策树模型出发,深入基数排序的理论基础、工程实现、性能陷阱和实际应用场景,给出一个系统性的分析。

基数排序:打破比较下界的正确姿势

一、比较排序的 O(n log n) 下界证明

1.1 决策树模型

比较排序的下界证明建立在决策树(Decision Tree)模型之上。这个模型的核心假设是:排序算法获取元素间关系的唯一手段是两两比较。

对于 n 个不同元素的排列,共有 n! 种可能的排列方式。每一种排列对应一种不同的输入顺序,排序算法需要将其映射到唯一的有序输出。在决策树中,每个内部节点代表一次比较操作(如 a[i] <= a[j]),每个叶子节点代表一种最终排列。

graph TD
    A["a[0] <= a[1]?"]
    A -- Yes --> B["a[1] <= a[2]?"]
    A -- No --> C["a[0] <= a[2]?"]
    
    B -- Yes --> D["[0,1,2]"]
    B -- No --> E["a[0] <= a[2]?"]
    
    C -- Yes --> F["[1,0,2]"]
    C -- No --> G["a[1] <= a[2]?"]
    
    E -- Yes --> H["[0,2,1]"]
    E -- No --> I["[2,0,1]"]
    
    G -- Yes --> J["[1,2,0]"]
    G -- No --> K["[2,1,0]"]

上面是 3 个元素排序的完整决策树。3! = 6 种排列,对应 6 个叶子节点。

1.2 下界推导

决策树必须有至少 n! 个叶子节点,才能区分所有可能的输入排列。一棵高度为 h 的二叉树最多有 2^h 个叶子,因此:

2^h >= n!
h >= log2(n!)

利用 Stirling 近似:

log2(n!) = n*log2(n) - n*log2(e) + O(log n)
         ≈ n*log2(n) - 1.443*n
         = Θ(n log n)

这意味着在最坏情况下,任何比较排序算法至少需要 Θ(n log n) 次比较。这不是某个特定算法的限制,而是整个比较排序模型的信息论下界。

1.3 信息论视角

从信息论的角度看,排序本质上是一个信息获取过程。输入排列的不确定性(熵)是 log2(n!) 比特。每次比较最多获取 1 比特信息(是或否)。因此,至少需要 log2(n!) 次比较才能消除所有不确定性,确定输入的准确排列。

信息熵 = log2(n!) ≈ n*log2(n) - 1.443*n 比特
每次比较获取 <= 1 比特
所需比较次数 >= n*log2(n) - 1.443*n

这个分析优雅而有力,但它有一个容易被忽视的前提:它只适用于通过两两比较获取信息的模型。

二、基数排序不违反下界的原因

2.1 计算模型的边界

决策树下界的证明假设算法只能通过比较两个元素来获取信息。但这个假设并非宇宙法则——它只是一种计算模型的约束。

基数排序根本不比较元素。它直接读取元素的数字表示(二进制位或十进制位),然后根据这些位的值将元素分配到桶中。这就像邮局分拣信件:不需要逐一比较两封信的地址,只需看邮编的每一位数字,分到对应的格子里。

比较排序模型:
  信息来源 = 两两比较 (a[i] < a[j]?)
  每步信息量 = 1 比特
  下界 = Ω(n log n)

基数排序模型:
  信息来源 = 直接读取键的位 (digit(a[i], k))
  每步信息量 = log2(radix) 比特
  复杂度 = O(d * (n + radix))
flowchart LR
        A["比较排序"] --> A1["信息来自元素间比较"]
        A1 --> A2["单次比较最多提供 1 bit 信息"]
        A2 --> A3["决策树下界 Ω(n log n)"]

        B["基数排序"] --> B1["信息来自键的内部位段"]
        B1 --> B2["每轮按位段分桶"]
        B2 --> B3["成本写成 O(d * (n + radix))"]

2.2 代价的转移

基数排序没有违反任何定律,它只是把代价从「比较次数」转移到了「键的表示长度」上。如果我们把键看作 d 位的 radix 进制数,基数排序的时间复杂度是 O(d * (n + radix))。

关键问题是:d 到底有多大?

对于 n 个不同的整数,至少需要 log2(n) 位来区分它们。如果 d = O(log n) 且 radix = O(n),基数排序的时间复杂度是 O(n)——看起来打破了 O(n log n) 的下界。

但这里有一个微妙之处:如果元素值域很大(比如 64 位整数),那么 d = 64/log2(radix)。当 radix = 256 时,d = 8;当 n < 2^64 时,这确实可能优于 O(n log n)。但如果 n 相对于值域很小,基数排序的常数因子和缓存不友好性可能抵消理论优势。

2.3 没有免费的午餐

基数排序的「免费性」有严格的条件:

  1. 键必须可以被分解为固定数量的「位」。
  2. 每一位的取值范围有限(基数有限)。
  3. 键的位数 d 不能随 n 增长太快。

如果键是任意精度的实数,或者键的长度随 n 对数增长(如字符串的平均长度为 O(log n)),基数排序退化为 O(n log n),和比较排序的下界一样。

我个人认为,这是算法理论中最容易被误解的地方之一。很多人以为基数排序「证明了」O(n log n) 不是排序的绝对下界,但实际上它只是在不同的计算模型下工作。在比较模型中,O(n log n) 仍然是不可逾越的。

三、MSD vs LSD 基数排序

基数排序有两种主要变体:LSD(Least Significant Digit)和 MSD(Most Significant Digit)。它们的区别不仅仅是处理方向,还涉及稳定性、内存使用、缓存行为和递归结构等多个维度。

MSD vs LSD 基数排序对比转存失败,建议直接上传图片文件

3.1 LSD 基数排序

LSD 从最低有效位开始,逐步向最高有效位处理。每一轮对所有元素按当前位进行稳定排序(通常使用计数排序),经过 d 轮后整个数组有序。

输入: [170, 045, 075, 090, 002, 024, 802, 066]

Pass 1 (个位): [170, 090, 002, 802, 024, 045, 075, 066]
Pass 2 (十位): [002, 802, 024, 045, 066, 170, 075, 090]
Pass 3 (百位): [002, 024, 045, 066, 075, 090, 170, 802]

LSD 的正确性依赖于一个关键性质:每一轮使用的子排序必须是稳定的。如果第 i 轮是稳定的,那么第 i-1 轮建立的相对顺序不会被破坏。归纳下去,d 轮之后所有位都参与了排序,结果正确。

3.2 MSD 基数排序

MSD 从最高有效位开始,先按最高位把数据分到不同的桶里,然后对每个桶递归地按下一位排序。

输入: [170, 045, 075, 090, 002, 024, 802, 066]

Pass 1 (百位):
  桶0: [045, 075, 090, 002, 024, 066]
  桶1: [170]
  桶8: [802]

对桶0递归 Pass 2 (十位):
  桶0: [002]  桶2: [024]  桶4: [045]
  桶6: [066]  桶7: [075]  桶9: [090]

合并: [002, 024, 045, 066, 075, 090, 170, 802]

3.3 核心差异对比

维度LSDMSD
处理方向从最低位到最高位从最高位到最低位
算法结构迭代,d 轮扫描递归,分治结构
稳定性天然稳定(依赖稳定子排序)默认不稳定,需额外处理
额外空间O(n + k),需要辅助数组可就地实现,但常需 O(n)
递归开销递归深度 d,栈空间 O(d * k)
短路优化不可能,必须处理所有位可提前终止(桶大小 <= 1)
变长键不自然,需要对齐天然支持(空字符最小)
缓存行为每轮扫描全数组,不友好子问题变小后可能更友好

3.4 选择建议

LSD 适合的场景:

  • 固定长度的键(32 位整数、64 位整数)。
  • 需要稳定排序的场景。
  • 键的位数较少,d 较小。

MSD 适合的场景:

  • 变长字符串排序(如字典序排序)。
  • 数据分布不均匀,大部分数据可在前几位区分。
  • 需要就地排序(如 American Flag Sort)。
  • 需要提前终止的场景。

四、计数排序作为基础

4.1 计数排序回顾

基数排序的每一轮本质上是一次计数排序(Counting Sort)。计数排序是基数排序的基石,理解它的性能特征对优化基数排序至关重要。

// 计数排序:对数组 arr 按第 digit_pos 位排序
// radix 是基数(桶的数量)
void counting_sort_by_digit(int *arr, int *output, int n,
                            int digit_pos, int radix)
{
    int *count = calloc(radix, sizeof(int));

    // 第一遍:统计每个桶的元素数量
    for (int i = 0; i < n; i++) {
        int digit = (arr[i] / digit_pos) % radix;
        count[digit]++;
    }

    // 前缀和:计算每个桶的起始位置
    for (int i = 1; i < radix; i++) {
        count[i] += count[i - 1];
    }

    // 第二遍:从后向前遍历,保证稳定性
    for (int i = n - 1; i >= 0; i--) {
        int digit = (arr[i] / digit_pos) % radix;
        output[count[digit] - 1] = arr[i];
        count[digit]--;
    }

    free(count);
}

4.2 基数(桶数量)的选择

基数(radix)的选择是基数排序中最关键的工程决策之一。它直接影响排序的轮数、内存使用和缓存行为。

设排序 n 个 W 位整数,选择基数 r = 2^b(按 b 位分组):

轮数 d = W / b
每轮时间 = O(n + 2^b)
总时间 = O(W/b * (n + 2^b))

当 2^b <= n(即 b <= log2(n))时,每轮时间可以近似看作 O(n),总时间约为 O(nW/b)。但这只是一个忽略缓存、带宽和常数项的简化模型。它能说明“增大 b 可以减少轮数”,却不能推出一个在所有机器上都成立的固定最优基数。

更稳妥的说法是:理论上的折中点通常落在 2^b 与 n 同阶的区域,但工程上还必须额外满足 count 数组尽量停留在低层缓存,否则减少轮数带来的收益很容易被缓存抖动抵消。

粗略折中原则:
    让 2^b 不显著大于 n
    同时让 count 数组尽量留在 L1 / L2

实践中常见的选择:

基数位数适用场景
21理论分析,实际不用
10~3.3教学用途,十进制直觉
2568通用选择,一次处理一字节
6553616大数据量时减少轮数

256(8 位)是最常见的选择,原因如下:

  1. 对 32 位整数只需 4 轮,64 位整数只需 8 轮。
  2. 计数数组大小仅 256 * sizeof(int) = 1 KB,轻松放入 L1 缓存。
  3. 提取一个字节的操作在所有架构上都很高效。

但当 n 很大时(比如 n > 100 万),使用 16 位基数(65536 个桶)可以把 32 位整数的排序轮数从 4 减少到 2,代价是计数数组增大到 256 KB。是否值得取决于 n 的大小和缓存层级的容量。

4.3 前缀和的并行化潜力

计数排序中的前缀和步骤可以用 SIMD 指令并行化(Blelloch scan 思路),对大基数尤其有效。详见本系列第 113 篇:SIMD 算法设计模式。

五、缓存友好性问题

5.1 基数排序的内存访问模式

基数排序最大的实践问题不是时间复杂度,而是缓存性能。让我们分析 LSD 基数排序的内存访问模式:

第一遍(计数):
  顺序读取 arr[0], arr[1], ..., arr[n-1]  // 顺序访问,缓存友好
  随机写入 count[digit]                     // 写入 count 数组,通常在 L1

第二遍(分配):
  顺序读取 arr[n-1], arr[n-2], ..., arr[0]  // 反向顺序,仍可预取
  随机写入 output[count[digit] - 1]          // 写入位置几乎随机!

第二遍的写入是关键瓶颈。根据当前位的数字值,元素被写入输出数组的不同位置。如果数据在当前位上分布均匀,这些写入位置几乎是随机的——完全破坏了空间局部性。

5.2 缓存未命中的代价

对于 n = 100 万个 32 位整数,使用基数 256:

输出数组大小 = 4 MB
L1 缓存典型大小 = 32-48 KB
L2 缓存典型大小 = 256 KB - 1 MB
L3 缓存典型大小 = 4-32 MB

每轮散射写入的典型后果:
    如果输出数组远大于最后级缓存:write-allocate miss、缓存行抖动和带宽压力都会显著增加
    如果输出数组仍能停留在 LLC:表现强依赖实现、数据分布、缓存关联度和写分配策略
    如果输出数组能落入 L2 或 L1:缓存压力会明显缓和

当 n 很大时,每轮基数排序都会对输出数组施加明显的缓存压力。更准确的说法不是“几乎每次写入都 miss”,而是散射写入会显著恶化空间局部性,并放大写分配和缓存行回写的成本。

5.3 与比较排序的对比

对比一下 pdqsort(Quicksort 变体)的内存访问模式:

pdqsort:
  - 分区操作:两个指针从两端向中间扫描,交换元素
  - 访问模式:几乎完全顺序
  - 递归后子问题变小,很快放入缓存
  - 缓存利用率极高

基数排序(LSD):
  - 每轮对全数组做散射写入
  - 访问模式:读顺序,写随机
  - 全局操作,不缩小问题规模
  - 缓存利用率低

这就是为什么在实际基准测试中,基数排序在 n 较小(< 几万)时常常输给 pdqsort。理论上的 O(n) vs O(n log n) 在缓存不友好的常数因子面前显得苍白。

5.4 改善缓存行为的策略

有几种方法可以改善基数排序的缓存行为:

策略一:减小问题规模后切换。 当子数组足够小(能放入 L1 或 L2 缓存)时,切换到插入排序。MSD 基数排序天然支持这种策略。

策略二:软件预取。 在散射写入之前,用 __builtin_prefetch 预取即将写入的缓存行。需要提前若干步预取(PREFETCH_DISTANCE 通常设为 8-16),给内存子系统足够的延迟来响应。

策略三:分块处理(Blocked Radix Sort)。 将输入分成 cache-sized 块,先在每个块内统计桶计数,全局前缀和后再做散射。可以提高计数阶段的局部性,但散射写入仍然不友好。

六、ska_sort:缓存友好的基数排序

6.1 American Flag Sort

在讨论 ska_sort 之前,需要先了解 American Flag Sort(AFS)。AFS 是一种就地(in-place)的 MSD 基数排序变体,由 McIlroy、Bostic 和 McIlroy 在 1993 年提出。

AFS 的核心思路:不使用辅助数组,而是通过交换将元素移到正确的桶中。

输入: [170, 045, 075, 090, 002, 024, 802, 066]
按百位排序:

1. 第一遍:计数
   count[0]=6, count[1]=1, count[8]=1

2. 前缀和 -> 桶边界
   边界: [0, 6, 7, 8]  // 桶0占位置0-5,桶1占位置6,桶8占位置7

3. 就地交换:扫描每个桶区域,如果元素不属于当前桶,
   与它应该去的桶的下一个空位置交换

AFS 避免了 O(n) 的辅助数组,但它不是稳定排序。

6.2 ska_sort 的设计思路

ska_sort 是 Malte Skarupke 在 2016 年提出的一种实用化、缓存友好的基数排序实现思路。具体细节会随实现版本而变化,但它的工程取向大致可以概括为:

  1. 按字节或小位段处理键:避免把 count 数组做得过大。
  2. 小数组回退:当子数组足够小时,回退到比较排序或插入排序,避免短数组上被常数因子吞噬。
  3. 低辅助空间分桶:倾向就地分区或低额外空间的循环交换,减少额外内存流量。
  4. 类型萃取:通过模板和 traits 从不同类型中抽取可排序键。

6.3 ska_sort 的核心算法

ska_sort 的就地分区过程可以概括为循环排列(cycle sort)的推广:先计数确定桶边界,然后扫描每个桶区域,把不属于当前桶的元素沿着「它应该去的桶」形成的链循环交换,直到链闭合。完整的 C 实现见 9.3 节。

6.4 ska_sort 的缓存优势

ska_sort 相比传统 LSD 基数排序的缓存优势来自两个方面:

  1. 就地操作:不需要 O(n) 的辅助数组,内存使用量减半,空间局部性更好。

  2. MSD 结构:递归处理子桶时,子问题迅速缩小。对 100 万个 32 位整数,第 1 轮处理全部 4 MB 数据,第 2 轮约 256 个子数组平均 16 KB(多数在 L1 内),第 3 轮后完全在缓存中完成。而 LSD 每轮都要对全数组做随机写入。

6.5 ska_sort 的局限

ska_sort 并非万能:不稳定排序(就地交换破坏相对顺序),实现复杂度高,对已有序数据无特别优化,模板机制增加编译开销。

七、为什么数据库排序通常不以基数排序为主

这里更准确的表述不是“数据库不用基数排序”,而是:在通用 SQL 执行器里,基数排序通常不是默认主力,但在定长键、列式存储或键已经被规范化编码的场景中,它仍然可能很有竞争力。

7.1 变长键问题

数据库中最常见的排序键是字符串——VARCHAR 类型。字符串的长度不固定,从几个字节到几千字节不等。

基数排序处理变长键有两种方式,都不完美:

方式一:填充到最大长度
  "cat"    -> "cat\0\0\0\0\0\0\0"
  "catalog" -> "catalog\0\0\0"
  问题:如果最大长度是 255 字节,大量短字符串浪费时间在填充位上

方式二:MSD + 特殊处理空字符
  把空字符(字符串结束)视为最小值
  递归时检查是否到达字符串末尾
  问题:递归深度等于最长字符串长度,极端情况下很深

相比之下,比较排序只需要一个字符串比较函数,遇到不同字符时立即返回,平均只需比较前几个字符。这种「提前终止」的能力是比较排序在变长键上的天然优势。

7.2 复合键和比较语义

数据库的 ORDER BY 经常涉及多个列和复杂的比较语义:

SELECT * FROM orders
ORDER BY region ASC, priority DESC, created_at ASC
COLLATE utf8mb4_unicode_ci;

这个查询涉及:

  • 三个不同类型的键(字符串、整数、时间戳)。
  • 不同的排序方向(升序和降序混合)。
  • 特定的字符串排序规则(Unicode collation)。

基数排序要处理这种场景,需要:

  1. 将复合键编码为可按字节比较的二进制串。
  2. 降序字段需要按位取反。
  3. Unicode collation 需要先转换为排序键(sort key),这本身就是昂贵的操作。

比较排序只需要一个自定义的比较函数就能处理所有这些情况。

7.3 内存约束

数据库排序经常面临严格的内存限制。当数据量超过内存时,需要使用外部排序(External Sort),将数据分成可管理的块在磁盘上归并。

外部排序天然适合基于比较的归并排序,因为归并操作是顺序访问的。基数排序的散射写入模式对磁盘来说是灾难性的。

7.4 更常见的工程取向

场景更常见的选择主要原因
通用 SQL ORDER BY比较排序 + 自定义比较逻辑复合键、ASC/DESC、NULL、collation 语义复杂
超内存排序外部归并排序顺序 I/O 友好,易于分块和多路归并
行存储 OLTP比较排序或混合排序记录大、键不规则、间接排序更自然
列式 OLAP、定长键可能引入 radix 或 hybrid键更规整,列数据连续,批处理更友好

因此,更稳妥的结论是:数据库排序的主流工程选择通常偏向比较排序和外部归并,而 radix 更容易在列式、定长、可编码键的场景里发挥优势。ClickHouse 之类系统之所以更容易使用这类技术,和它们的数据布局与工作负载模型直接相关。

八、基数排序的实际最佳场景

基数排序不是通用排序的替代品,但在少数几类输入上往往非常强。下面只抓最典型的场景,完整的 C11 示例放在第九节。

8.1 整数数组排序

32 位 / 64 位整数是基数排序的经典目标。使用基数 256(按字节分组),32 位整数只需 4 轮,每轮 O(n)。有符号整数需要在最高字节排序时翻转符号位的解释顺序——补码中 0x80-0xFF 是负数,前缀和时先累计负数桶再累计正数桶即可。详见 9.1 节的完整实现。

8.2 浮点数排序

IEEE 754 浮点数有一个有趣的性质:如果把正浮点数的位模式解释为无符号整数,它们的大小顺序是一致的。负浮点数需要翻转所有非符号位。实际工程里还要单独约定 NaN、-0 和 +0 的排序语义;下面先只看核心映射,完整的 lsd_radix_sort_u32radix_sort_float 放到 9.1。

static inline uint32_t float_to_sortable(float f)
{
    uint32_t bits;
    memcpy(&bits, &f, sizeof(bits));

    // 如果是负数(符号位为1),翻转所有位
    // 如果是正数(符号位为0),只翻转符号位
    uint32_t mask = -(bits >> 31) | 0x80000000u;
    return bits ^ mask;
}

static inline float sortable_to_float(uint32_t bits)
{
    uint32_t mask = ((bits >> 31) - 1) | 0x80000000u;
    bits ^= mask;
    float f;
    memcpy(&f, &bits, sizeof(f));
    return f;
}

8.3 字符串排序

对于字符串,MSD 基数排序在前缀分化强、字符集较简单的场景里很有效。对定长字符串它尤其自然;对变长字符串,也可以把字符串结束视为最小字符来处理。下面给出一个更接近可直接编译的版本:

static int char_at(const char *s, int depth)
{
    unsigned char c = (unsigned char)s[depth];
    return c == '\0' ? -1 : c;
}

static void insertion_sort_strings(char **arr, int lo, int hi, int depth)
{
    for (int i = lo + 1; i <= hi; i++) {
        char *value = arr[i];
        int j = i - 1;

        while (j >= lo && strcmp(arr[j] + depth, value + depth) > 0) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = value;
    }
}

void msd_radix_sort_strings(char **arr, int lo, int hi,
                            int depth, int max_len)
{
    if (hi <= lo || depth >= max_len) return;

    // 小数组切换到插入排序
    if (hi - lo < 32) {
        insertion_sort_strings(arr, lo, hi, depth);
        return;
    }

    int count[258] = {0};
    int starts[257];

    // 计数
    for (int i = lo; i <= hi; i++) {
        int c = char_at(arr[i], depth);
        count[c + 2]++;
    }

    // 前缀和
    for (int i = 1; i < 258; i++) {
        count[i] += count[i - 1];
    }

    for (int i = 0; i < 257; i++) {
        starts[i] = count[i];
    }

    // 分配到辅助数组
    char **aux = malloc((hi - lo + 1) * sizeof(char *));
    if (!aux) return;

    for (int i = lo; i <= hi; i++) {
        int c = char_at(arr[i], depth);
        aux[count[c + 1]++] = arr[i];
    }

    // 复制回原数组
    for (int i = lo; i <= hi; i++) {
        arr[i] = aux[i - lo];
    }
    free(aux);

    // 跳过字符串结束桶,只对实际字符桶递归
    for (int bucket = 1; bucket < 257; bucket++) {
        int sub_lo = lo + starts[bucket];
        int sub_hi = lo + count[bucket] - 1;
        if (sub_lo < sub_hi) {
            msd_radix_sort_strings(arr, sub_lo, sub_hi,
                                   depth + 1, max_len);
        }
    }
}

8.4 IP 地址排序

IPv4 地址本质是 32 位无符号整数,可以直接使用 LSD 基数排序。IPv6 是 128 位,可以按 4 个 32 位段分别处理,或用 16 轮按字节排序。对于附带额外数据的场景(如路由表项),建议使用间接排序:先排序键的索引数组,再按索引重排原数组,避免大结构体的散射写入开销。

九、完整实现

下面的代码按“单文件示例”组织。9.1 提供共享头文件、辅助函数,以及 LSD 的有符号/无符号实现;9.2 和 9.3 分别给出 MSD 与 ska 风格变体;9.4 是最小测试程序。把 9.1 到 9.4 顺序放进同一个 radix_demo.c,用 cc -O3 -std=c11 radix_demo.c -o radix_demo 即可编译。

9.1 共享基础与 LSD 实现

#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#define RADIX_BITS 8
#define RADIX (1u << RADIX_BITS)
#define MASK (RADIX - 1)

static void insertion_sort_u32(uint32_t *arr, int n)
{
    for (int i = 1; i < n; i++) {
        uint32_t key = arr[i];
        int j = i - 1;

        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

static void lsd_radix_sort_u32(uint32_t *arr, int n)
{
    if (n <= 1) return;

    uint32_t *src = arr;
    uint32_t *dst = malloc((size_t)n * sizeof(uint32_t));
    if (!dst) return;

    int hist[4][RADIX] = {{0}};
    for (int i = 0; i < n; i++) {
        uint32_t val = src[i];
        hist[0][val & MASK]++;
        hist[1][(val >> 8) & MASK]++;
        hist[2][(val >> 16) & MASK]++;
        hist[3][(val >> 24) & MASK]++;
    }

    for (int pass = 0; pass < 4; pass++) {
        int shift = pass * RADIX_BITS;

        int non_empty_buckets = 0;
        for (int i = 0; i < RADIX; i++) {
            if (hist[pass][i] != 0) {
                non_empty_buckets++;
            }
        }
        if (non_empty_buckets <= 1) continue;

        int offsets[RADIX];
        int total = 0;
        for (int i = 0; i < RADIX; i++) {
            offsets[i] = total;
            total += hist[pass][i];
        }

        for (int i = 0; i < n; i++) {
            int digit = (src[i] >> shift) & MASK;
            dst[offsets[digit]++] = src[i];
        }

        uint32_t *tmp = src;
        src = dst;
        dst = tmp;
    }

    if (src != arr) {
        memcpy(arr, src, (size_t)n * sizeof(uint32_t));
        free(src);
    } else {
        free(dst);
    }
}

void lsd_radix_sort(int32_t *arr, int n)
{
    if (n <= 1) return;

    uint32_t *data = (uint32_t *)arr;
    for (int i = 0; i < n; i++) {
        data[i] ^= 0x80000000u;
    }

    lsd_radix_sort_u32(data, n);

    for (int i = 0; i < n; i++) {
        data[i] ^= 0x80000000u;
    }
}

static inline uint32_t float_to_sortable(float f)
{
    uint32_t bits;
    memcpy(&bits, &f, sizeof(bits));

    return bits ^ (-(bits >> 31) | 0x80000000u);
}

static inline float sortable_to_float(uint32_t bits)
{
    uint32_t mask = ((bits >> 31) - 1) | 0x80000000u;
    float f;

    bits ^= mask;
    memcpy(&f, &bits, sizeof(f));
    return f;
}

void radix_sort_float(float *arr, int n)
{
    if (n <= 1) return;

    uint32_t *data = malloc((size_t)n * sizeof(uint32_t));
    if (!data) return;

    for (int i = 0; i < n; i++) {
        data[i] = float_to_sortable(arr[i]);
    }

    lsd_radix_sort_u32(data, n);

    for (int i = 0; i < n; i++) {
        arr[i] = sortable_to_float(data[i]);
    }

    free(data);
}

9.2 MSD 版本

#define MSD_RADIX 256
#define MSD_THRESHOLD 64

static void msd_radix_sort_impl(uint32_t *arr, int n, int shift)
{
    if (n <= 1) return;

    if (n < MSD_THRESHOLD) {
        insertion_sort_u32(arr, n);
        return;
    }

    if (shift < 0) return;

    int count[MSD_RADIX + 1] = {0};
    int starts[MSD_RADIX];
    int non_empty = 0;

    for (int i = 0; i < n; i++) {
        count[((arr[i] >> shift) & 0xFF) + 1]++;
    }

    for (int i = 1; i <= MSD_RADIX; i++) {
        count[i] += count[i - 1];
    }

    for (int i = 0; i < MSD_RADIX; i++) {
        starts[i] = count[i];
        if (count[i + 1] != count[i]) {
            non_empty++;
        }
    }

    if (non_empty <= 1) {
        msd_radix_sort_impl(arr, n, shift - 8);
        return;
    }

    uint32_t *aux = malloc((size_t)n * sizeof(uint32_t));
    if (!aux) return;

    for (int i = 0; i < n; i++) {
        int digit = (arr[i] >> shift) & 0xFF;
        aux[count[digit]++] = arr[i];
    }

    memcpy(arr, aux, (size_t)n * sizeof(uint32_t));
    free(aux);

    for (int d = 0; d < MSD_RADIX; d++) {
        int bucket_size = count[d] - starts[d];
        if (bucket_size > 1) {
            msd_radix_sort_impl(arr + starts[d], bucket_size, shift - 8);
        }
    }
}

void msd_radix_sort(int32_t *arr, int n)
{
    if (n <= 1) return;

    uint32_t *data = (uint32_t *)arr;

    for (int i = 0; i < n; i++) {
        data[i] ^= 0x80000000u;
    }

    msd_radix_sort_impl(data, n, 24);

    // 恢复符号位
    for (int i = 0; i < n; i++) {
        data[i] ^= 0x80000000u;
    }
}

9.3 ska_sort 风格版本

#define SKA_RADIX 256
#define SKA_THRESHOLD 128

static void ska_sort_impl(uint32_t *arr, int n, int shift)
{
    if (n <= 1 || shift < 0) return;

    if (n < SKA_THRESHOLD) {
        insertion_sort_u32(arr, n);
        return;
    }

    int count[SKA_RADIX] = {0};
    for (int i = 0; i < n; i++) {
        count[(arr[i] >> shift) & 0xFF]++;
    }

    int num_buckets = 0;
    for (int i = 0; i < SKA_RADIX; i++) {
        if (count[i] > 0) num_buckets++;
    }
    if (num_buckets == 1) {
        ska_sort_impl(arr, n, shift - 8);
        return;
    }

    int starts[SKA_RADIX];
    int ends[SKA_RADIX];
    int offsets[SKA_RADIX];
    int pos = 0;
    for (int i = 0; i < SKA_RADIX; i++) {
        starts[i] = pos;
        offsets[i] = pos;
        pos += count[i];
        ends[i] = pos;
    }

    for (int bucket = 0; bucket < SKA_RADIX; bucket++) {
        while (offsets[bucket] < ends[bucket]) {
            uint32_t elem = arr[offsets[bucket]];
            int target = (elem >> shift) & 0xFF;

            if (target == bucket) {
                offsets[bucket]++;
                continue;
            }

            do {
                uint32_t tmp = arr[offsets[target]];
                arr[offsets[target]] = elem;
                offsets[target]++;
                elem = tmp;
                target = (elem >> shift) & 0xFF;
            } while (target != bucket);

            arr[offsets[bucket]] = elem;
            offsets[bucket]++;
        }
    }

    for (int i = 0; i < SKA_RADIX; i++) {
        int bucket_size = ends[i] - starts[i];
        if (bucket_size > 1) {
            ska_sort_impl(arr + starts[i], bucket_size, shift - 8);
        }
    }
}

void ska_sort(int32_t *arr, int n)
{
    if (n <= 1) return;

    uint32_t *data = (uint32_t *)arr;

    for (int i = 0; i < n; i++) {
        data[i] ^= 0x80000000u;
    }

    ska_sort_impl(data, n, 24);

    // 恢复符号位
    for (int i = 0; i < n; i++) {
        data[i] ^= 0x80000000u;
    }
}

9.4 最小测试程序

static int cmp_int32(const void *a, const void *b)
{
    int32_t x = *(const int32_t *)a, y = *(const int32_t *)b;
    return (x > y) - (x < y);
}

static void verify(int32_t *arr, int n)
{
    for (int i = 1; i < n; i++) assert(arr[i - 1] <= arr[i]);
}

static void bench(const char *name, void (*fn)(int32_t *, int),
                  int32_t *orig, int n)
{
    int32_t *arr = malloc((size_t)n * sizeof(int32_t));
    if (!arr) return;

    memcpy(arr, orig, (size_t)n * sizeof(int32_t));
    clock_t t0 = clock();
    fn(arr, n);
    double ms = (double)(clock() - t0) / CLOCKS_PER_SEC * 1000;
    verify(arr, n);
    printf("  %-20s: %.2f ms\n", name, ms);
    free(arr);
}

int main(void)
{
    srand(42);
    int sizes[] = {10000, 100000, 1000000};
    for (int s = 0; s < 3; s++) {
        int n = sizes[s];
        int32_t *data = malloc((size_t)n * sizeof(int32_t));
        if (!data) return 1;

        for (int i = 0; i < n; i++)
            data[i] = (int32_t)rand() - RAND_MAX / 2;

        printf("n = %d:\n", n);
        bench("LSD Radix Sort", lsd_radix_sort, data, n);
        bench("MSD Radix Sort", msd_radix_sort, data, n);
        bench("ska_sort",       ska_sort,       data, n);

        int32_t *q = malloc((size_t)n * sizeof(int32_t));
        if (!q) {
            free(data);
            return 1;
        }

        memcpy(q, data, (size_t)n * sizeof(int32_t));
        clock_t t0 = clock();
        qsort(q, n, sizeof(int32_t), cmp_int32);
        printf("  %-20s: %.2f ms\n", "qsort (baseline)",
               (double)(clock() - t0) / CLOCKS_PER_SEC * 1000);
        free(q);
        free(data);
        printf("\n");
    }
    return 0;
}

上面这四段拼起来,就是一份可以直接编译、跑基准、验证正确性的最小示例。工程里当然还会继续做缓冲区复用、并行化和类型泛化,但主体结构基本就是这样。

十、与 pdqsort / std::sort 的性能对比

前面谈的是原理和实现,下面只收束到工程上最值得记住的性能趋势。

10.1 理论复杂度对比

算法时间(平均)时间(最坏)空间稳定
LSD 基数排序O(d * n)O(d * n)O(n + k)
MSD 基数排序O(d * n)O(d * n)O(n + d*k)
ska_sortO(d * n)O(d * n)O(d * k)
pdqsortO(n log n)O(n log n)O(log n)
std::sortO(n log n)O(n log n)O(log n)
std::stable_sortO(n log n)O(n log^2 n)O(n)

注:d = 位数/基数位数,k = 基数大小。pdqsort 的最坏情况通过 heapsort 回退保证。

10.2 常见性能趋势

如果没有给出 CPU 型号、编译器、优化选项、数据分布、键宽度和具体实现版本,那么看似精确的性能数字通常不值得完全相信。对这类文章而言,更稳妥的写法是总结“常见趋势”而不是罗列一张伪精确的成绩单。

在典型 x86-64 平台上,经验趋势通常如下:

数据类型或场景常见结果主要原因
32 位随机整数当 n 足够大且实现调优得当时,radix 常常优于 pdqsort轮数少,键提取便宜
64 位随机整数优势通常仍在,但会明显缩小需要更多 pass,内存流量更大
短字符串或前缀分化强的字符串MSD 可能占优,也可能与比较排序接近结果强依赖长度分布、字符集和前缀共享程度
大 struct 直接排序直接 radix 往往失去优势散射写入大对象的代价很高
大 struct 间接排序radix 可能重新变得有吸引力只搬运索引或键,避免搬动大对象

如果你的目标是写一篇工程文章,而不是发表基准测试报告,那么把这些趋势讲清楚,比给出一组脱离上下文的固定数字更有说服力。

10.3 数据分布的影响

数据分布对基数排序和比较排序的影响非常不同:

均匀随机分布:
    基数排序:时间通常更可预测,接近 O(dn)
    pdqsort:平均表现依然很强,但比较次数仍受分布影响

几乎有序:
    基数排序:通常无法明显利用已有顺序
    pdqsort:许多实现会因此受益,但收益依具体实现而异

少量唯一值(如 [0, 1, 2] 重复):
    基数排序:如果高位高度集中,可能减少有效轮次
    pdqsort:三路分区通常会明显受益

逆序:
    基数排序:与正序相比通常差别不大
    pdqsort:不少实现会比随机输入更好,但不应笼统写成 O(n)

pdqsort 这类自适应排序算法在非随机数据上有巨大优势。基数排序的「数据无关性」既是优点(可预测),也是缺点(无法利用结构)。

十一、工程陷阱

说完优势和适用边界,再看最容易踩的坑。以下是我在实践中遇到或观察到的常见问题:

陷阱现象解决方案
有符号整数位序错误负数排在正数后面最高字节排序时翻转符号位,或预处理 XOR 0x80000000
浮点数直接按位排序负数顺序反转,-0 和 +0 不相邻使用 float-to-sortable 转换(正数翻转符号位,负数翻转全部位)
LSD 子排序不稳定排序结果错误确保每轮使用稳定排序(计数排序从后向前遍历)
基数过大导致缓存抖动排序速度反而变慢基数选择应保证计数数组能放入 L1 缓存;256 是安全选择
大元素的散射写入缓存行利用率低,TLB miss 高对大结构体使用间接排序(排序索引数组)
忽略辅助数组的分配成本短数组上性能不如预期预分配辅助数组或在小 n 时回退到比较排序
对变长字符串使用 LSD需要填充到最大长度,浪费改用 MSD 基数排序,天然支持变长
忽略 NaN 的处理NaN 排序位置不确定预扫描过滤 NaN 或将其映射到特殊值
轮次间的多余复制每轮末尾 memcpy 浪费时间使用指针交换(ping-pong buffer)
计数数组未清零排序结果混乱每轮使用 calloc 或显式 memset
单桶未跳过值域集中时浪费轮次预扫描直方图,跳过只有一个非零桶的轮次
并行排序的竞争多线程直方图计数错误每个线程独立的局部直方图,最后合并

11.1 典型 Bug 示例

以下是一个常见的 bug——LSD 最后一轮结果可能在辅助数组中:

// 错误版本
void buggy_radix_sort(uint32_t *arr, int n)
{
    uint32_t *aux = malloc(n * sizeof(uint32_t));

    for (int pass = 0; pass < 4; pass++) {
        // ... 排序从 arr 到 aux ...

        // BUG: 每轮都把 aux 复制回 arr
        // 这浪费了一半的时间!
        memcpy(arr, aux, n * sizeof(uint32_t));
    }

    free(aux);
}

// 正确版本:使用 ping-pong buffer
void correct_radix_sort(uint32_t *arr, int n)
{
    uint32_t *buf = malloc(n * sizeof(uint32_t));
    uint32_t *src = arr;
    uint32_t *dst = buf;

    for (int pass = 0; pass < 4; pass++) {
        // ... 排序从 src 到 dst ...

        // 交换 src 和 dst
        uint32_t *tmp = src;
        src = dst;
        dst = tmp;
    }

    // 4 轮(偶数次交换),src == arr,结果正好在原数组
    // 如果是奇数轮,需要 memcpy
    if (src != arr) {
        memcpy(arr, src, n * sizeof(uint32_t));
    }

    free(buf);
}

11.2 别过度优化

我见过一些工程师为了追求极致性能,对基数排序做了过度优化:

  1. 使用 AVX-512 指令做直方图计数——实现复杂度显著上升,但收益常常取决于真正的瓶颈位置,并不稳定。
  2. 手写内存池避免 malloc——只有在高频、重复排序路径中才可能值得。
  3. 使用 16 位基数减少轮数——如果 count 数组压不住缓存,效果往往会适得其反。

优化应该从性能分析出发,而不是从直觉出发。在我的经验中,基数排序最大的性能杀手是缓存未命中,而不是指令数量。

十二、总结与思考

12.1 何时选择基数排序

前面的讨论可以压缩成一个简单的决策框架:

选择基数排序的判断框架可以概括为:

flowchart TD
        A["键是固定长度整数或定长二进制键"] -->|否| B["优先比较排序"]
        A -->|是| C["数据规模足够大 能摊销额外轮次和辅助空间"]
        C -->|否| B
        C -->|是| D["元素较小 或可以做间接排序"]
        D -->|否| B
        D -->|是| E["需要稳定性"]
        E -->|是| F["优先 LSD 基数排序"]
        E -->|否| G["前几位区分度高 或希望提前终止"]
        G -->|是| H["优先 MSD / American Flag / ska_sort 风格"]
        G -->|否| I["两类方案都可 再按缓存行为与实现复杂度选择"]

这个框架不是机械规则,但它能帮助你快速判断:当前问题到底是在 radix sort 的甜区,还是更适合交给自适应的比较排序。

12.2 基数排序在算法史中的位置

基数排序的历史比电子计算机还长。Herman Hollerith 在 1890 年的美国人口普查中就使用了基于打孔卡片的基数排序机。在早期大型机时代,基数排序因为避免了昂贵的比较操作而广泛使用。

随着 CPU 缓存层级的复杂化和比较排序算法的不断优化(从快速排序到 introsort 再到 pdqsort),基数排序在通用场景中的地位被逐渐取代。但在特定场景——大规模整数排序、网络数据包分类、数据库列存储——它仍然是不可替代的。

12.3 个人观点

我认为基数排序的价值不在于它是「最快的排序算法」(它在很多场景下不是),而在于它揭示了一个深刻的算法设计原则:改变问题的抽象层次,可以绕过看似不可逾越的理论限制。

O(n log n) 的下界是比较模型的下界,不是排序本身的下界。当你能直接访问键的内部结构时,就打开了一个新的设计空间。这个思路在其他领域也反复出现:

  • 哈希表绕过了比较搜索的 O(log n) 下界。
  • 布隆过滤器用概率方法绕过了精确集合成员测试的空间下界。
  • 量子算法用量子并行绕过了经典计算的查询复杂度下界。

每一次看似打破规则的突破,实际上都是找到了一个更合适的计算模型。理解这一点,比记住任何具体的排序算法都重要。

另一个值得深思的点是:理论最优不等于工程最优。基数排序在 RAM 模型下的 O(n) 看起来完美,但在真实的内存层级面前,缓存未命中的常数因子可以让「O(n)」比「O(n log n)」更慢。这提醒我们,分析算法不能只看大 O,还要看它背后的假设是否符合实际硬件。

最终,选择排序算法应该是一个工程决策:分析数据特征,测量实际性能,然后选择最适合当前场景的方案。基数排序是工具箱中的一把利器,但不是万能钥匙。

参考文献

  1. Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Chapter 8: Sorting in Linear Time.
  2. Knuth, D. E. (1998). The Art of Computer Programming, Volume 3: Sorting and Searching (2nd ed.). Addison-Wesley. Section 5.2.5: Sorting by Distribution.
  3. McIlroy, P. M., Bostic, K., & McIlroy, M. D. (1993). Engineering Radix Sort. Computing Systems, 6(1), 5-27.
  4. Skarupke, M. (2016). ska_sort: A Practical Implementation of Ska Sort. probablydance.com/2016/12/27/…
  5. Wassenberg, J., & Sanders, P. (2011). Engineering a Multi-core Radix Sort. Euro-Par 2011 Parallel Processing, 160-169.
  6. Obeya, O., Kahssay, E., Fan, E., & Shun, J. (2019). Theoretically-Efficient and Practical Parallel In-Place Radix Sorting. SPAA 2019.
  7. Peters, O. R. L. (2021). pdqsort: Pattern-Defeating Quicksort. github.com/orlp/pdqsor…
  8. LaMarca, A., & Ladner, R. E. (1999). The Influence of Caches on the Performance of Sorting. Journal of Algorithms, 31(1), 66-104.
  9. Satish, N., Harris, M., & Garland, M. (2009). Designing Efficient Sorting Algorithms for Manycore GPUs. IEEE IPDPS 2009.
  10. Polychroniou, O., Raghavan, A., & Ross, K. A. (2015). Rethinking SIMD Vectorization for In-Memory Databases. SIGMOD 2015.