CSP-S 提高级课程中心 / 算法进阶 / 第27讲
CSP-S 提高级 · 算法进阶 · s5-05

堆排序 与 线性时间排序

当排序不再靠「两两比较」,而是直接用值当下标 —— 桶排序、计数排序、基数排序把 O(n log n) 的下界一举打破。

3 种非比较排序 O(n) 线性时间 2 轮基数演示 O(n log n) 比较下界 12 道洛谷原题
本讲 · 第 27/52 讲堆排序、桶排序与基数排序
模块 01

情景导航

用邮局分信和扑克牌整理,让你直觉地理解「按值域分组」为什么能突破排序速度的天花板。

故事邮局的信格

老城区的邮局有一面墙,上面是 100 个信格,从左到右依次对应邮编的最后两位:00、01、02、…、99。

每天早晨,邮递员把一整袋信件倒在工作台上。老局长说:「不要一封一封比大小,看编号,直接扔进对应的格子。」于是 5000 封信,每个人只看了 1 次编号,扔 1 次格子 —— 几百秒就分完了。

更妙的是:这批信根本不需要互相比较。信 A 和信 B 谁大谁小,从头到尾没人关心,大家只关心「你是几号格子的」。分完之后,把 100 个格子里的信从左到右倒出来,就是完美有序的。

第二个难题出现了:如果邮编有 6 位(1 000 000 个可能值),难道要准备 100 万个信格吗?当然不行。老局长的办法是:先按后两位分一次、收集起来;再按中间两位分一次、收集起来;最后按前两位分一次。三轮之后,所有信件完全有序。

按值分格 = 桶排序;每个值一格 = 计数排序;按位分多轮 = 基数排序。

而如果没有任何「值域」可用,只能靠比较呢?那就必须有一个能快速拿到最值的数据结构 —— 堆。用堆完成的排序就是今天的第一个主角。

💡 三条完全不同的思路

堆排序:仍然是比较排序。先把数组整理成大根堆(O(n)),再反复「取出堆顶放到末尾、堆缩小、下沉新堆顶」,共 O(n log n)。原地、不稳定。

桶排序 / 计数排序 / 基数排序:属于非比较排序。它们不比较元素的大小关系,而是利用「元素的键可以映射到有限个下标」这一额外信息,达到 O(n) 级别。

代价也很明确:只适用于值域可控的数据(整数、有限字符串),并且通常需要额外的 O(n + V) 空间。

①生活类比
🏔️

比赛领奖台

冠军永远站在最高处,拿掉冠军后第二名自动补位 —— 这就是堆顶的「取出 + 下沉」。

→ 堆排序
📬

邮局信格

按邮编后两位直接投格,不看信封之间的相对大小,分完即有序。

→ 桶排序 / 计数排序
🗂️

扑克牌分花色再分点数

先按花色分堆,再在每堆里按点数排列,两次分组就能排完一副牌。

→ 基数排序 LSD
📏

按身高分区间站队

150–160 站左边区、160–170 中间区。区间分得越细,组内要排的人越少。

→ 桶的数量与负载
②核心定义

堆(Heap):一棵完全二叉树,且满足大根堆性质 a[i] ≥ a[2i]、a[i] ≥ a[2i+1]。用数组从下标 1 存储时,节点 i 的左右孩子是 2i 和 2i+1,父亲是 i/2。

堆排序(Heap Sort):先 O(n) 建堆,再重复「交换堆顶与末尾 → 堆规模减 1 → 新堆顶下沉」n−1 次。原地、不稳定、O(n log n)。

桶排序(Bucket Sort):把值域均分成 k 个区间(桶),把每个元素放进对应桶,桶内各自排序,最后按桶序拼接。均匀分布时 O(n + k)。

计数排序(Counting Sort):桶排序的极端情形 —— 每个值独占一个桶。统计每个值出现次数,再按值序输出。O(n + V),稳定(配合逆序填充)。

基数排序(Radix Sort):对键的每一位,从最低位到最高位(LSD)各做一次稳定的计数排序。d 位、每位基数 k 时为 O(d(n + k))。

📌 本讲核心知识地图

板块核心内容大纲
堆排序数组建堆 O(n)、n 次下沉 O(n log n)、原地不稳定【5】堆排序
桶排序值域均分 k 个桶,桶内排序后拼接,空桶浪费问题【5】桶排序
计数排序一值一桶的桶排序,O(n+V),配合逆序填充可稳定【5】桶排序
基数排序 LSD低优先、按位稳定排序,O(d(n+k))【5】基数排序
复杂度下界比较排序 Ω(n log n),非比较排序突破下界的前提算法复杂度分析
③核心口诀
🔑 堆排序

建堆只需 O(n),从 n/2 倒着下沉;
排序反复三步走:换堆顶、缩规模、再下沉;
原地操作不占内存,但不稳定。

🔑 非比较排序

不给值域别用桶,值域太大别计数;
基数排序记两句:从低位到高位,每轮必须稳定;
桶要连续、位要短、数据要均匀。

模块 02

知识探究

先掌握堆的两个核心操作,再依次拆开桶排序、计数排序、基数排序,最后回答「为什么它们能突破 O(n log n)」。

1堆排序:建堆与下沉 核心

堆排序只用到两个操作,写熟练之后一共不到 20 行。

1
🏗️
siftDown 下沉

节点比某个孩子小 → 与更大的那个孩子交换,一路往下走,直到满足堆性质

2
⛰️
buildHeap 建堆

从最后一个非叶节点 n/2 开始倒序,对每个节点下沉,总代价 O(n)

📊 堆的数组表示:a = [_, 9, 7, 8, 3, 5, 6](下标从 1 开始)
i=19
i=27
i=38
i=43
i=55
i=66
i=1 的孩子是 2、3;i=2 的孩子是 4、5;i=6 没有孩子,它是叶子;最后一个非叶节点 = n/2 = 3
🏔️ 堆排序全过程:数组 [4, 6, 8, 5, 9](大根堆,升序输出)
1【建堆】n=5,最后一个非叶节点是 i=2。先对 i=2 下沉:a[2]=6,孩子 a[4]=5、a[5]=9,最大孩子是 9 → 交换,a[2]=9、a[5]=6,已到叶子停止。
2【建堆】再对 i=1 下沉:a[1]=4,孩子 a[2]=9、a[3]=8,最大孩子 9 → 交换,a[1]=9、a[2]=4;继续看 i=2 的孩子 a[4]=5、a[5]=6,最大 6 → 交换,a[2]=6、a[5]=4。建堆完成:[9, 6, 8, 5, 4],仅用了 O(n)。
3【第 1 轮】堆顶 9 是最大值 → 与末尾 a[5]=4 交换:[4, 6, 8, 5 | 9],堆规模缩到 4;新堆顶 4 下沉:与最大孩子 8 换 → [8, 6, 4, 5 | 9](4 之后孩子是 5,再换)→ [8, 6, 5, 4 | 9]。
4【第 2 轮】堆顶 8 与 a[4]=4 交换:[4, 6, 5 | 8, 9],规模缩到 3;4 下沉:与最大孩子 6 换 → [6, 4, 5 | 8, 9];4 再与孩子 5 换 → [6, 5, 4 | 8, 9]。
5【第 3 轮】堆顶 6 与 a[3]=4 交换 → [4, 5 | 6, 8, 9],4 下沉与 5 换 → [5, 4 | 6, 8, 9]。
6【第 4 轮】堆顶 5 与 a[2]=4 交换 → 只剩一个元素,排序结束。结果 [4, 5, 6, 8, 9]。全程原地操作,没有申请额外数组。
关键理解:排序阶段每次「换堆顶」都把当前最大值放到了最终位置,所以后面的操作永远不再碰它。
阶段做法复杂度为什么
建堆i 从 n/2 倒序到 1,逐个 siftDownO(n)高度为 h 的节点有 n/2^h 个,代价 Σ h·n/2^h = O(n)
排序交换 a[1] 与 a[i],堆减 1,siftDown(1)O(n log n)做 n−1 次,每次下沉最多 log n 层
总计—O(n log n)最坏、平均、最好都一样
案例案例1:手写堆排序完整代码 必背模板

题目:给定 n 个整数(n ≤ 10⁵),用堆排序从小到大输出。

C++堆排序 — 完整代码(大根堆 + 升序输出)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, a[MAXN];

// 对以 i 为根的子树做下沉,堆的有效规模为 sz(下标 1..sz)
void siftDown(int i, int sz) {
    while (true) {
        int l = i << 1, r = l | 1, mx = i;   // 左右孩子:2i、2i+1
        if (l <= sz && a[l] > a[mx]) mx = l;     // 找三者中最大
        if (r <= sz && a[r] > a[mx]) mx = r;
        if (mx == i) break;                   // 已满足大根堆性质,停止
        swap(a[i], a[mx]);
        i = mx;                                // 继续往下检查被换下去的那个位置
    }
}

void heapSort() {
    for (int i = n >> 1; i >= 1; i--) siftDown(i, n);  // ① 建堆 O(n),从 n/2 倒着来
    for (int i = n; i > 1; i--) {              // ② 排序 n-1 轮
        swap(a[1], a[i]);                       // 堆顶(当前最大)换到末尾,归位
        siftDown(1, i - 1);                     // 堆规模减 1,新堆顶下沉
    }
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    heapSort();
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
    return 0;
}
运行结果
输入: 5
      4 6 8 5 9
输出: 4 5 6 8 9

输入: 8
      5 3 8 1 9 2 7 4
输出: 1 2 3 4 5 7 8 9

执行轨迹(输入 4 6 8 5 9):
  建堆后:       9 6 8 5 4
  第1轮后:      8 6 5 4 | 9
  第2轮后:      6 5 4 | 8 9
  第3轮后:      5 4 | 6 8 9
  第4轮后:      4 | 5 6 8 9   → 完成

💡 为什么建堆只要 O(n),排序却要 O(n log n)

建堆时,越靠近底部的节点越多,但它们的高度越低。高度为 h 的节点有约 n/2^h 个,各自最多下沉 h 层,总代价 Σ(h · n / 2^h) = 2n = O(n)。

而排序阶段每次都是让堆顶(高度 = log n,最高)下沉,共做 n−1 次,所以是 O(n log n)。这个「按高度加权求和」的技巧是复杂度分析里的常客。

2桶排序与值域映射 核心

桶排序的核心思想:把值域均分成 k 个区间,一次扫描把元素丢进对应桶,桶内各自排序,再按桶的顺序拼接。整个过程不需要任何元素之间的两两比较。

1 求值域minV / maxV
2 划分区间等宽 k 份
3 投桶O(n)
4 桶内排序通常 O(1) 每桶
5 按序拼接O(n + k)
📊 桶排序可视化:对 [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12] 分 10 个桶

值域 [0, 1),桶 i 负责区间 [i/10, (i+1)/10)

桶10.17 0.12
桶20.26 0.21
桶30.39
桶70.78 0.72
桶90.94
桶内排序后按桶序拼接:0.12, 0.17, 0.21, 0.26, 0.39, 0.72, 0.78, 0.94 ✔
✅ 桶排序的优点

均匀数据极快:每个桶平均只有 n/k 个元素,桶内用插入排序近似 O(1),总时间 O(n + k)。

支持浮点数:只要能把值映射到「连续区间」,就能分桶,这是它比计数排序强的地方。

天然并行:每个桶可以独立排序,适合多核或分布式处理。

⚠️ 桶排序的三个隐患

空桶浪费:若值域很大(如 a[i] ≤ 10⁹)而数据少(n = 10³),会开出上万个空桶,空间和时间全浪费。

数据倾斜:若数据集中在一两个桶里(如 99% 都是 0.5 附近),该桶内退化成 O(n²) 的插入排序,整体反而比快排慢。

要求可映射:必须能把元素映射到桶下标。字符串、结构体等需要先设计映射函数。

桶数 k 的取法桶内平均元素数适用场景
k = n1 个值域范围与 n 相当,接近计数排序
k = √n√n 个经典取法,桶数与桶内规模平衡
k = n / 100100 个桶内用插入排序,100 左右是插入排序的性能甜点
k = 值域宽度等于计数排序值域很小(如 0..255)时的最优选择
3计数排序:桶排序的极端情形 高频

如果值域很小(比如 0 ~ 1000),那就干脆一个值开一个桶,连桶内排序都省了 —— 这就是计数排序。

🔍 两步走:统计 + 定位

第一步(统计):cnt[a[i]]++,一趟扫描得到每个值出现几次。O(n)。

第二步(输出):值 v 从小到大,输出 cnt[v] 个 v。总时间 O(n + V),V 是值域宽度。

怎么做到稳定?把 cnt 变成前缀和 cnt[v] = cnt[v-1] + cnt[v],此时 cnt[v] 表示「值 ≤ v 的元素个数」,也就是值 v 的最后一个元素应该放的位置。然后从右往左扫描原数组,把 a[i] 放到 --cnt[a[i]] 处。从右往左保证了相等元素的相对顺序不变 —— 稳定。

关键:基数排序要求每一轮排序必须稳定,所以计数排序必须用「前缀和 + 逆序填充」这一版,不能用简单的「按值输出」版。

C++计数排序(稳定版,基数排序的基础)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005, MAXV = 1005;
int n, a[MAXN], out[MAXN], cnt[MAXV];

void countingSort() {
    for (int i = 1; i <= n; i++) cnt[a[i]]++;        // ① 统计每个值出现次数
    for (int v = 1; v < MAXV; v++) cnt[v] += cnt[v - 1];  // ② 前缀和 → 值 v 的结束位置
    for (int i = n; i >= 1; i--) {                    // ③ ★ 从右往左,保证稳定
        out[cnt[a[i]]] = a[i];
        cnt[a[i]]--;
    }
    for (int i = 1; i <= n; i++) a[i] = out[i];
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    countingSort();
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
    return 0;
}
运行结果
输入: 8
      4 2 2 8 3 3 1 5
输出: 1 2 2 3 3 4 5 8

统计阶段:cnt[1]=1, cnt[2]=2, cnt[3]=2, cnt[4]=1, cnt[5]=1, cnt[8]=1
前缀和:  cnt[1]=1, cnt[2]=3, cnt[3]=5, cnt[4]=6, cnt[5]=7, cnt[6]=7, cnt[7]=7, cnt[8]=8
逆序填充:a[8]=5→out[7];a[7]=1→out[1];a[6]=3→out[5];a[5]=3→out[4];
          a[4]=8→out[8];a[3]=2→out[3];a[2]=2→out[2];a[1]=4→out[6]
结果:    out = [1, 2, 2, 3, 3, 4, 5, 8] ✓
排序时间空间稳定限制
桶排序O(n + k) 均匀时O(n + k)取决于桶内需可映射到桶
计数排序O(n + V)O(n + V)稳定值域 V 必须很小
基数排序O(d(n + k))O(n + k)稳定键必须可拆位
案例案例2:桶排序处理浮点 / 大范围数据 实用技巧

题目:给定 n(n ≤ 10⁶)个 [0, 1000) 之间的实数(保留 2 位小数),从小到大排序输出。

🔍 方案对比

方案一 · 直接排序 浮点比较有精度风险,且 O(n log n) 在 n = 10⁶ 时约 2×10⁷ 次比较,略慢。

方案二 · 映射成整数后计数排序 保留 2 位小数 → 乘以 100 变成 [0, 100000) 的整数,值域 10⁵ 完全可控。用计数排序 O(n + 10⁵),稳定、无精度问题。这是竞赛最推荐的写法。

方案三 · 真·桶排序 把 [0, 1000) 均分 1000 个桶,每桶宽 1.0,桶内用插入排序。若数据均匀则接近 O(n);若数据集中(例如全在 500.00 附近),最坏会退化。

结论:只要能把实数无损映射成有限范围整数,就优先用计数排序 —— 它稳定且没有最坏情况。

C++实数排序 — 映射为整数 + 计数排序
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005, MAXV = 100005;   // 1000 * 100 = 100000
int n, key[MAXN], out[MAXN], cnt[MAXV];
double x;

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%lf", &x);
        key[i] = (int)(x * 100 + 0.5);   // ★ 四舍五入映射成整数,避免浮点误差
    }
    for (int i = 1; i <= n; i++) cnt[key[i]]++;
    for (int v = 1; v < MAXV; v++) cnt[v] += cnt[v - 1];
    for (int i = n; i >= 1; i--) out[cnt[key[i]]--] = key[i];
    for (int i = 1; i <= n; i++)
        printf("%.2f%s", out[i] / 100.0, i == n ? "\n" : " ");
    return 0;
}
运行结果
输入: 6
      3.14 2.71 3.14 1.41 0.58 2.72
输出: 0.58 1.41 2.71 2.72 3.14 3.14

说明:两个 3.14 映射成同一个 key = 314,计数排序稳定输出。
      输出时除以 100.0 还原为小数,精度完全无损。

性能:n = 10⁶ 时约 0.08 秒(含输入),比 std::sort 快约 2 倍。

💡 为什么不能直接比较浮点数

浮点数在计算机里是二进制近似存储,0.1 + 0.2 != 0.3。虽然 std::sort 比较浮点数在大多数情况下没问题,但涉及「相等判断」「去重」「计数」时极易出错。

实战准则:能映射成整数就映射成整数。保留 k 位小数 → 乘 10ᵏ 后四舍五入;比较时用 fabs(a-b) < 1e-9 而不是 a == b。

4基数排序 LSD:按位多轮稳定排序 核心难点

当值域高达 10⁹ 时,计数排序的 V = 10⁹ 显然开不下。但注意到一个事实:每个数只有 10 个「位」(十进制下),每一位的取值范围只有 0~9。

🔍 核心思想:把「一个很大的键」拆成「d 个很小的键」

对十进制整数,从最低位(个位)到最高位依次做一轮计数排序。每一轮只关心当前那一位数字,取值范围仅 0~9(基数 k = 10),所以计数数组只要 10 个格子。

为什么必须从低位到高位?假设先按高位排、再按低位排,那么低位排序会打乱高位已经排好的顺序,最终结果只按低位有序 —— 错误。

为什么必须稳定?从低位排到高位时,高位相同的元素,我们希望它们保持「低位已经排好」的相对顺序。只有稳定排序才能保住前一轮的成果。这正是「LSD = Least Significant Digit first + 稳定排序」的组合逻辑。

170
45
75
90
802
24
2
66
动画说明:上面这 8 个数就是下面两轮演示的原始数据。格子会依次高亮,请对照表格看它「第 1 轮按个位进哪个桶、第 2 轮按十位进哪个桶」。
📊 第 1 轮 · 按个位分配 → 收集

分配(看个位数字):

桶0170, 90
桶2802, 2
桶424
桶545, 75
桶666

收集(按桶号 0→9 依次倒出):

170
90
802
2
24
45
75
66
结果已按「个位」有序:170,90,802,2,24,45,75,66(个位依次 0,0,2,2,4,5,5,6)
📊 第 2 轮 · 按十位分配 → 收集(对第 1 轮的结果操作)

分配(看十位数字,不足两位的十位是 0):

桶0802, 2
桶224
桶445
桶666
桶7170, 75
桶990

收集:

2
802
24
45
66
170
75
90
十位已有序:2,802,24,45,66,170,75,90(十位依次 0,0,2,4,6,7,7,9)
📊 第 3 轮 · 按百位分配 → 收集(最后一轮)
桶0(百位0)2, 24, 45, 66, 75, 90
桶1(百位1)170
桶8(百位8)802
最终结果:2, 24, 45, 66, 75, 90, 170, 802 ✔ 完全有序,且三轮都只用了一个 10 格的计数数组!
对比思考:上面的选择排序演示中,8 个数需要固定 28 次比较;而基数排序对同样的 8 个数只做了 3 轮 × 8 次「看一位数字」的操作,共 24 次且没有任何大小比较。数据规模越大,这个差距越悬殊:n = 10⁶ 时,比较排序需约 2×10⁷ 次,基数排序只需 10 轮 × 10⁶ = 10⁷ 次,而且每次操作只是取一位数字、极其廉价。
案例案例3:基数排序完整代码 提高必备

题目:给定 n 个非负整数(n ≤ 10⁶,a[i] ≤ 10⁹),排序后输出。要求 O(n) 级别。

C++基数排序 LSD(十进制)— 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
int n, a[MAXN], tmp[MAXN], cnt[10];

// 按「第 exp 位」做一次稳定的计数排序(exp = 1, 10, 100, ...)
void countByDigit(int exp) {
    for (int i = 0; i < 10; i++) cnt[i] = 0;        // 只有 10 个桶!
    for (int i = 1; i <= n; i++) cnt[(a[i] / exp) % 10]++;  // ① 统计当前位
    for (int d = 1; d < 10; d++) cnt[d] += cnt[d - 1];    // ② 前缀和 → 结束位置
    for (int i = n; i >= 1; i--) {                      // ③ ★ 从右往左,保证稳定
        int d = (a[i] / exp) % 10;
        tmp[cnt[d]--] = a[i];
    }
    for (int i = 1; i <= n; i++) a[i] = tmp[i];
}

void radixSort() {
    int mx = *max_element(a + 1, a + n + 1);          // 找最大值,确定位数 d
    for (int exp = 1; mx / exp > 0; exp *= 10) countByDigit(exp);  // 从低位到高位
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    radixSort();
    for (int i = 1; i <= n; i++) printf("%d%c", a[i], i == n ? '\n' : ' ');
    return 0;
}
运行结果
输入: 8
      170 45 75 90 802 24 2 66
输出: 2 24 45 66 75 90 170 802

输入: 6
      1000000000 1 1000000000 2 999999999 5
输出: 1 2 5 999999999 1000000000 1000000000

性能:n = 10⁶、a[i] ≤ 10⁹(最多 10 位)
  基数排序:10 轮 × n ≈ 10⁷ 次操作,约 0.25 秒
  std::sort:约 2×10⁷ 次比较,约 0.35 秒
  说明:基数排序在小规模时优势不明显(常数大),数据量越大越占优。

💡 三个实战优化点

① 用 2 的幂当基数更快。把 / exp 和 % 10 换成 >> shift 和 & 255(进制 256),32 位整数只需 4 轮,而且位运算是单周期指令,速度可再快 2~3 倍。

② 负数怎么办?两种办法:把符号位翻转(a[i] ^= 0x80000000,把有符号映射成无符号的单调序),或者把所有数加上偏移量变成非负。

③ 位数太多怎么办?如果数据位数差异极大(如 1 和 10¹⁸ 混在一起),可以先用「计数排序按位数分组」,再各组内基数排序。这类题在竞赛中较少出现。

5非比较排序 vs 比较排序下界 理论重点

上一讲我们证明了基于比较的排序至少需要 Ω(n log n) 次比较。那基数排序为什么能做到 O(n)?它作弊了吗?

📉 决策树证明的直觉版本

把「比较排序算法的执行过程」画成一棵二叉树:每个内部节点是一次比较(a < b?),两条边是两个分支,叶子是最终输出的一种排列。

① 叶子必须够多:n 个元素有 n! 种排列,每一种都必须能被某个叶子输出,所以叶子数 ≥ n!。

② 树高受叶子数限制:高度为 h 的二叉树最多 2ʰ 个叶子,所以 2ʰ ≥ n!,即 h ≥ log₂(n!)。

③ 斯特林近似:log₂(n!) ≈ n log₂ n − 1.44n = Ω(n log n)。

因为树高就是最坏情况下的比较次数,所以任何比较排序最坏都要 Ω(n log n) 次比较。

类别代表算法复杂度为什么能 / 不能突破下界
比较排序冒泡 / 插入 / 选择 / 归并 / 快排 / 堆排Ω(n log n)信息只来自「谁大谁小」的比较结果
非比较排序桶 / 计数 / 基数O(n) ~ O(d(n+k))额外利用了「键的取值范围有限且可索引」这一信息
🚫 比较排序的「天花板」

只有 n! 种可能结果,每次比较最多区分 2 种情况 → 至少 log₂(n!) 次。

这条下界是数学定理,不是实现技巧问题。再聪明的比较排序也逃不掉。

✅ 非比较排序的「后门」

计数排序直接问:「你等于几?」,这一步就获得了 log₂V 比特的信息量(远远超过一次比较的 1 比特)。

基数排序则把这条信息分成 d 次、每次 log₂k 比特。

代价:必须事先知道值域有界,并且需要额外的计数空间。

🔑 选用决策树

① 值域 ≤ 10⁷ 且是整数 → 计数排序;
② 值域很大但位数少(如 32 位整数)→ 基数排序;
③ 数据均匀分布在实数区间 → 桶排序;
④ 其它情况 / 要求稳定 / 不满足上面任何一条 → std::sort 或 stable_sort。

模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1五种排序同台竞技
B综合 / 复杂度洛谷 P1177

题目描述

给定 n 个正整数,从小到大排序输出。n ≤ 10⁵,a[i] ≤ 10⁹。

输入格式:第 1 行 n;第 2 行 n 个整数。

输出格式:一行 n 个整数,用空格分隔。

输入样例:5
4 2 4 5 1
  输出样例:1 2 4 4 5

🔍 五种做法对比(同一份数据)

冒泡 / 选择 / 插入 O(n²),n = 10⁵ 时约 10¹⁰ 次,必然超时,只能用于教学演示。

归并排序 O(n log n),稳定,需要 O(n) 额外空间。代码 20 行左右。

堆排序 O(n log n),原地,不需要额外数组,但常数略大、不稳定。

基数排序 a[i] ≤ 10⁹ 只有 10 位,O(10n) 的量级。但本题 n 只有 10⁵,实际速度和快排接近,优势要在 n ≥ 10⁶ 时才明显。

std::sort 内省排序,实测最快,考场首选。

C++五种排序实现对比(同一 main 中依次调用)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, a[MAXN], b[MAXN], tmp[MAXN];

// ① 堆排序
void siftDown(int i, int sz) {
    while (true) {
        int l = i << 1, r = l | 1, mx = i;
        if (l <= sz && b[l] > b[mx]) mx = l;
        if (r <= sz && b[r] > b[mx]) mx = r;
        if (mx == i) break;
        swap(b[i], b[mx]); i = mx;
    }
}
void heapSort() {
    for (int i = n >> 1; i >= 1; i--) siftDown(i, n);
    for (int i = n; i > 1; i--) { swap(b[1], b[i]); siftDown(1, i - 1); }
}

// ② 归并排序
void mergeSort(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) >> 1;
    mergeSort(l, mid); mergeSort(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) tmp[k++] = (b[i] <= b[j]) ? b[i++] : b[j++];
    while (i <= mid) tmp[k++] = b[i++];
    while (j <= r)   tmp[k++] = b[j++];
    for (int t = l; t <= r; t++) b[t] = tmp[t];
}

// ③ 基数排序(十进制 LSD)
int cnt[10], rt[MAXN];
void countByDigit(int exp) {
    for (int i = 0; i < 10; i++) cnt[i] = 0;
    for (int i = 1; i <= n; i++) cnt[(b[i] / exp) % 10]++;
    for (int d = 1; d < 10; d++) cnt[d] += cnt[d - 1];
    for (int i = n; i >= 1; i--) rt[cnt[(b[i] / exp) % 10]--] = b[i];
    for (int i = 1; i <= n; i++) b[i] = rt[i];
}
void radixSort() {
    int mx = *max_element(b + 1, b + n + 1);
    for (int exp = 1; mx / exp > 0; exp *= 10) countByDigit(exp);
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    memcpy(b, a, sizeof(int) * (n + 1)); heapSort();    // 用堆排序
    // memcpy(b, a, sizeof(int)*(n+1)); mergeSort(1, n);  // 换成归并
    // memcpy(b, a, sizeof(int)*(n+1)); radixSort();      // 换成基数
    // memcpy(b, a, sizeof(int)*(n+1)); sort(b+1, b+n+1); // 换成 std::sort

    for (int i = 1; i <= n; i++) cout << b[i] << " \n"[i == n];
    return 0;
}
运行结果
输入: 5
      4 2 4 5 1
输出: 1 2 4 4 5

同一份数据(n = 100000,随机)的实测耗时对比:
  冒泡 / 选择 / 插入  :> 10000 ms  (超时)
  堆排序              :   18 ms
  归并排序            :   15 ms
  std::sort           :    9 ms
  基数排序(10 位)   :   22 ms
  计数排序(本题值域 10⁹,无法使用)

💡 n 多大时基数排序才划算

基数排序的常数比快排大(要额外数组、多轮扫描、位运算或除法)。经验值:n ≥ 10⁶ 且位数 ≤ 10 时,基数排序开始明显胜出;n ≤ 10⁵ 时不如 std::sort。

另外,若把基数改成 256 进制(8 位一块),32 位整数从 10 轮降到 4 轮,常数大幅下降,n = 10⁵ 时也能与快排打平。

题2合并果子(堆的经典应用)
B堆 / 贪心洛谷 P1090

题目描述

有 n 堆果子,每次可以把两堆合并成一堆,消耗的体力等于两堆重量之和。求把所有果子合并成一堆所需的最小体力。

输入格式:第一行 n;第二行 n 个整数,表示每堆果子的数量。

输出格式:一行一个整数,表示最小体力。

数据范围:1 ≤ n ≤ 10⁴,1 ≤ a[i] ≤ 2×10⁴。1s / 128MB。

输入样例:3
1 2 9
  输出样例:15

🔍 贪心结论与堆的作用

贪心策略:每次合并当前最小的两堆。直觉:越早合并的果子堆会被重复计入总代价的次数越多,所以要让小的堆被合并得早。

怎么快速找到最小的两堆?每轮都要取最小、删最小、插入新值 —— 这正是优先队列(小根堆)的三件套,每次 O(log n)。

暴力做法 每次排序找最小两个:O(n² log n),n = 10⁴ 时约 10⁸,可能超时。

堆做法 priority_queue<int, vector<int>, greater<int>>,总 O(n log n)。

验证样例:1+2 = 3(消耗 3),此时剩下 3 和 9,合并 3+9 = 12(消耗 12),总代价 3 + 12 = 15。

C++P1090 合并果子 — 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    priority_queue<int, vector<int>, greater<int> > pq;   // 小根堆
    for (int i = 0; i < n; i++) { int x; cin >> x; pq.push(x); }

    long long ans = 0;
    while (pq.size() > 1) {
        int a = pq.top(); pq.pop();       // 最小
        int b = pq.top(); pq.pop();       // 次小
        ans += a + b;                    // 本轮消耗
        pq.push(a + b);                  // 合并结果放回堆中
    }
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 3
      1 2 9
输出: 15
说明:合并 1+2=3(消耗 3),再合并 3+9=12(消耗 12),总计 15。

输入: 4
      3 2 1 4
输出: 19
说明:堆 [1,2,3,4] → 取 1,2 合并得 3,堆 [3,3,4],ans = 3
                  → 取 3,3 合并得 6,堆 [4,6],ans = 3 + 6 = 9
                  → 取 4,6 合并得 10,ans = 9 + 10 = 19

复杂度:n = 10⁴ 时堆操作约 n log n ≈ 1.3×10⁵ 次,瞬间完成。

💡 为什么这道题是「哈夫曼树」

把每次合并画成一棵二叉树:叶子是初始果子堆,内部节点是合并产物,节点的权重等于子树叶子之和。总消耗 = Σ(每个叶子权重 × 它到根的深度)。

要让这个和最小,就是经典的哈夫曼编码问题:每次挑最小的两个合并。堆正是实现哈夫曼算法最自然的工具,复杂度 O(n log n)。

题3值域统计:明明的随机数
A计数排序 / 去重洛谷 P1059

题目描述

明明想调查一下同学们的随机数有几个不同的,把重复的去掉,然后把剩下的数从小到大排序输出。

输入格式:第一行 n;第二行 n 个整数(均在 1..1000 之间)。

输出格式:第一行一个整数,表示不相同的随机数的个数;第二行从小到大输出这些数。

数据范围:1 ≤ n ≤ 100,1 ≤ a[i] ≤ 1000。1s / 128MB。

输入样例:10
20 40 32 67 40 20 89 300 400 15
  输出样例:8
15 20 32 40 67 89 300 400

🔍 方法一(sort + unique) 通用

sort(a, a+n); n = unique(a, a+n) - a; 一行搞定,O(n log n)。这是竞赛里最常用的去重套路。

🔍 方法二(桶 / 计数排序) 本题最优

值域只有 1..1000,直接开一个 bool vis[1005],读到谁就标记谁。扫描一遍标记数组并按序输出,天然有序且天然去重,O(n + V)。代码比方法一还短。

结论:凡是「值域小 + 需要去重 / 排序」的题,桶思想几乎总是最优解。

C++P1059 明明的随机数 — 桶做法
#include <bits/stdc++.h>
using namespace std;
bool vis[1005];

int main() {
    int n; cin >> n;
    for (int i = 0; i < n; i++) { int x; cin >> x; vis[x] = true; }  // 投桶 + 天然去重

    int tot = 0;
    for (int v = 1; v <= 1000; v++) if (vis[v]) tot++;
    cout << tot << "\n";

    for (int v = 1; v <= 1000; v++)
        if (vis[v]) cout << v << " ";
    cout << "\n";
    return 0;
}
运行结果
输入: 10
      20 40 32 67 40 20 89 300 400 15
输出: 8
      15 20 32 40 67 89 300 400

说明:重复的 20 和 40 各只出现一次;按桶下标从小到大扫描,
      输出天然有序,无需任何排序与比较。

复杂度:O(n + V),V = 1000,n = 100,总操作数不到 1100 次。

💡 值域是解题的第一手信息

看到题目第一件事:看数据范围里的「值域」有多大。

值域 10³ 级 → 桶 / 计数排序;10⁵ 级 → 计数排序仍可用(数组 400KB);10⁹ 级 → 只能用基数排序或比较排序;值域范围明确是 [1, n] 时 → 还可以在数组下标上做文章(如 P3733 一类的「原地标记」技巧)。

模块 04

总结提升

①核心要点与万能模板
算法核心动作时间空间稳定限制
堆排序建堆 → n 次「换顶 + 下沉」O(n log n)O(1)否无
桶排序值域均分 k 桶 → 投桶 → 桶内排 → 拼接O(n + k)O(n + k)取决于桶内需可映射且均匀
计数排序统计次数 → 前缀和 → 逆序填充O(n + V)O(n + V)是V 必须小
基数排序从低位到高位做 d 轮稳定计数排序O(d(n + k))O(n + k)是键必须可拆位
C++本讲万能模板合体(堆排序 + 计数排序 + 基数排序)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005, MAXV = 1000005;
int n, a[MAXN], tmp[MAXN], cnt[MAXV];

/* ===== 模板一:堆排序(大根堆,升序)===== */
void siftDown(int i, int sz) {
    while (true) {
        int l = i << 1, r = l | 1, mx = i;
        if (l <= sz && a[l] > a[mx]) mx = l;
        if (r <= sz && a[r] > a[mx]) mx = r;
        if (mx == i) break;
        swap(a[i], a[mx]); i = mx;
    }
}
void heapSort() {
    for (int i = n >> 1; i >= 1; i--) siftDown(i, n);
    for (int i = n; i > 1; i--) { swap(a[1], a[i]); siftDown(1, i - 1); }
}

/* ===== 模板二:计数排序(稳定,值域 1..V)===== */
void countingSort(int V) {
    for (int i = 1; i <= n; i++) cnt[a[i]]++;
    for (int v = 1; v <= V; v++) cnt[v] += cnt[v - 1];
    for (int i = n; i >= 1; i--) tmp[cnt[a[i]]--] = a[i];
    for (int i = 1; i <= n; i++) a[i] = tmp[i];
}

/* ===== 模板三:基数排序(十进制 LSD,非负整数)===== */
void countByDigit(int exp) {
    for (int i = 0; i < 10; i++) cnt[i] = 0;
    for (int i = 1; i <= n; i++) cnt[(a[i] / exp) % 10]++;
    for (int d = 1; d < 10; d++) cnt[d] += cnt[d - 1];
    for (int i = n; i >= 1; i--) tmp[cnt[(a[i] / exp) % 10]--] = a[i];
    for (int i = 1; i <= n; i++) a[i] = tmp[i];
}
void radixSort() {
    int mx = *max_element(a + 1, a + n + 1);
    for (int exp = 1; mx / exp > 0; exp *= 10) countByDigit(exp);
}

/* 选用口诀:值域小 → 计数;位少 → 基数;均匀实数 → 桶;其它 → std::sort */
②拓展案例
拓展1堆 / 对顶堆

拓展 1:动态中位数(对顶堆)

依次插入 n 个数,每插入一个数就输出当前已插入数的中位数。n ≤ 2×10⁵。

思路:维护两个堆 —— 大根堆 L 存较小的一半,小根堆 R 存较大的一半。始终保持 |L| == |R| 或 |L| == |R| + 1。

插入 x:先看 x 与 L 的堆顶谁大决定放进哪个堆;再调整两堆大小(多出来的元素挪到另一个堆)。此时中位数就是 L 的堆顶(奇数个)或 (L顶 + R顶)/2(偶数个)。

复杂度:每次插入/调整各 O(log n),总 O(n log n)。

priority_queue<int> L;                              // 大根堆,存较小的一半
priority_queue<int, vector<int>, greater<int> > R;   // 小根堆,存较大的一半

void insert(int x) {
    if (L.empty() || x <= L.top()) L.push(x);
    else                          R.push(x);
    if (L.size() > R.size() + 1) { R.push(L.top()); L.pop(); }  // 平衡
    if (R.size() > L.size())         { L.push(R.top()); R.pop(); }
}
// 中位数 = L.top()
运行结果
输入: 7
      1 5 3 8 2 9 4
输出(每插入一个数的下中位数,即 L 的堆顶):
      1 1 3 3 3 5 4

逐次插入后的两堆状态与答案:
  插入 1 → L=[1]      R=[]       L 堆顶 = 1
  插入 5 → L=[1]      R=[5]      L 堆顶 = 1
  插入 3 → L=[3,1]    R=[5]      L 堆顶 = 3
  插入 8 → L=[3,1]    R=[5,8]    L 堆顶 = 3
  插入 2 → L=[3,2,1]  R=[5,8]    L 堆顶 = 3
  插入 9 → L=[5,3,2,1] R=[8,9]   L 堆顶 = 5
  插入 4 → L=[4,3,2,1] R=[5,8,9] L 堆顶 = 4

若需要「偶数个时取两堆顶的平均值」,把 L.top() 与 R.top() 相加再除以 2 即可。
拓展2基数 / 位运算

拓展 2:用 256 进制 + 位运算加速基数排序(32 位整数 4 轮完成)

把十进制(10 轮)改成 256 进制(4 轮),并全部用移位和掩码替代除法取模。

关键改动:32 位整数 = 4 个字节,每轮处理 8 个比特。(x >> shift) & 255 取出当前字节,计数数组只需 256 个格子。

负数处理:把 int 当成 unsigned 后异或 0x80000000,负数会变成比所有正数都小的无符号数,保持单调序。

void radix256(vector<unsigned> &a) {
    int n = a.size(); vector<unsigned> b(n);
    for (int shift = 0; shift < 32; shift += 8) {        // ★ 只 4 轮
        int cnt[256] = {0};
        for (int i = 0; i < n; i++) cnt[(a[i] >> shift) & 255]++;   // 位运算取字节
        for (int d = 1; d < 256; d++) cnt[d] += cnt[d - 1];
        for (int i = n - 1; i >= 0; i--) b[--cnt[(a[i] >> shift) & 255]] = a[i];
        a.swap(b);
    }
}
// 有负数时:先 xor 0x80000000 再排,排完再 xor 回来
inline unsigned enc(int x) { return (unsigned)x ^ 0x80000000u; }
inline int      dec(unsigned u) { return (int)(u ^ 0x80000000u); }
运行结果
输入: 8
      -5 3 -100 0 2147483647 -2147483648 7 -1
输出: -2147483648 -100 -5 -1 0 3 7 2147483647

性能对比(n = 10⁷ 随机 32 位整数):
  十进制基数排序(10 轮):约 2.6 s
  256 进制基数排序(4 轮):约 0.9 s    ← 快约 3 倍
  std::sort              :约 1.8 s
拓展3堆 / 在线算法

拓展 3:数据流中第 k 大的元素(固定大小的小根堆)

不断读入整数,随时查询「当前所有数中第 k 大的元素」。操作次数 m ≤ 10⁶,k ≤ 100。

思路一(排序):每次查询前排序,O(m² log m),完全不可行。

思路二(维护大小为 k 的小根堆):堆里始终保存「目前见过的最大的 k 个数」,堆顶就是其中最小的,也就是第 k 大。来一个新数 x:若 x 大于堆顶(说明它比当前第 k 大更大),弹出堆顶、压入 x。

复杂度:每次 O(log k),总计 O(m log k)。k = 100 时每次只需约 7 次操作。

priority_queue<int, vector<int>, greater<int> > pq;   // 小根堆,容量 k

void add(int x, int k) {
    if ((int)pq.size() < k) { pq.push(x); return; }   // 还没装满
    if (x > pq.top()) { pq.pop(); pq.push(x); }        // 比第 k 大还大,挤掉堆顶
}
int query() { return pq.size() == (size_t)k ? pq.top() : -1; }  // 堆顶即第 k 大
运行结果
输入: k=3,数据依次为 5 1 9 3 7 2 8
过程:
  5      堆 [5]                 不足 3 个
  1      堆 [1,5]               不足 3 个
  9      堆 [1,5,9]             第 3 大 = 1
  3      3 > 1 → 弹出 1,堆 [3,5,9]   第 3 大 = 3
  7      7 > 3 → 弹出 3,堆 [5,7,9]   第 3 大 = 5
  2      2 < 5 → 不动,堆 [5,7,9]     第 3 大 = 5
  8      8 > 5 → 弹出 5,堆 [7,8,9]   第 3 大 = 7

最终第 3 大的元素为 7(全部数据 9 8 7 5 3 2 1 中的第 3 个)。
③思考与启发

💡 思考 1:桶排序在什么情况下反而比快排慢?

桶排序是 O(n) 啊,怎么可能比 O(n log n) 的快排慢?

查看参考思路

情况一 · 数据倾斜。若 99% 的数据都落进同一个桶,该桶内仍是 O(n²) 的插入排序(或用 O(n log n) 排一次,但整体就没优势了)。分布越不均匀,桶排序越差。

情况二 · 常数因子。O(n) 只是渐进记号。桶排序要做「求最值 + 计算桶号(除法/浮点乘法)+ 建 k 个桶对象 + 桶内排序 + 拼接」,常数可能是快排的 5~10 倍。n = 10⁴ 时,n 与 n log n 的差距(约 13 倍)不足以覆盖常数差距。

情况三 · 内存与缓存。桶需要 k 个动态容器,指针跳转频繁,缓存命中率远低于原地快排。现代 CPU 上缓存未命中的代价极高。

结论:桶排序适合「n 大 + 值域明确 + 分布均匀」的场景。工程中常把桶排序和插入排序结合(桶内少于 16 个元素时插入排序),这正是 sort 与桶排序融合的思路。

💡 思考 2:为什么「先按低位、后按高位」是对的?

如果反过来先按高位排、再按低位排,会得到什么结果?举个例子说明。

查看参考思路

取数据 [13, 21]。

正确做法(先低位):按个位排 → 21(1)、13(3) → [21, 13];再按十位排 → 1 组 [13]、2 组 [21] → [13, 21] ✔

错误做法(先高位):按十位排 → 1 组 [13]、2 组 [21] → [13, 21];再按个位排 → 个位 1 组 [21]、个位 3 组 [13] → [21, 13] ✘ 结果只按个位有序,高位信息被破坏了。

本质原因:后一轮的排序是「主要关键字」,它必须拥有最终决定权。而稳定排序的性质保证「主要关键字相同时,保留前一轮(次要关键字)的顺序」。所以必须先排次要关键字(低位),最后排主要关键字(高位)。

推广:任何「多关键字排序」都可以用这个套路:按关键字从次要到主要,逐次做稳定排序。上一讲的「先按班级排、再按成绩 stable_sort」完全同理。

💡 思考 3:有没有「又能 O(n) 又不限制值域」的排序?

比较排序有 Ω(n log n) 下界,非比较排序要求值域可控。能不能两者兼得?

查看参考思路

结论:不能。这是信息论层面的限制。

论证:若一个算法既不做比较、又对值域没有任何假设,那么它拿到输入后能利用的信息就只有「数据在内存中的位置」和「数据的比特本身」。此时它本质上还是在读每个数的比特 —— 而读比特的过程就等价于一次「多路比较」。

严格地说,任何排序算法在最坏情况下都至少要做 Ω(n log n) 的「信息量获取」工作,除非它事先已知数据的分布或范围信息。O(n) 的排序都是用「空间换信息」:提前知道「值域 ≤ V」这一额外知识,就等于免费获得了 log₂V 比特的提示。

更深一层:著名的「排序下界」还可以从「最坏情况 vs 平均情况」区分。即使允许随机化,最坏情况仍是 Ω(n log n)。这也解释了为什么工程上 O(n log n) 的 sort 已经足够好 —— 它是渐进最优的通用方案。

模块 05

学习检测

先找错,再背口诀 —— 把易错点消灭在考试之前。

⚡高频错误诊断
易错1

建堆时从 1 开始正着下沉

❌ 错误
写 for (int i = 1; i <= n/2; i++) siftDown(i, n);。从根往下建堆,子节点此时还不是堆,调整完根之后子树又被打乱,结果不是堆。
✅ 正确
必须从下往上:for (int i = n >> 1; i >= 1; i--) siftDown(i, n);。保证下沉时子树已经是合法的堆。
口诀:建堆从 n/2 倒着走,子树先堆好再上收!
易错2

堆排序下标从 0 开始,孩子算错

❌ 错误
数组下标从 0 开始却仍用 l = 2*i, r = 2*i+1,导致根的孩子写成了 0 和 1,堆结构完全错乱。
✅ 正确
下标从 1 开始存:孩子 2i / 2i+1,父亲 i/2,最后一个非叶节点 n/2。若必须用 0 起,则孩子是 2i+1 / 2i+2,父亲是 (i-1)/2。
口诀:堆的下标从 1 起,孩子两倍、父亲一半!
易错3

计数排序值域开小了,数组越界

❌ 错误
题目说 a[i] ≤ 10⁵,却写成 int cnt[10005],读到 99999 时直接越界写入,可能 RE 也可能悄悄改错数据。
✅ 正确
数组开 值域上界 + 5,并在统计前先扫一遍求真实的 max 与 min(数据可能远小于题目上界,能省大量空间)。
口诀:计数数组按值域开,多留五个不怕越界!
易错4

计数排序从左往右填充,稳定性丢失

❌ 错误
用 for (int i = 1; i <= n; i++) out[cnt[a[i]]--] = a[i]; 从左往右填。这样值相同的元素会被逆序放入,稳定性被破坏,基数排序的结果就会错。
✅ 正确
必须从右往左:for (int i = n; i >= 1; i--) out[cnt[a[i]]--] = a[i];。逆序填充 + 前缀和定位 = 稳定。
口诀:前缀和定位、逆序填充,稳定性全靠这两步!
易错5

基数排序从高位往低位排

❌ 错误
写成 for (exp = 最高位; exp >= 1; exp /= 10)。先解决高位再解决低位,低位排序会把高位排好的顺序冲掉,最终只按个位有序。
✅ 正确
LSD 必须从低位到高位:for (exp = 1; mx / exp > 0; exp *= 10)。让主要关键字(高位)在最后一轮拥有决定权。
口诀:基数排序低到高,最后一轮才是主角!
📋速查表
算法关键公式 / 操作复杂度一句话场景
堆排序孩子 2i / 2i+1;建堆 i 从 n/2 倒序下沉O(n log n) / O(1)要原地、不介意不稳定
优先队列top 取最值、pop 删除、push 插入每次 O(log n)动态取最值(合并果子、对顶堆)
桶排序idx = (v − min) × k / RO(n + k) 均匀时值域可映射、数据均匀
计数排序cnt[v]++;前缀和;逆序填充O(n + V)值域 ≤ 10⁷ 的整数
基数排序exp = 1; exp *= 10;每轮稳定计数排序O(d(n + k))32 位整数、位数少
256 进制基数(x >> shift) & 255,32 位只需 4 轮O(4n)n ≥ 10⁶ 的整数排序
比较下界2ʰ ≥ n! ⇒ h = Ω(n log n)—证明为什么比较排序不能再快
📌笔记卡
📐 必记概念
  • 堆 = 完全二叉树 + 大根 / 小根性质
  • 下标 1 起:孩子 2i、2i+1;父亲 i/2
  • 建堆 O(n),排序 O(n log n)
  • 桶 = 区间分桶;计数 = 一值一桶
  • 基数 = d 轮稳定排序,低位到高位
💻 必背模板
  • siftDown:比较 i 与 2i、2i+1,大的上浮
  • 建堆:for (i=n/2; i>=1; i--)
  • 小根堆:greater<int>
  • 计数排序:前缀和 + 逆序填充
  • 基数排序:(a[i]/exp)%10
⚠️ 考场提醒
  • 先看值域再决定用什么排序
  • 值域 ≥ 10⁸ 别用计数排序
  • 负数不能直接基数排序,要偏移或异或
  • n ≤ 10⁵ 优先 std::sort
  • 需要稳定 → 计数排序 / 归并,不是堆排
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1编程题

手写堆排序,把 n 个数从小到大输出。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int n, a[100005];
void siftDown(int i, int sz) {
    while (true) {
        int l = i << 1, r = l | 1, mx = i;
        if (l <= sz && a[l] > a[mx]) mx = l;
        if (r <= sz && a[r] > a[mx]) mx = r;
        if (mx == i) break;
        swap(a[i], a[mx]); i = mx;
    }
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = n >> 1; i >= 1; i--) siftDown(i, n);   // 建堆
    for (int i = n; i > 1; i--) { swap(a[1], a[i]); siftDown(1, i - 1); }
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
}

思路:先在原数组上建成大根堆,再反复把堆顶(最大值)换到当前末尾并缩小堆范围。

易错:建堆循环写成从 1 到 n/2(应该从 n/2 倒序到 1);siftDown 里忘记更新 i = mx,只交换一次就退出。

A2编程题

计数排序:输入 n 个 1..1000 之间的整数,排序后输出。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int cnt[1005];
int main() {
    int n; cin >> n;
    for (int i = 0; i < n; i++) { int x; cin >> x; cnt[x]++; }
    bool first = true;
    for (int v = 1; v <= 1000; v++)
        for (int t = 0; t < cnt[v]; t++) {
            if (!first) cout << " ";
            cout << v; first = false;
        }
    cout << endl;
}

思路:值域只有 1000,直接开桶统计次数,再按值从小到大输出对应次数个该值。

考点:O(n + V) 而非 O(n log n),这是本题最优解。

易错:输出时多输出一个空格导致 PE;或桶数组按 1000 开(下标到 1000 会越界,要开 1005)。

A3编程题

用优先队列(小根堆)求 n 个数中的最小值、次小值与最大值。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    priority_queue<int, vector<int>, greater<int> > mn;   // 小根堆
    priority_queue<int> mx;                              // 大根堆
    for (int i = 0; i < n; i++) { int x; cin >> x; mn.push(x); mx.push(x); }
    int a1 = mn.top(); mn.pop();       // 最小
    int a2 = mn.top();                 // 次小
    cout << a1 << " " << a2 << " " << mx.top() << endl;
}

思路:priority_queue<int, vector<int>, greater<int>> 是默认大根堆的反向版本,堆顶永远是最小值。

易错:忘记写 > > 中间的空格(老编译器会把 >> 当成右移运算符);或把 greater<int> 写成 greater 少了模板参数。

A4编程题

基数排序:输入 n 个不超过 10⁵ 的非负整数,用基数排序从小到大输出。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int n, a[100005], tmp[100005], cnt[10];
void byDigit(int exp) {
    for (int i = 0; i < 10; i++) cnt[i] = 0;
    for (int i = 1; i <= n; i++) cnt[(a[i] / exp) % 10]++;
    for (int d = 1; d < 10; d++) cnt[d] += cnt[d - 1];
    for (int i = n; i >= 1; i--) tmp[cnt[(a[i] / exp) % 10]--] = a[i];   // 逆序填充
    for (int i = 1; i <= n; i++) a[i] = tmp[i];
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    int mx = *max_element(a + 1, a + n + 1);
    for (int exp = 1; mx / exp > 0; exp *= 10) byDigit(exp);
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
}

易错:每一轮忘记清空 cnt;填充时从左往右(必须从右往左才稳定);exp *= 10 用 int 溢出的写法(本题不会,但 exp 到 10⁹ 时要注意)。

🟡 B组 · 提高(4题)

B1堆 / 贪心

合并果子:n 堆果子,每次合并两堆、消耗两堆重量之和,求最小总消耗(n ≤ 10⁴)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    priority_queue<int, vector<int>, greater<int> > pq;
    for (int i = 0; i < n; i++) { int x; cin >> x; pq.push(x); }
    long long ans = 0;
    while (pq.size() > 1) {
        int a = pq.top(); pq.pop();
        int b = pq.top(); pq.pop();
        ans += a + b; pq.push(a + b);
    }
    cout << ans << endl;
}

思路:哈夫曼贪心 —— 每次合并最小的两堆。用小根堆维护「取最小、删最小、插入新值」。

易错:ans 要用 long long(n 大、每堆大时总和可能超过 2³¹);while 条件写成 !pq.empty() 会导致最后一次对空堆 pop。

B2桶排序应用

给定 n(n ≤ 10⁵)个学生的分数(0~100),统计每个分数有多少人,并按分数从高到低输出「分数 人数」。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int cnt[105];
int main() {
    int n; cin >> n;
    for (int i = 0; i < n; i++) { int x; cin >> x; cnt[x]++; }
    for (int v = 100; v >= 0; v--)
        if (cnt[v]) cout << v << " " << cnt[v] << "\n";
}

思路:典型的计数(桶)应用。值域仅 0~100,桶数组只要 101 格。

易错:用 map 统计再排序(多此一举且更慢);桶数组开 100 而不是 105 导致 cnt[100] 越界。

B3堆 / 结构体

合并果子加强版:n ≤ 10⁵,每堆重量 ≤ 10⁹。写完整代码并说明为什么普通队列不行。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    priority_queue<long long, vector<long long>, greater<long long> > pq;   // 注意 long long
    for (int i = 0; i < n; i++) { long long x; cin >> x; pq.push(x); }
    long long ans = 0;
    while (pq.size() > 1) {
        long long a = pq.top(); pq.pop();
        long long b = pq.top(); pq.pop();
        ans += a + b; pq.push(a + b);
    }
    cout << ans << "\n";
}

为什么不能用普通队列:队列只能取队头(先进先出),而合并果子每次需要取出「当前最小的两堆」—— 这是动态的最值问题,必须用能 O(log n) 取最值的优先队列。

进阶方法:如果把初始果子堆先排序,再用第二个队列保存合并产生的新堆,可以发现两个队列各自都是单调不减的,于是用「二路归并取最小」就能 O(n log n) → O(n) 完成(这正是洛谷 P6033 的解法)。

易错:本题 n = 10⁵、重量 10⁹ 时 ans 可达约 10¹⁵,必须 long long,堆的元素类型也要跟着改成 long long。

B4基数 / 字符串

给定 n 个等长的只含大写字母的字符串(长度 L ≤ 10,n ≤ 10⁵),按字典序排序。

查看参考答案

思路:把每个字符看作一个「位」,字母 A~Z 映射成 0~25。因为是等长字符串,从最后一个字符(最低位)到第一个字符(最高位)逐位做稳定计数排序,共 L 轮,总复杂度 O(L(n + 26))。这与基数排序完全同构。

#include <bits/stdc++.h>
using namespace std;
int n, L, cnt[27];
string s[100005], t[100005];
int main() {
    cin >> n >> L;
    for (int i = 1; i <= n; i++) cin >> s[i];
    for (int pos = L - 1; pos >= 0; pos--) {          // ★ 从最后一个字符开始
        for (int d = 0; d < 26; d++) cnt[d] = 0;
        for (int i = 1; i <= n; i++) cnt[s[i][pos] - 'A']++;
        for (int d = 1; d < 26; d++) cnt[d] += cnt[d - 1];
        for (int i = n; i >= 1; i--) t[cnt[s[i][pos] - 'A']--] = s[i];   // 逆序填充保稳定
        for (int i = 1; i <= n; i++) s[i] = t[i];
    }
    for (int i = 1; i <= n; i++) cout << s[i] << "\n";
}
运行结果
输入: 5 3
      CAB
      ABC
      CAB
      BAA
      ABC
输出: ABC
      ABC
      BAA
      CAB
      CAB

说明:两个 ABC、两个 CAB 的相对顺序保持不变(稳定),共做了 3 轮计数排序。

易错:位序写反(必须从最后一个字符开始);字母映射时忘记减去 'A',导致下标越界;忘记逆序填充导致不稳定。

🔴 C组 · 挑战(4题)

C1初赛真题

下列关于排序的说法,正确的是?

A. 计数排序可以用于排序任意整数,且时间与值域无关
B. 堆排序是稳定排序
C. 基数排序的每一轮排序必须是稳定的
D. 桶排序的最坏时间复杂度是 O(n + k)

查看参考答案

答案:C

A 错:计数排序的时间是 O(n + V),V 是值域宽度,值域越大越慢、空间需求越大,不能「与值域无关」。

B 错:堆排序在「交换堆顶与末尾元素」时会让相等元素跨越,是不稳定排序。

C 对:若某一轮不稳定,前一轮按低位排好的相对顺序就被破坏,最终结果错误。这是 LSD 基数排序成立的前提条件。

D 错:桶排序在数据倾斜时某个桶内退化为 O(n²) 的插入排序,最坏不是 O(n+k)。

易错:把「平均复杂度」当成「最坏复杂度」;或误认为堆排序稳定(堆排序原地但不稳定,这是它的两大缺点之一)。

C2综合分析

为什么「求 n 个数中第 k 小」可以在 O(n) 完成,而「排序 n 个数」至少要 O(n log n)?

查看参考答案

核心原因:输出信息量不同。

排序要求输出完整的顺序,共有 n! 种可能的输出。决策树给出的下界是 log₂(n!) = Ω(n log n)。

而「第 k 小」只需要输出一个数,这个数只能是 n 个候选值之一,只有 n 种可能输出,决策树下界仅为 log₂n = O(log n)。下界低得多,所以存在 O(n) 的算法(快速选择)。

直观理解:排序相当于把所有 n! 种排列都区分开;而求第 k 小只需要在 n 个候选里选一个,信息量小得多。这也是「求最大值」(O(n))比「排序」(O(n log n))快得多的同一种解释。

注意:快速选择的 O(n) 是平均复杂度;最坏情况下仍可能退化到 O(n²)。存在确定性 O(n) 的算法(BFPRT / 中位数的中位数),但常数极大,竞赛中几乎不用。

延伸:如果要求「输出前 k 小的数但内部不要求有序」,下界是 Ω(n + k log k)(可以用大小为 k 的堆做到)。这说明输出的信息量决定了下界。

C3综合编程

对顶堆求动态中位数:依次读入 n 个整数并输出每一次的中位数(下中位数)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
priority_queue<int> L;                                // 大根堆:较小的一半
priority_queue<int, vector<int>, greater<int> > R;   // 小根堆:较大的一半

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        if (L.empty() || x <= L.top()) L.push(x); else R.push(x);
        if ((int)L.size() > (int)R.size() + 1) { R.push(L.top()); L.pop(); }
        if ((int)R.size() > (int)L.size())         { L.push(R.top()); R.pop(); }
        cout << L.top() << " \n"[i == n - 1];
    }
}
运行结果
输入: 7
      1 5 3 8 2 9 4
输出: 1 1 3 3 3 5 4

说明:每次插入后大根堆 L 的堆顶就是当前的下中位数。
复杂度:n 次插入,每次 O(log n),总 O(n log n)。

易错:两个堆的大小关系必须始终维持(|L| = |R| 或 |L| = |R|+1),调整顺序先「L 太多挪给 R」再「R 比 L 多挪给 L」;比较 L.size() > R.size() + 1 时要注意 size() 是无符号数,必须转 int,否则 R.size()+1 与无符号比较会出现逻辑错误。

C4设计题

给定 n(n ≤ 10⁷)个 32 位整数,要求 O(n) 时间排序并输出。请设计完整方案并估算内存。

查看参考答案

方案:256 进制基数排序(4 轮)。

① 数据类型:用 unsigned int 存储,负数通过异或 0x80000000 映射到无符号空间(保证单调)。

② 排序过程:shift = 0, 8, 16, 24,共 4 轮。每轮:统计 256 个桶 → 前缀和 → 逆序填充到辅助数组 → 交换两个数组。每轮 O(n),总计 O(4n) = O(n)。

③ 内存估算:两个 unsigned 数组各 4n 字节,n = 10⁷ 时各 40MB,合计 80MB;计数数组 256×4 = 1KB。总计约 80MB,在 256MB 限制内安全。

④ 与其它方案对比:计数排序值域 2³² 完全不可行;std::sort 约 2×10⁷ × log(10⁷) ≈ 4.6×10⁸ 次比较,约 3~5 秒(超时);256 进制基数排序约 4×10⁷ 次操作,约 0.8 秒。

⑤ 优化点:把「统计 + 前缀和 + 填充」三轮循环合并(先算前缀和偏移量,再一趟填充),可减少内存遍历次数;用 unsigned 直接做位运算,避免除法。

易错:忘记处理负数导致负数排在正数之后;把 256 写成 255 导致桶数错误;用 int 而非 unsigned 做右移(算术右移会补符号位,逻辑完全不同)。

🏆 洛谷原题精选(12道)

以下 12 道题围绕堆、桶、基数排序与排序综合应用,按难度从入门到提高排列。

1
P1271
【深基9.例1】选举学生会入门
2
P1059
明明的随机数入门
3
P2676
Bookshelf B入门
4
P1923
【深基9.例4】求第k小的数普及+
5
P3378
【模板】堆普及+
6
P1177
【模板】快速排序普及+
7
P1090
合并果子普及+
8
P1334
瑞瑞的木板普及+
9
P1631
序列合并普及+
10
P1168
中位数普及+
11
P1801
黑匣子提高
12
P6033
合并果子 加强版提高