第 42 章 不比较的排序——计数排序与基数排序
🏗️ 前情回顾:前几章学的排序——冒泡、选择、插入、归并、快排——都靠"比较"来决定元素的先后。它们的下界是 O(n log n)。但你有没有想过:如果数据有特殊性质,能不能更快?答案是能——计数排序和基数排序在特定条件下可以达到 O(n),突破了比较排序的理论下限。它们不是"比"出来的,而是"算"出来的。
🎯 本章目标
学完这一章,你能:
- 理解计数排序的"桶"思想——用下标直接映射值
- 手写计数排序,知道它适合什么数据
- 理解基数排序——按位排序,从低位到高位
- 分析 O(n) 排序的适用条件和局限性
- 了解桶排序的基本概念
- 学会根据数据特征选择合适的排序算法
📖 故事引入
假设你要给全班 50 个人按"0 到 100 分的考试成绩"排序。你当然可以用 sort(),一瞬间搞定。
但如果你换一种更"物理"的思路:在黑板前摆上 101 个盒子,标着 0 分、1 分、2 分……100 分。你挨个念成绩,念到 85 分就把那个人的名字扔进 85 号盒子。全部念完后,从 100 号盒子开始一个个往外掏——分数从高到低,自然排好了。
你用了多少时间?念 50 个人的成绩:O(n)。从 101 个盒子里掏:O(数据范围)。总时间 O(n + 数据范围)。
这就是计数排序——不比较,不交换,直接"归类"。
🧱 知识讲解
42.1 计数排序(Counting Sort)
核心思想:统计每个值出现了多少次,然后根据计数"还原"出有序序列。
使用条件:数据范围(最大值 - 最小值)不能太大。如果 n = 10⁵ 但数值范围是 1 到 10⁶,计数排序空间和时间都可接受。但如果数值范围是 1 到 10⁹,数组开不出来,就不能用。
步骤:
- 统计:遍历数组,
cnt[a[i]]++ - 前缀和:
cnt[i] += cnt[i-1](确定每个值的最终位置) - 放置:从后往前遍历,放入结果数组
C++ 实现(值域 0 ~ MAXV):
const int MAXV = 100000; // 最大可能值
int cnt[MAXV + 1];
void countingSort(int a[], int n) {
// 1. 清空计数数组
memset(cnt, 0, sizeof(cnt));
// 2. 统计每个值出现次数
for (int i = 0; i < n; i++)
cnt[a[i]]++;
// 3. 根据计数还原
int idx = 0;
for (int v = 0; v <= MAXV; v++) {
while (cnt[v] > 0) {
a[idx++] = v;
cnt[v]--;
}
}
}🎯 复杂度:O(n + MAXV)。如果 MAXV 和 n 同级别,就是 O(n)。空间 O(MAXV)。稳定(如果从后往前放置)。
用前缀和实现稳定版(保证相等元素相对顺序不变):
void countingSortStable(int a[], int n, int output[]) {
memset(cnt, 0, sizeof(cnt));
for (int i = 0; i < n; i++) cnt[a[i]]++;
for (int i = 1; i <= MAXV; i++) cnt[i] += cnt[i-1]; // 前缀和
for (int i = n - 1; i >= 0; i--) { // 从后往前
output[cnt[a[i]] - 1] = a[i];
cnt[a[i]]--;
}
}42.2 计数排序的局限性
计数排序看起来很美——O(n)!但它有苛刻的前置条件:
- 值域必须小:如果有数值是 10⁹,你开不出
cnt[1000000010]。如果值域很大但数据很少,计数排序反而不如sort()。 - 只能排整数(或可映射为整数的东西):无法直接排浮点数、
string。 - 空间和值域绑定:O(MAXV) 的空间在 MAXV 很大时不可接受。
📝 小技巧:如果数据范围在
[L, R]之间且 R-L 不太大,可以把值偏移:cnt[a[i] - L]++,下标从 0 开始,节省空间。
42.3 基数排序(Radix Sort)
计数排序不能处理大范围数据,那有没有办法既利用"计数"的 O(n) 优势,又能排大数?
有——基数排序。思路是把大数"拆成每一位",对每一位分别用计数排序。
核心思想:从最低位开始,逐位排序。每一位的排序是稳定的(这点至关重要!)。
过程(以 [170, 45, 75, 90, 802, 24, 2, 66] 为例):
原始: 170 45 75 90 802 24 2 66
按个位排: 170 90 802 24 45 75 66 2 (只关心个位数)
按十位排: 802 2 24 45 66 170 75 90 (在保持个位有序基础上排十位)
按百位排: 2 24 45 66 75 90 170 802 (在保持低两位有序基础上排百位)注意!第三次排完后,低两位的有序性被保留——因为每次排序都是稳定的。
C++ 实现(十进制,处理非负整数):
// 对 a[] 中 n 个元素,按第 exp 位(1=个位,10=十位,100=百位...)做计数排序
void countSortByDigit(int a[], int n, int exp) {
int output[n];
int cnt[10] = {0}; // 每位只有 0~9
for (int i = 0; i < n; i++)
cnt[(a[i] / exp) % 10]++;
for (int i = 1; i < 10; i++)
cnt[i] += cnt[i - 1]; // 前缀和
for (int i = n - 1; i >= 0; i--) { // 从后往前保证稳定
int digit = (a[i] / exp) % 10;
output[cnt[digit] - 1] = a[i];
cnt[digit]--;
}
for (int i = 0; i < n; i++) // 拷回原数组
a[i] = output[i];
}
void radixSort(int a[], int n) {
int maxVal = a[0];
for (int i = 1; i < n; i++)
if (a[i] > maxVal) maxVal = a[i];
for (int exp = 1; maxVal / exp > 0; exp *= 10)
countSortByDigit(a, n, exp);
}🎯 复杂度:O(d × n),其中 d 是最大数的位数。对于 int(最多 10 位),d ≤ 10,所以几乎是 O(n)。空间 O(n + 10)。
类比:基数排序就像在图书馆整理按"出版年份(4 位数字)"编目的书。你不用比较每本书的年份大小,而是按个位排一轮、按十位排一轮、按百位排一轮、按千位排一轮——四轮下来,所有书就按年份顺序排好了。
42.4 桶排序(Bucket Sort)概念
桶排序是计数排序的泛化版本:
- 把值域分成若干个"桶"(区间)
- 把每个元素放进对应的桶里
- 对每个桶内部单独排序(随便用什么排序,比如
sort()) - 按桶的顺序依次输出
如果桶的个数和规模设计得当,平均也能达到 O(n)。桶排序在数据均匀分布时效果特别好。
📊 竞赛中,桶排序用得不多(因为
sort()已经很好了),但它的"分桶"思想在别的题里反复出现——尤其是分块算法(M8 会接触)。
✋ 动手试试
试试 1:输入 20 个 0~100 之间的整数,用计数排序排好输出。和 sort() 的结果对比,确认正确。
试试 2:手动模拟基数排序。取 [53, 14, 27, 38, 92, 11],写出每轮(个位、十位)之后的结果。
试试 3:写一个程序,分别用计数排序和 sort() 对 10⁷ 个范围在 [0, 100] 的随机数排序,用 clock() 比速度。你猜谁更快?
⚠️ 容易犯的错
错 1:计数排序数组开小了
❌ 数据范围 0~1000,但只声明 cnt[100]
✅ 声明 cnt[1001]——留出最大值的位置,必要时先扫一遍找最大值
错 2:基数排序的中间排序不稳定
❌ 基数排序里每一位的排序用了不稳定的方法——最后结果可能全乱了
✅ 每一轮必须用稳定的排序(如从后往前放置的计数排序)
错 3:忽略负数
❌ 计数排序的数组下标不能为负
✅ 处理负数的方法:先找最小值 minVal,把每个元素减去 minVal 映射到非负范围。比如 [-5, 10] → 偏移 +5 → [0, 15]
错 4:盲目使用计数排序
❌ "计数排序是 O(n),肯定比 sort 快!"——数据范围 1~10⁹ 时直接爆内存
✅ 先判断数据范围!范围小时用计数,范围大时老老实实用 sort
📝 练习
基础题
1. 选择题
(1)计数排序的时间复杂度是:
A. O(n) B. O(n log n) C. O(n + MAXV) D. O(n²)
(2)基数排序需要多少次稳定排序(对 int 类型)?
A. 1 次 B. 10 次 C. 32 次 D. 取决于最大值
(3)以下哪种数据最适合用计数排序?
A. 10⁵ 个 1~10⁹ 的随机数 B. 10⁶ 个 0~1000 的整数 C. 100 个字符串 D. 10⁴ 个浮点数
2. 填空题
(1)计数排序的核心数据结构是 _____ 数组。
(2)基数排序是从 _____ 位到 _____ 位进行的(填"低/高")。
(3)桶排序中,每个桶内部可以用 _____ 排序。
提高题
3. 编程题 — 成绩统计
输入 n 个学生的成绩(0 ≤ 成绩 ≤ 100,可能有重复)。用计数排序将成绩从小到大输出,同时输出每个分数段的人数:
- 0~59:不及格
- 60~79:良好
- 80~100:优秀
4. 编程题 — 基数排序实现
输入 n 个非负整数(每个 ≤ 10⁹),用基数排序将它们升序排列并输出。要求完整实现 radixSort(),每轮用计数排序处理一位。
挑战题
5. 编程题 — 字符串排序(基数排序思想)
输入 n 个长度相同的字符串(只含小写字母),用基数排序的思想将它们按字典序排列。
提示:每个字符可以看作 26 进制的一位('a'=0, 'b'=1, ..., 'z'=25),从最后一个字符开始向前,逐位做计数排序(每轮 26 个桶)。
🧠 本章小结
计数排序:
统计每个值的出现次数 → 按值从小到大输出
O(n + MAXV),适用于值域较小的整数
不是严格意义上的 O(n):MAXV 大了就不行
基数排序:
按位排序,低位到高位,每一位用稳定的计数排序
O(d × n),d 是位数
解决了计数排序"值域大"的问题——值域 10⁹ 也只需要 10 轮
桶排序:
把值域分成桶 → 元素入桶 → 桶内排序 → 合并
数据均匀分布时平均 O(n)
记住:没有万能排序,每种排序都有它的"舒适区"
竞赛中:一般情况下直接用 sort(),遇到特定条件才考虑计数/基数📝 配套练习
共4题。计数排序条件:值域小→P1271(投票)/P1059(随机数)完美匹配。。★核心(课堂必做) ◆拓展(课后练习)
| 级别 | 题号 | 链接 | 覆盖知识点 |
|---|---|---|---|
| ★ 核心 | luogu-P1271 | https://hydro.ac/p/luogu-P1271 | 计数排序、桶统计 |
| ◆ 拓展 | luogu-P1923 | https://hydro.ac/p/luogu-P1923 | nth_element、第k小 |
| ◆ 拓展 | luogu-P1059 | https://hydro.ac/p/luogu-P1059 | 计数排序版去重(值域小) |
| ◆ 拓展 | luogu-P1090 | https://hydro.ac/p/luogu-P1090 | 桶思想、合并果子 |
💡 练习建议:先完成 1 道★核心题,确保掌握本章基本方法;再完成 3 道◆拓展题,覆盖不同变式和细节。
配套练习
共4题。计数排序条件:值域小→P1271(投票)/P1059(随机数)完美匹配。★核心(课堂必做) · ◆拓展(课后练习)
| 级别 | 题号 | 链接 | 覆盖知识点 |
|---|---|---|---|
| ★ 核心 | luogu-P1271 | https://hydro.ac/p/luogu-P1271 | 计数排序、桶统计 |
| ◆ 拓展 | luogu-P1923 | https://hydro.ac/p/luogu-P1923 | nth_element、第k小 |
| ◆ 拓展 | luogu-P1059 | https://hydro.ac/p/luogu-P1059 | 计数排序版去重(值域小) |
| ◆ 拓展 | luogu-P1090 | https://hydro.ac/p/luogu-P1090 | 桶思想、合并果子 |
练习建议:先在课堂完成 1 道★核心题,掌握本章基本方法;课后完成 3 道◆拓展题,覆盖不同变式和细节。
自查清单:
- [ ] 我能手写计数排序,理解 cnt 数组的作用
- [ ] 我知道计数排序的适用条件(值域小、整数)
- [ ] 我理解基数排序"按位排、低位优先"的原理
- [ ] 我知道为什么基数排序的每一轮必须是稳定的
- [ ] 我了解桶排序的基本概念
- [ ] 我能根据题目数据特征选择计数/基数/sort
🚀 下章预告:M6 的最后一章来了!把前面学的冒泡、选择、插入、归并、快排、计数、基数,再加上 sort,融会贯通。我们做一张"排序全景对比表",然后学一个重要的排序应用——逆序对。第 43 章,给你的排序工具箱做一次彻底的大盘点。