惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V2EX - 技术
V2EX - 技术
P
Privacy International News Feed
Security Latest
Security Latest
H
Hacker News: Front Page
T
Tenable Blog
The Hacker News
The Hacker News
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
S
Security @ Cisco Blogs
Project Zero
Project Zero
O
OpenAI News
AI
AI
Spread Privacy
Spread Privacy
C
CERT Recently Published Vulnerability Notes
The Last Watchdog
The Last Watchdog
G
GRAHAM CLULEY
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Scott Helme
Scott Helme
Application and Cybersecurity Blog
Application and Cybersecurity Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
CXSECURITY Database RSS Feed - CXSecurity.com
NISL@THU
NISL@THU
A
Arctic Wolf
T
Threat Research - Cisco Blogs
PCI Perspectives
PCI Perspectives
N
News and Events Feed by Topic
C
Cyber Attacks, Cyber Crime and Cyber Security
C
Cybersecurity and Infrastructure Security Agency CISA
Simon Willison's Weblog
Simon Willison's Weblog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Know Your Adversary
Know Your Adversary
Google Online Security Blog
Google Online Security Blog
罗磊的独立博客
L
LINUX DO - 最新话题
U
Unit 42
S
Security Affairs
有赞技术团队
有赞技术团队
WordPress大学
WordPress大学
博客园 - 【当耐特】
T
The Exploit Database - CXSecurity.com
S
Schneier on Security
月光博客
月光博客
Engineering at Meta
Engineering at Meta
腾讯CDC
F
Full Disclosure
Cyberwarzone
Cyberwarzone
S
SegmentFault 最新的问题
Recorded Future
Recorded Future
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
博客园 - 司徒正美
The Cloudflare Blog

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 排序基准测试:用数据说话
基数排序:打破比较下界的正确姿势
2025-07-15 · via 土法炼钢兴趣小组的算法知识备份

每个学过算法的人都听过这样一句话:基于比较的排序算法,时间复杂度的下界是 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))

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 核心差异对比

维度 LSD MSD
处理方向 从最低位到最高位 从最高位到最低位
算法结构 迭代,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 尽可能大,但不超过 log2(n)。

最优基数 r ≈ n
最优位数 b ≈ log2(n)
最优总时间 = O(nW / log n)

实践中常见的选择:

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

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

每轮散射写入导致的缓存未命中:
  如果输出数组 > L3:几乎每次写入都 miss(灾难级)
  如果输出数组在 L3 内:miss 率取决于关联度
  如果输出数组在 L2 内:miss 率较低

当 n 很大时,每轮基数排序都会对输出数组产生大量缓存未命中。这些 miss 是写 miss(write miss),在现代 CPU 上通常比读 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 年提出的一种缓存友好的基数排序实现。它基于 American Flag Sort,但做了若干关键优化:

  1. 自适应基数选择:根据数据类型和分布自动选择最优基数。
  2. 小数组回退:当子数组小于阈值时切换到 std::sort。
  3. 就地分区:使用类似 AFS 的就地交换,避免辅助数组。
  4. 类型萃取:通过模板特化支持不同的数据类型。

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

七、为什么数据库排序不用基数排序

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 实际数据库的选择

数据库 排序算法 原因
PostgreSQL 外部归并排序 + 快速排序 通用性,支持自定义比较
MySQL (InnoDB) 归并排序 稳定性,外部排序需要
SQLite 归并排序 简单可靠,内存可控
ClickHouse 基数排序 + 归并排序 列存储,固定类型,OLAP 场景

注意 ClickHouse 的例外——它是列式存储数据库,排序键通常是固定长度的整数或日期类型,正是基数排序的最佳场景。这不是偶然的。

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

基数排序并非通用排序的替代品,但在以下几类场景中几乎无可匹敌。完整的 C 实现见第九节。

8.1 整数数组排序

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

8.2 浮点数排序

IEEE 754 浮点数有一个有趣的性质:如果把正浮点数的位模式解释为无符号整数,它们的大小顺序是一致的。负浮点数需要翻转所有非符号位。

/*
 * 浮点数到可排序整数的转换
 * 利用 IEEE 754 的位模式性质
 */
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;
}

void radix_sort_float(float *arr, int n)
{
    uint32_t *data = malloc(n * sizeof(uint32_t));
    if (!data) return;

    // 转换为可排序的整数表示
    for (int i = 0; i < n; i++) {
        data[i] = float_to_sortable(arr[i]);
    }

    // 使用无符号整数基数排序
    radix_sort_uint32(data, n);

    // 转换回浮点数
    for (int i = 0; i < n; i++) {
        arr[i] = sortable_to_float(data[i]);
    }

    free(data);
}

8.4 固定长度字符串

对于固定长度的字符串(如 MD5 哈希、UUID、IP 地址),MSD 基数排序特别高效:

/*
 * MSD 基数排序:固定长度字符串
 * 按字节从高到低递归排序
 */
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};  // 0: 字符串结束, 1-256: 字符值

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

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

    // 分配到辅助数组
    char **aux = malloc((hi - lo + 1) * sizeof(char *));
    for (int i = lo; i <= hi; i++) {
        int c = (unsigned char)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 c = 0; c < 256; c++) {
        int sub_lo = lo + count[c];
        int sub_hi = lo + count[c + 1] - 1;
        if (sub_lo < sub_hi) {
            msd_radix_sort_strings(arr, sub_lo, sub_hi,
                                   depth + 1, max_len);
        }
    }
}

8.5 IP 地址排序

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

九、完整实现

9.1 LSD 基数排序完整实现

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#define RADIX_BITS  8
#define RADIX       (1 << RADIX_BITS)  // 256
#define MASK        (RADIX - 1)        // 0xFF

/*
 * LSD 基数排序完整实现
 *
 * 特点:
 *   - 基数 256,32 位整数 4 轮完成
 *   - 跳过全部相同的数字位(单桶优化)
 *   - 支持有符号整数(符号位翻转)
 *   - 使用指针交换避免不必要的复制
 *
 * 参数:
 *   arr  - 待排序数组
 *   n    - 数组长度
 *
 * 时间复杂度:O(d * n),d = 4(32 位 / 8 位)
 * 空间复杂度:O(n)
 */
void lsd_radix_sort(int32_t *arr, int n)
{
    if (n <= 1) return;

    uint32_t *src = (uint32_t *)arr;
    uint32_t *dst = malloc(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 skip = 0;
        for (int i = 0; i < RADIX; i++) {
            if (hist[pass][i] == n) {
                skip = 1;
                break;
            }
        }
        if (skip) continue;

        // 将直方图转为前缀和(起始偏移量)
        int offsets[RADIX];
        int total = 0;
        for (int i = 0; i < RADIX; i++) {
            offsets[i] = total;
            total += hist[pass][i];
        }

        // 对于最高字节(pass==3),处理有符号整数
        if (pass == 3) {
            total = 0;
            // 负数先(0x80-0xFF)
            for (int i = 128; i < 256; i++) {
                offsets[i] = total;
                total += hist[3][i];
            }
            // 正数后(0x00-0x7F)
            for (int i = 0; i < 128; i++) {
                offsets[i] = total;
                total += hist[3][i];
            }
        }

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

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

    // 确保结果在原数组中
    if (src != (uint32_t *)arr) {
        memcpy(arr, src, n * sizeof(int32_t));
        free(src);
    } else {
        free(dst);
    }
}

9.2 MSD 基数排序完整实现

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#define MSD_RADIX      256
#define MSD_THRESHOLD  64   // 小数组切换到插入排序

/*
 * 插入排序:用于小数组
 */
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;
    }
}

/*
 * MSD 基数排序:递归核心
 *
 * 参数:
 *   arr   - 待排序子数组
 *   n     - 子数组长度
 *   shift - 当前处理的位偏移(从高到低:24, 16, 8, 0)
 */
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};

    // 计数
    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];
    }

    // 分配到辅助数组
    uint32_t *aux = malloc(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, n * sizeof(uint32_t));
    free(aux);

    // 对每个桶递归排序
    int start = 0;
    for (int d = 0; d < MSD_RADIX; d++) {
        int end = count[d];
        int bucket_size = end - start;
        if (bucket_size > 1) {
            msd_radix_sort_impl(arr + start, bucket_size, shift - 8);
        }
        start = end;
    }
}

/*
 * MSD 基数排序:入口函数
 * 支持 32 位有符号整数
 */
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 风格的就地基数排序

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#define SKA_RADIX      256
#define SKA_THRESHOLD  128

/*
 * 插入排序:用于小数组
 */
static void ska_insertion_sort(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;
    }
}

/*
 * ska_sort 风格的就地 MSD 基数排序
 *
 * 核心优化:
 *   1. 就地分区,不需要辅助数组
 *   2. 循环置换(cycle sort)风格的元素交换
 *   3. 小数组回退到插入排序
 *   4. 单桶跳过优化
 *
 * 参数:
 *   arr   - 待排序子数组
 *   n     - 子数组长度
 *   shift - 当前处理的位偏移
 */
static void ska_sort_impl(uint32_t *arr, int n, int shift)
{
    if (n <= 1 || shift < 0) return;

    if (n < SKA_THRESHOLD) {
        ska_insertion_sort(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);
        }
    }
}

/*
 * ska_sort 入口:32 位有符号整数
 */
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 测试程序

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

void lsd_radix_sort(int32_t *arr, int n);
void msd_radix_sort(int32_t *arr, int n);
void ska_sort(int32_t *arr, int n);

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(n * sizeof(int32_t));
    memcpy(arr, orig, 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(n * sizeof(int32_t));
        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(n * sizeof(int32_t));
        memcpy(q, data, 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_sort O(d * n) O(d * n) O(d * k)
pdqsort O(n log n) O(n log n) O(log n)
std::sort O(n log n) O(n log n) O(log n)
std::stable_sort O(n log n) O(n log^2 n) O(n)

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

10.2 跨数据类型的实际性能

以下是不同数据类型和数据规模下的性能对比(基于典型 x86-64 平台的经验数据)。相对性能用 pdqsort 作为基准 1.0x:

32 位整数排序(均匀随机)

n LSD Radix ska_sort pdqsort std::sort
1K 1.2x 1.5x 1.0x 1.1x
10K 0.8x 0.7x 1.0x 1.1x
100K 0.5x 0.4x 1.0x 1.1x
1M 0.4x 0.35x 1.0x 1.1x
10M 0.35x 0.3x 1.0x 1.1x

注:< 1.0x 表示比 pdqsort 快。基数排序在 n > 10K 时开始显现优势。

64 位整数排序(均匀随机)

n LSD Radix ska_sort pdqsort
1K 1.8x 1.6x 1.0x
10K 1.1x 0.9x 1.0x
100K 0.7x 0.6x 1.0x
1M 0.6x 0.5x 1.0x

64 位整数需要 8 轮(LSD),优势减小。

字符串排序(随机 ASCII,平均长度 16)

n MSD Radix std::sort
1K 1.1x 1.0x
10K 0.9x 1.0x
100K 0.7x 1.0x
1M 0.6x 1.0x

MSD 基数排序在字符串上也能胜出,但优势不如整数场景明显。

struct 排序(按 32 位整数键)

n LSD Radix pdqsort
100K (8B struct) 0.5x 1.0x
100K (64B struct) 1.3x 1.0x
100K (256B struct) 2.5x 1.0x

关键发现:当元素体积增大时,基数排序的优势迅速消失。散射写入大元素比交换两个指针(间接排序)贵得多。对于大元素,应该先提取键做间接排序。

10.3 分布对性能的影响

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

均匀随机分布:
  基数排序:稳定的 O(dn),与分布无关
  pdqsort:平均 O(n log n),分区质量好

几乎有序:
  基数排序:仍然是 O(dn),无法利用已有顺序
  pdqsort:接近 O(n),插入排序回退非常快

少量唯一值(如 [0, 1, 2] 重复):
  基数排序:可能只需 1-2 轮(高位全部相同,可跳过)
  pdqsort:Dutch National Flag 三路分区,接近 O(n)

逆序:
  基数排序:O(dn),与正序一样
  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 指令做直方图计数——代码复杂度增加 10 倍,性能提升不到 5%。
  2. 手写内存池避免 malloc——对于一次性排序,malloc 的开销可以忽略。
  3. 使用 16 位基数减少轮数——计数数组从 1 KB 膨胀到 256 KB,缓存反而更差。

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

十二、总结与思考

12.1 何时选择基数排序

选择基数排序的判断框架:

问题 1:键是否是固定长度的整数或定长字符串?
  否 -> 使用比较排序(pdqsort / timsort)
  是 -> 继续

问题 2:n 是否足够大(> 10K)使基数排序的常数因子被摊销?
  否 -> 使用比较排序
  是 -> 继续

问题 3:元素大小是否较小(<= 8 字节)或可以使用间接排序?
  否 -> 使用比较排序(大元素的散射写入太贵)
  是 -> 继续

问题 4:是否需要稳定排序?
  是 -> LSD 基数排序
  否 -> ska_sort / American Flag Sort

问题 5:数据是否可能已经部分有序?
  是 -> pdqsort 可能更快(自适应性)
  否 -> 基数排序

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. https://probablydance.com/2016/12/27/i-wrote-a-faster-sorting-algorithm/
  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. https://github.com/orlp/pdqsort
  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.

算法系列导航上一篇:pdqsort | 下一篇:外部排序

相关阅读SIMD 算法设计模式 | 排序基准测试

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-10 · algorithms

排序算法专题:从 TimSort 到并行排序

把 TimSort、pdqsort、radix sort、external sort、parallel sort 与 benchmark 串成一条阅读路径。先读哪篇、什么时候选哪种排序,这一页讲清。

2025-07-15 · algorithms

外部排序:当数据装不进内存

当数据量超过内存容量时,排序算法面临全新的挑战。从败者树到异步 I/O,外部排序的工程实现远比教科书描述的复杂。