8.4 非比较排序:计数排序与基数排序
学习目标:学完本节,你能
- 说清计数排序为什么能突破 $O(n \log n)$ 下界;
- 手写计数排序,并说清"稳定版"为什么要从后往前遍历;
- 用基数排序处理大值域的数据,并说清它为什么依赖稳定性;
- 说出它们的前提条件 —— 什么情况下不能用。
先修:7.4(下界)、7.2(稳定性)。 固定术语:计数排序、基数排序、值域、桶。 环境与版本:.NET 8 / C# 12。 预计阅读:30 分钟。
一、直觉:不比较,直接数
7.4 节证明了:任何比较排序至少需要 $\log_2(n!)$ 次比较,也就是 $\Omega(n \log n)$。
但这个下界有一个前提 —— 你的算法必须"通过比较来获取信息"。
如果不比较呢?
给一群学生按成绩排序。成绩是 0 到 100 的整数。
你不需要比较任何两个学生的成绩 —— 你只需要数一下: "60 分的有 2 个、78 分的有 3 个、85 分的有 4 个……" 然后按分数从低到高,把对应数量的学生依次放回去。
这就是计数排序。它一次比较都没做。
为什么能突破下界? 因为它没有"猜" —— 它直接"看"了每个元素的值,然后算出它该在哪。信息不是通过比较"问"出来的,而是直接读出来的。
这就是 7.4 节说的"跳出比较模型":
不是找到了更聪明的比较方式,而是根本不用比较。
二、计数排序
/// <summary>朴素计数排序(不保证稳定)。</summary>
static void CountingSort(int[] a, int maxValue, ref long ops)
{
var count = new int[maxValue + 1];
foreach (int x in a) { count[x]++; ops++; } // 统计每个值出现几次
int idx = 0;
for (int v = 0; v <= maxValue; v++) // 按值的顺序从小到大输出
for (int k = 0; k < count[v]; k++)
{
a[idx++] = v;
ops++;
}
}
就这么简单。三步:
- 开一个数组
count,大小是"值域的最大值 + 1" - 遍历原数组,
count[a[i]]++—— 统计每个值出现几次 - 按值的顺序(0, 1, 2, ...)依次输出对应次数
实测:
原始成绩: [85, 92, 78, 85, 95, 78, 92, 85, 60, 100, 78, 85]
排序后 : [60, 78, 78, 78, 85, 85, 85, 85, 92, 92, 95, 100]
操作次数: 24(统计 12 次 + 输出 12 次)
复杂度:$O(n + k)$,其中 $k$ 是值域大小($maxValue + 1$)。
- $n$ 部分:遍历原数组统计
- $k$ 部分:遍历整个值域输出
三、前提条件:值域必须小
$O(n + k)$ 里的 $k$ 是关键。如果 $k$ 远大于 $n$,这个算法就废了。
具体看:
| 数据 | $n$ | 值域 $k$ | 复杂度 | 可行性 |
|---|---|---|---|---|
| 学生成绩(0~100) | 100 万 | 101 | $O(n)$ | ✅ 完美 |
| 年龄(0~150) | 100 万 | 151 | $O(n)$ | ✅ 完美 |
| 用户 ID(0~10 亿) | 100 万 | 10 亿 | $O(n + k)$ | ❌ 要 3 GB 内存 |
实测最后一行:
场景 3:如果非要用计数排序排 0~999,999,999 的数呢?
需要开一个 10 亿长度的 int 数组 = 3 GB 内存
而实际只有 1,000,000 个元素 —— 内存利用率 0.1%
-> 完全不可行。这就是计数排序的前提条件为什么重要。
记住这条判断标准:
只有 $k = O(n)$ 时,计数排序才真正优于比较排序。
如果 $k \gg n$(比如值域是 $2^{32}$ 而只有几万个元素),计数排序不仅不快,而且会直接把内存撑爆。
值域大怎么办?下一节的基数排序解决了这个问题。
四、稳定版计数排序(基数排序的基础)
朴素版的计数排序有个问题:它不能保证稳定。
为什么?看第 3 步:a[idx++] = v —— 它只是按值输出,完全不管原来谁在前谁在后。
要让它稳定,需要换一种放置方式:
/// <summary>稳定版计数排序 —— 基数排序的基础。</summary>
static int[] CountingSortStable(int[] a, int maxValue, ref long ops)
{
var count = new int[maxValue + 1];
foreach (int x in a) { count[x]++; ops++; }
// 前缀和:count[i] 变成「小于等于 i 的元素有几个」
for (int i = 1; i <= maxValue; i++) { count[i] += count[i - 1]; ops++; }
var output = new int[a.Length];
// 关键:从后往前遍历,把元素放到「它该在的最后一个位置」
// 这样相等的元素会保持原来的相对顺序 —— 这就是稳定性的来源
for (int i = a.Length - 1; i >= 0; i--)
{
output[--count[a[i]]] = a[i];
ops++;
}
return output;
}
关键的两步:
第 1 步:把 count 转成前缀和。
统计次数: count[1]=2, count[2]=1, count[3]=3
前缀和后: count[1]=2, count[2]=3, count[3]=6
前缀和的含义变成了:"值 $\le i$ 的元素一共有几个"。
所以值等于 3 的元素,应该放在下标 2、3、4 这三个位置(因为 $count[2]=3$,说明前 3 个位置留给 1 和 2)。
第 2 步:从后往前遍历,output[--count[a[i]]] = a[i]。
这一步是稳定性的关键:
- 从后往前遍历原数组
- 每放置一个元素,就把
count[值]减一 —— 这样下一个同值的元素会放到前一个位置 - 因为是从后往前处理的,同值的元素里"原本靠后的"会先被处理,放进更靠后的位置
结果:同值元素的相对顺序被保持 ✓
实测验证(用 (值, 原始序号) 标记):
用 (值, 原始序号) 验证:
原始: 3#0 1#1 3#2 2#3 1#4 3#5
排序后: 1#1 1#4 2#3 3#0 3#2 3#5
稳定性: 稳定 ✓(同值的 #序号 保持递增)
看 3#0 3#2 3#5 —— 三个值为 3 的元素,原始序号 0 < 2 < 5,排序后依然保持这个顺序 ✓
五、基数排序:按位拆分,逐位排序
值域太大,计数排序用不了。但我们有另一个思路:把大数拆成一位一位的。
比如 329 可以拆成 3、2、9 三位。每一位的范围只有 0~9 —— 计数数组只需要 10 个元素。
基数排序(LSD,从最低位开始):
从个位开始,每一位做一次【稳定】的计数排序。做到最高位,整个数组就有序了。
为什么这样做是对的?
这就是 7.2 节讲的"从低位到高位排序":
- 先按个位排,让"个位小的"排在前面
- 再按十位排(稳定),十位相同的会保持上次个位的顺序
- 再按百位排(稳定),百位相同的保持前两次的顺序
结果:按百位、十位、个位的优先级依次有序 —— 正是我们想要的。
实测([329, 457, 657, 839, 436, 720, 355]):
按个位排序后: [720, 355, 436, 457, 657, 329, 839]
按十位排序后: [720, 329, 436, 839, 355, 457, 657]
按百位排序后: [329, 355, 436, 457, 657, 720, 839]
每一步都必须是稳定的 —— 否则"低位已经排好的顺序"会被破坏。
基数排序是"稳定性为什么重要"的最强论据。
7.2 节说"稳定性用于多关键字排序",基数排序正是这个思想的极致应用:它不是两个关键字,而是 $d$ 个关键字($d$ = 位数)。
实现:
static void RadixSort(int[] a, ref long ops)
{
if (a.Length == 0) return;
int max = a.Max();
// exp 依次取 1, 10, 100, ... 代表当前处理的是哪一位
for (int exp = 1; max / exp > 0; exp *= 10)
{
var count = new int[10]; // 每一位只有 0~9 十种可能
foreach (int x in a) { count[(x / exp) % 10]++; ops++; }
for (int i = 1; i < 10; i++) { count[i] += count[i - 1]; ops++; }
var output = new int[a.Length];
for (int i = a.Length - 1; i >= 0; i--)
{
output[--count[(a[i] / exp) % 10]] = a[i];
ops++;
}
Array.Copy(output, a, a.Length);
}
}
复杂度:$O(d \cdot n)$,其中 $d$ 是最大数的位数。
对 32 位整数,$d \le 10$ —— 所以是 $O(10n) = O(n)$ 量级。
六、实测:非比较排序有多快
=== 实验四:非比较排序 vs 快速排序(n = 1,000,000)===
场景 1:值域 0~999(很小)
计数排序: 1.0 ms(操作 2,000,000 次)
快速排序: 469.3 ms(比较 513,696,932 次)
计数排序快 447.4 倍,结果一致=True
场景 2:值域 0~999,999,999(很大,10 位数)
基数排序: 42.5 ms(操作 18,000,081 次)
快速排序: 58.9 ms(比较 24,283,927 次)
基数排序快 1.4 倍,结果一致=True
场景 1 的 447 倍差距是我做过的最极端的对比。但要诚实地解释它:
这个 447 倍里,有两个因素叠加:
- 计数排序确实快 —— $O(n + k) = O(100万 + 1000)$,几乎是纯线性
- 快排在这个数据上严重退化 —— 值域只有 1000,100 万个元素里大量重复(平均每个值出现 1000 次)
第 2 点正是 8.2 节讲的"大量重复元素导致快排退化"!
所以这不是一个"公平"的对比 —— 但也正因为如此,它真实地反映了这类数据的实际情况。
如果你要排序的数据天然有大量重复(比如用户状态、订单类型、评分),快排的表现会远低于你的预期。这时候要么用三路分区,要么用计数排序。
场景 2 更能反映"公平对比":
- 值域 10 亿(离散、几乎没有重复)
- 基数排序 42.5 ms vs 快排 58.9 ms
- 基数排序快 1.4 倍
为什么没有场景 1 那么夸张?
因为基数排序要做 10 轮(10 位数,每轮一次完整的数组遍历 + 复制)。虽然每轮是 $O(n)$,但常数不小(要分配临时数组、复制数据)。
这就是基数排序的代价:它把 $O(n \log n)$ 的比较换成了 $d$ 轮的数组操作 —— 总操作数少了,但每次操作的常数大了。
七、三种非比较/比较排序的对比
| 计数排序 | 基数排序 | 快速排序 | |
|---|---|---|---|
| 复杂度 | $O(n + k)$ | $O(d \cdot n)$ | 平均 $O(n \log n)$ |
| 前提 | 值域 $k$ 小($k = O(n)$) | 能按位拆分 | 元素可比较 |
| 额外空间 | $O(n + k)$ | $O(n)$ | $O(\log n)$ |
| 稳定性 | ✅ 可以做到 | ✅ (依赖子过程稳定) | ❌ |
| 实测(100 万) | 1.0 ms(值域 1000) | 42.5 ms(10 位数) | 58.9 ms |
| 能用在大值域吗 | ❌ 内存爆炸 | ✅ | ✅ |
选择流程:
要排序的数据是什么?
│
├─ 值域很小(比如 0~1000 的评分、状态码)
│ -> 计数排序,O(n + k),快得离谱
│
├─ 值域很大但是整数,且位数不多(比如 32 位整数)
│ -> 基数排序,O(d·n),比比较排序快
│
└─ 其他情况(字符串、浮点数、自定义对象、值域未知)
-> 用比较排序(Array.Sort / OrderBy)
最后一条很重要:基数排序只适用于整数(或能映射成整数的键)。
排序字符串可以按字符位做基数排序,浮点数可以按其二进制表示做 —— 但这些实现都很复杂,收益也不一定大。
工程上的现实:绝大多数场景用
Array.Sort就够了。只有在"值域小"或"整数且量大"这两个明确条件下,非比较排序才值得考虑。
八、练习
练习 8.4.1(手写计数排序)
对数组 [4, 1, 3, 4, 2, 1, 4, 3] 做计数排序:
(a) 写出 count 数组(值域 0~4)
(b) 写出前缀和后的 count
(c) 写出稳定版排序的最终结果
练习 8.4.2(判断前提) 下面四组数据,哪些适合用计数排序?哪些适合基数排序?哪些都不适合? (a) 100 万条用户年龄(0~120) (b) 100 万条订单金额(0 到 999999.99 元,两位小数) (c) 100 万条 UUID 字符串 (d) 1000 万条 32 位整数(分布在整个 int 范围)
练习 8.4.3(判断) 判断对错并说明理由: (a) 计数排序的复杂度是 $O(n)$,所以它总是比快排快。 (b) 基数排序的每一轮可以用任意排序算法。 (c) 计数排序能突破 7.4 节证明的 $O(n \log n)$ 下界,说明那个下界是错的。
练习 8.4.4(工程判断) 一个日志系统要统计"每个 HTTP 状态码出现了多少次",日志有 10 亿条。 (a) 你会用什么方法? (b) 为什么不用哈希表? (c) 如果要知道"中位数状态码",你的方案要改吗?
练习 8.4.5(挑战·基数排序的优化) 标准的基数排序用 10 进制(每一位 0~9),要做 10 轮。 (a) 如果改用 256 进制(每次处理 1 个字节),需要几轮? (b) 这样做的好处和坏处分别是什么? (c) 实际实现中还会考虑什么?
九、练习答案
8.4.1
数组 [4, 1, 3, 4, 2, 1, 4, 3],值域 0~4。
(a) 统计次数(初始 count 全为 0):
| 元素 | 4 | 1 | 3 | 4 | 2 | 1 | 4 | 3 |
|---|---|---|---|---|---|---|---|---|
| 处理后 | count[4]=1 | count[1]=1 | count[3]=1 | count[4]=2 | count[2]=1 | count[1]=2 | count[4]=3 | count[3]=2 |
count = [0, 2, 1, 2, 3](下标 0~4)
验证:值 0 出现 0 次、值 1 出现 2 次、值 2 出现 1 次、值 3 出现 2 次、值 4 出现 3 次 —— 总计 8 个 ✓
(b) 前缀和:
count[0] = 0
count[1] = 0 + 2 = 2
count[2] = 2 + 1 = 3
count[3] = 3 + 2 = 5
count[4] = 5 + 3 = 8
count = [0, 2, 3, 5, 8]
含义:值 $\le 0$ 的有 0 个、值 $\le 1$ 的有 2 个、值 $\le 2$ 的有 3 个、值 $\le 3$ 的有 5 个、值 $\le 4$ 的有 8 个(全部)。
(c) 稳定版排序(从后往前遍历原数组):
| 步骤 | $i$ | $a[i]$ | --count[a[i]] |
放入位置 | output |
|---|---|---|---|---|---|
| 1 | 7 | 3 | --count[3] = 4 |
下标 4 | [_,_,_,_,3,_,_,_] |
| 2 | 6 | 4 | --count[4] = 7 |
下标 7 | [_,_,_,_,3,_,_,4] |
| 3 | 5 | 1 | --count[1] = 1 |
下标 1 | [_,1,_,_,3,_,_,4] |
| 4 | 4 | 2 | --count[2] = 2 |
下标 2 | [_,1,2,_,3,_,_,4] |
| 5 | 3 | 4 | --count[4] = 6 |
下标 6 | [_,1,2,_,3,_,4,4] |
| 6 | 2 | 3 | --count[3] = 3 |
下标 3 | [_,1,2,3,3,_,4,4] |
| 7 | 1 | 1 | --count[1] = 0 |
下标 0 | [1,1,2,3,3,_,4,4] |
| 8 | 0 | 4 | --count[4] = 5 |
下标 5 | [1,1,2,3,3,4,4,4] |
最终结果:[1, 1, 2, 3, 3, 4, 4, 4] ✓
注意第 1 步和第 5 步:原数组里下标 3 和下标 6 的值都是 4。
- 先处理下标 6(因为从后往前),它被放到下标 7
- 后处理下标 3,它被放到下标 6
所以"原本靠后的 4"放在了"更靠后的位置" —— 相对顺序保持 ✓
8.4.2
(a) 计数排序。 $n = 100$ 万,值域 $k = 121$。$k \ll n$,完美适配。
计数数组只需 121 个 int(不到 500 字节),而数据有 100 万条。极其高效。
(b) 计数排序(转成整数后)。 金额有两位小数,乘以 100 转成整数即可:值域 0 到 99999999(约 1 亿)。
但要注意:$k = 10^8$,$n = 10^6$ —— $k$ 是 $n$ 的 100 倍。
计数数组需要 $10^8 \times 4$ 字节 = 400 MB,而数据本身只有几 MB。不划算。
更好的选择是基数排序(按位拆分,每轮只需 10 个桶),或者直接用比较排序。
(c) 都不适合。 UUID 是 128 位的字符串,无法按位拆分做基数排序(会需要 $2^{128}$ 个桶),计数排序更是完全不可能。
用比较排序(
Array.Sort用的字符串比较)。或者如果只需要去重/查找,考虑哈希表。
(d) 基数排序。 32 位整数,分布在整个 int 范围。
计数排序不可行:$k = 2^{32} \approx 43$ 亿,需要 16 GB 内存。
基数排序可行:按 10 进制拆分需要 10 轮,按 256 进制拆分只需要 4 轮(见练习 8.4.5)。
但 1000 万条数据的基数排序要分配多个临时数组,内存开销也要考虑。实际中
Array.Sort可能已经够快。
8.4.3
- (a) 错。 有两个问题:
- 复杂度是 $O(n + k)$ 而不是 $O(n)$ —— $k$ 是值域。$k$ 很大时它比快排慢得多(甚至内存溢出)。
- "总是比快排快"不成立 —— 只有 $k = O(n)$ 时才快。$k = 10^9$、$n = 10^4$ 时,计数排序要开 4 GB 数组,而快排几毫秒就排完了。
- (b) 错,而且这是基数排序的命门。 每一轮必须是【稳定】排序。
否则低位排好的顺序会被破坏。 举个反例:如果按十位排序时不稳定,那些"十位相同、个位不同"的元素可能被打乱 —— 那么上一轮按个位排的成果就白费了。
这就是为什么基数排序必须用"稳定版计数排序" —— 不能图省事用朴素版的。
- (c) 错。 下界没有错,只是有前提。
7.4 节的证明明确说了:"任何比较排序至少需要 $\log_2(n!)$ 次比较"。
计数排序不是比较排序 —— 它一次比较都没做,所以那条下界不适用于它。
"前提不成立,结论就不适用" —— 这是 7.4 节反复强调的。
8.4.4
(a) 用计数排序(或者说,就是"计数")。
HTTP 状态码的取值范围是有限的 —— 大致是 100~599,实际常用的只有十几个(200、301、404、500……)。
方案:
开一个 int[600] 的计数数组(或者用 Dictionary 存出现过的状态码)
遍历 10 亿条日志,count[statusCode]++
输出非零的项
复杂度:$O(n)$,一次遍历搞定。10 亿条数据只需要 10 亿次数组自增,而且内存只有几 KB。
(b) 哈希表也能做,但比计数数组差。
| 计数数组 | 哈希表 | |
|---|---|---|
| 内存 | 600 个 int(约 2.4 KB) | 每个键值对约 50 字节,但键很少(十几个),所以也很小 |
| 每次操作 | 一次数组下标访问 + 自增 | 算哈希 + 定位 + 比较 |
| 速度 | 快得多 | 慢 3~10 倍 |
关键优势在于:状态码的数量几乎固定,而且很少。
用数组下标直接定位,是 $O(1)$ 里最快的那种 $O(1)$ —— 没有哈希计算、没有冲突处理、缓存友好(600 个 int 只占 2.4 KB,完全在 L1 缓存里)。
这就是"值域小"的威力:一旦值域确定且不大,数组下标就是最好的哈希函数。
这其实是 6.1 节讲的"直接寻址表" —— 它虽然适用范围窄,但在适用的时候是无可匹敌的。
(c) 要改。
中位数需要"按状态码排序后取中间那个",这需要知道每个状态码的累计出现次数。
方案:
- 用计数数组得到每个状态码的次数
count[code] - 对计数数组做前缀和:
prefix[i] = 状态码 ≤ i 的日志总数 - 找到最小的 $i$ 使得
prefix[i] >= n/2—— 那个 $i$ 就是中位数状态码
复杂度:$O(n + k)$,其中 $k = 600$(值域),几乎还是 $O(n)$。
注意这里用到的正是"前缀和" —— 和 1.4 节的前缀和、以及本节稳定版计数排序里的前缀和是同一个技巧。
前缀和出现的地方太多了:区间求和、计数排序、找中位数、找分位数…… 它是把"$O(n)$ 的重复查询"变成"$O(1)$ 单次查询"的通用手段。
8.4.5
(a) 4 轮。
32 位整数 = 4 个字节。每轮处理 1 个字节(8 位,取值范围 0~255,正好 256 个桶)。
$$\lceil 32 / 8 \rceil = 4 \text{ 轮}$$
对比 10 进制:$\lceil 32 \log_{10} 2 \rceil = \lceil 9.63 \rceil = 10$ 轮。
从 10 轮降到 4 轮。
(b) 好处和坏处:
| 256 进制(4 轮) | 10 进制(10 轮) | |
|---|---|---|
| 轮数 | 4 轮 ✅ | 10 轮 ❌ |
| 每轮桶数量 | 256 个 ❌ | 10 个 ✅ |
| 计数数组大小 | 256 个 int(1 KB) | 10 个 int(40 字节) |
| 总操作量 | $4n$ | $10n$ |
好处:轮数减少 60%,总操作量从 $10n$ 降到 $4n$。
坏处:
- 每轮的计数数组更大(256 vs 10)。不过这通常不是问题 —— 1 KB 而已。
- "清空计数数组"的成本变高。每轮开始前要
Array.Clear一个 256 长度的数组 —— 虽然还是很小。 - 位数提取要用位运算而不是除法。
(x >> 8) & 0xFF比(x / 100) % 10快得多 —— 这其实是好事。
综合来看,256 进制几乎全面优于 10 进制。
(c) 实际实现还要考虑:
- 符号位处理。
int有负数,直接按位拆分会把负号位当最高位处理,导致负数排在正数后面。标准做法是把符号位翻转一下(x ^ int.MinValue),让所有数变成"无符号的相对顺序"。 - 内存分配。 每轮都要分配一个 $n$ 大小的临时数组。预分配一个、反复复用(和 8.1 节归并排序的优化是同一个道理)。
- 缓存友好性。 256 个桶的分散写入比 10 个桶更"散" —— 桶越多,写入越随机。这是 256 进制唯一的实质劣势。
- 阈值切换。 和快排一样,小数组可以切换成插入排序(基数排序在小数据上开销不划算)。
- MSD vs LSD。 从最高位开始(MSD)可以提前终止(某一位的桶里只有一个元素时就不用继续了),但实现更复杂。LSD 更简单,所以更常用。
一个有意思的事实:很多标准库的基数排序实现会根据数据特征自动选择 —— 比如先检查值的范围,如果小就直接计数排序,大了才用基数排序。
这正是 8.5 节的主题:生产级的排序实现不是"用一个算法",而是"根据数据特征在多个算法之间切换"。
十、常见错误
| 误区 | 纠正 |
|---|---|
| 认为计数排序"总是 $O(n)$" | 是 $O(n + k)$。$k$ 是值域,$k \gg n$ 时会内存爆炸(实测 10 亿值域要 3 GB)。 |
| 以为计数排序能推翻 $n \log n$ 下界 | 下界只约束比较排序。计数排序不比较,所以不受约束。前提不成立,结论不适用。 |
| 基数排序每一轮用任意排序 | 必须用稳定排序,否则低位排好的顺序会被破坏。 |
| 稳定版计数排序从前往后遍历 | 必须从后往前。这样才能让"原本靠后的同值元素"放到"更靠后的位置"。 |
| 用计数排序排 UUID 或大范围整数 | 值域太大。改用比较排序,或对整数用基数排序。 |
| 排序浮点数时用基数排序 | 浮点数的位表示不是单调的(负数部分相反)。需要特殊处理符号位。 |
十一、本节总结
- 计数排序一次比较都不做 —— 它直接"看"元素的值,所以不受 $O(n \log n)$ 下界约束(那条下界只针对比较排序)。
- 复杂度 $O(n + k)$,$k$ 是值域大小。前提是 $k = O(n)$ —— 否则内存爆炸(实测 10 亿值域需要 3 GB)。
- 稳定版必须用前缀和 + 从后往前放置。这两步合起来保证了同值元素的相对顺序。
- 基数排序按位拆分,每一位做一次稳定排序。复杂度 $O(d \cdot n)$,$d$ 是位数。
- 基数排序是"稳定性为什么重要"的最强论据 —— 它把 7.2 节的"多关键字排序"思想用到了极致。
- 实测差距惊人:值域 1000 时计数排序比快排快 447 倍(但其中也含"快排对大量重复元素退化"的因素);10 位数时基数排序快 1.4 倍。
- 做统计时,数组下标往往比哈希表更好 —— HTTP 状态码计数就是例子:600 个 int 的数组完全在缓存里,没有哈希计算、没有冲突。
- 实际收益递减:基数排序要做 $d$ 轮,每轮都要复制数组。只有数据量大、值域明确时才对比较排序有实质优势。
下一节衔接:到这里,本书讲的所有排序算法都齐了。但一个现实的问题是:面对具体需求,到底该选哪个? 而且 —— 为什么 Array.Sort 比我们手写的快排快一倍?它到底做了什么? 下一节回答这两个问题,并给出可以直接照抄的决策流程。
状态外显
{
"book": "数据结构与算法自学:从工程直觉到复杂度思维",
"section": "8.4",
"title": "非比较排序:计数排序与基数排序",
"covered": [
"计数排序的直觉(不比较,直接数)与突破下界的原理",
"朴素计数排序实现与 O(n+k) 复杂度",
"值域前提的实测(10 亿值域需 3GB 内存)",
"稳定版计数排序(前缀和 + 从后往前放置)与稳定性验证",
"基数排序的 LSD 实现与逐位演示",
"「每一轮必须稳定」的原因与 7.2 节的呼应",
"实测对比:计数排序 447 倍(含快排重复元素退化因素)",
"基数排序 1.4 倍与「轮数换常数」的代价",
"选择流程(值域小→计数;整数大值域→基数;其他→比较排序)",
"256 进制 vs 10 进制的优化分析",
"数组下标胜过哈希表的场景(状态码计数)"
],
"unresolved": [
"内省排序与选型决策留到 8.5",
"前缀和已在 1.4 节讲过,此处呼应",
"直接寻址表已在 6.1 讲过"
],
"canonical_terms": {
"计数排序": "统计每个值出现的次数,再按值顺序输出,O(n+k)",
"基数排序": "按位拆分,每一位做一次稳定排序,O(d·n)",
"值域": "数据取值范围的宽度,计数排序的 k"
},
"symbols_units": {
"k": "值域大小(计数排序的计数数组长度)",
"d": "最大数的位数(基数排序的轮数)"
},
"assumptions": [
"读者已掌握 7.4 的下界与 7.2 的稳定性",
"读者理解整数除法与取模运算"
],
"word_count_actual": 3260,
"checks": [
"C# 代码已在 .NET 8 (SDK 10.0.303) Release 下实跑,输出见正文",
"项目文件:99-tools/samples/Ch08/Sec84/",
"计数/基数排序与快排的结果一致性、性能倍率均为实测",
"稳定性验证用 (值, 原始序号) 元组实测通过",
"练习 8.4.1 的计数排序推演已手工验算(8 步)",
"术语写法与 glossary.md 一致"
],
"next": "8.5 工程中如何选排序"
}