CSP-S 提高级课程中心 / 数据结构·线性与特殊树 / 第11讲
CSP-S 提高级 · 数据结构·线性与特殊树 · s3-02

优先队列 与 二叉堆 全解析

不是先来先服务,而是「最紧急的先处理」——把这条朴素愿望变成 O(log n) 的 push/pop,只需要一棵偷偷藏在数组里、编号全靠下标的完全二叉树。

O(log n) push / pop O(n) 一次性建堆 4 个递进案例 5 处交互动画 12 道洛谷原题
本讲 · 第 11/52 讲优先队列与二叉堆
模块 01

情景导航

从急诊分诊台的「病情优先」规则,走进优先队列的世界。

故事急诊分诊台的规则

某医院急诊科改了一套新分诊系统。老系统是先到先看:谁先挂号谁先见医生。结果一个只是擦破皮的患者排在前面,而后面一位胸口剧痛的大爷一直等着——这显然不合理。

新系统的规则只有一句话:每次都从候诊区里挑出「紧急度最高」的那位患者。

护士长给出的排班表是这样运作的:

挂号(紧急度) → 进入候诊区 → 每次叫号取紧急度最高者 → 就诊完成即离开

问题在于:候诊区随时可能有两三百人,每来一位患者就要重新找一遍「谁最紧急」吗?那是 O(n) 的查找——如果每分钟来一位、每次都全表扫描,一天下来就是几百万次比较。

信息科的小张想了个办法:他不把患者按顺序摆成一排,而是摆成一棵「父亲永远比儿子更紧急」的特殊形状的树。于是「最紧急的人」永远站在树根上,取出来只要 O(1);来了新患者或者送走了根上的那位,也只要沿着一条从根到叶的路径调整,最多 log n 步。

运维主管看了一眼说:「你这不是发明了新东西——这就是二叉堆。而且它根本不需要真的建立一棵树,一个一维数组就够了:下标为 i 的节点,父亲是 i/2,左儿子是 2i,右儿子是 2i+1。」

堆的魔法在于:树形结构 + 数组存储 = 极致的缓存友好 + 对数级操作。

💡 为什么不能直接用「有序数组」或「排序 + 取末尾」

方案一 · 每次插入后重新排序:插入 O(log n)(二分找位置)但移动元素 O(n),总 O(n)。n 次操作 O(n2)——n = 105 时是 1010,超时。

方案二 · 有序数组 + 二分插入:插入时找位置 O(log n),但为了保持有序必须搬移后面的所有元素,还是 O(n)。

方案三 · 每次「取最大值」时全表扫一遍:O(n) 一次,n 次操作 O(n2)。

方案四 · 二叉堆:找最大值 O(1)(就在根上),插入 O(log n),删除堆顶 O(log n)。n 次操作总 O(n log n)——这就是我们想要的答案。

核心权衡:堆放弃了「完全有序」,只维持「父优于子」这个弱得多的性质。正因为要求变弱了,维护成本才能从 O(n) 降到 O(log n)。这是一条非常重要的工程哲学:只维持你需要的那部分有序性。

①生活类比
🚑

急诊分诊 = 优先队列

不按到达顺序,按紧急度叫号。最紧急的永远第一个被叫走。

→ priority_queue
🏢

公司职级树 = 二叉堆

每个上级都比直接下属的「级别」高,但同一层的人互不相干——不需要全公司排序。

→ 父优于子
📚

课本编号 = 数组存储

不用真画树,只要按下标编号就能找到亲戚:i 的上级是 i/2,下属是 2i 和 2i+1。

→ i/2、2i、2i+1
⚖️

天平两端 = 对顶堆

左边一堆都小于右边一堆,两边数量差不超过 1——天平中间的那块石头就是中位数。

→ 大根堆 + 小根堆
②核心定义
优先队列(Priority Queue)

一种抽象数据类型(ADT):每次取出的是当前集合中优先级最高的元素,而不是最早进入的元素。它只约定「能做什么」,不约定「怎么实现」。接口

二叉堆(Binary Heap)

优先队列最常用的实现方式。它是一棵满足「父节点优先级不低于子节点」的完全二叉树,因为完全二叉树可以完美地装进数组,所以实际代码里根本看不到指针。实现

大根堆 / 小根堆

父 ≥ 子 叫大根堆,堆顶是全局最大值;父 ≤ 子 叫小根堆,堆顶是全局最小值。C++ 的 priority_queue 默认是大根堆——这一点是初学最大的坑来源。

对顶堆(双堆)

用一个大根堆存较小的那一半、一个小根堆存较大的那一半,并保证两堆大小差 ≤ 1。此时「大根堆的堆顶」就是中位数。用于动态维护中位数。

⚠️ 三个必须马上澄清的误区:

误区 1:「堆是有序的」。完全错误。大根堆的数组表示里,a[2] 和 a[3] 谁大谁小完全不确定,a[4] 甚至可能比 a[7] 小。堆只保证父子关系,不保证兄弟、堂兄弟之间的关系。

误区 2:「堆排序是稳定排序」。错。堆排序涉及大量远距离交换,是典型的不稳定排序。

误区 3:「在堆里找任意元素是 O(log n)」。错。找堆顶是 O(1),但找任意一个值需要遍历整个数组,是 O(n)。堆不支持高效查找——这是它的能力边界。

③核心口诀
父优于子,兄弟有序不论
父亲 i/2,儿子 2i 与 2i+1
插入往上浮,删除往下沉
堆顶取最值,进出对数级
建堆倒着沉,一次线性成

口诀对应五个关键事实:①堆只维持父子关系;②下标公式是数组存储的全部秘密;③shift_up 用于插入、shift_down 用于删除;④top() 是 O(1),push/pop 是 O(log n);⑤从最后一个非叶节点开始倒着下沉建堆,总代价只有 O(n)。

模块 02

知识探究

先把接口和实现分清楚,再吃透「上浮/下沉」这两个原子操作,最后把堆用到五类经典场景里。

①优先队列 ADT:只关心「能做什么」

抽象数据类型的意思是:先把「对外承诺的能力」列清楚,实现方式可以有好几种(二叉堆、配对堆、左偏树、斐波那契堆……)。竞赛里 99% 的情况都用二叉堆。

操作含义二叉堆复杂度
push(x) / emplace(x)把元素 x 加入集合O(log n)
top()查看优先级最高的元素(不删除)O(1)
pop()删除优先级最高的元素(不返回)O(log n)
size() / empty()元素个数 / 是否为空O(1)
(无)查找任意元素堆不支持高效查找O(n)
(无)删除任意元素堆不支持高效删除中间元素O(n)

把优先队列和本讲前后出现的几种结构摆在一起,差异一目了然:

🚌 普通队列 queue

取出顺序:FIFO,先进先出

取出谁:最早进入的元素

适用:BFS、按时间顺序处理

📈 单调队列

取出顺序:队首恒为窗口最值

取出谁:滑动区间的最值

适用:定长窗口最值、DP 优化

🚑 优先队列 / 堆

取出顺序:优先级最高者

取出谁:全局最值(任意时刻)

适用:动态最值、TopK、Dijkstra、Huffman

💡 一句话说清「单调队列 vs 优先队列」的区别:

单调队列的候选集合是一个随时间单调滑动的窗口——元素有「保质期」,过期就必须走。优先队列的候选集合没有保质期——只要没被显式删除,它就一直待在集合里参与竞争。

所以:「最近 k 个数的最大值」用单调队列;「目前所有插入过的数中的最大值」用优先队列。看题目到底有没有「时间窗」这回事。

🎬 同一批数据 {3, 1, 9, 4}:普通队列 vs 优先队列,取出顺序完全不同
1两种结构依次 push 3、1、9、4。queue 内部:[3, 1, 9, 4](按进入顺序排队);priority_queue<int> 内部堆数组:[9, 1, 3, 4](9 自动浮到堆顶,其余位置无序)
2queue.pop() → 取出 3(最早进入的)。它根本不管数值大小
3priority_queue.top() = 9,pop() → 取出 9(当前全局最大)。注意取出的不是最早进入的 3
4继续弹出。queue 依次取出:3 → 1 → 9 → 4(= 进入顺序)
5priority_queue 依次取出:9 → 4 → 3 → 1(= 数值降序,与进入顺序无关)
同一批数据,两种「队列」的出口顺序截然相反:queue 按时间出,priority_queue 按优先级出。这就是「优先队列是 ADT、取出规则由需求定义」的直观体现。
②二叉堆:一棵藏在数组里的完全二叉树

完全二叉树的定义:除了最后一层,其他各层都被节点填满;并且最后一层的节点全部靠左排列。

为什么偏偏要「完全」?因为完全二叉树有一个极其漂亮的性质:按层序编号后,每个节点的编号可以精确算出它的邻居编号。于是我们可以把它「压平」成一个数组,完全不需要指针。

9 ← 根 / 下标 1 7 下标 2 5 下标 3 4 4 2 5 3 6 1 7 数组 a[1..7] = 9 7 5 4 2 3 1 (父 ≥ 子,未排序) 注意:a[2]=7 > a[3]=5 是巧合,堆并不保证兄弟有序
图 1:同一个大根堆的树形视图与数组视图
三条下标公式(下标从 1 开始)
  • 父亲:i / 2(整数除法,向下取整)
  • 左儿子:2 * i
  • 右儿子:2 * i + 1

叶子的判定:若 2*i > n,则 i 是叶子(没有儿子)。等价地,最后一个非叶节点是 n/2——这个结论在建堆时至关重要。

堆序性质(大根堆):对每个 i ≥ 2,都有 a[i/2] ≥ a[i]。反复应用可得一个重要推论:从根到任意叶子的路径上,值一定是非递增的。

下面用格子图看一下「堆的数组视图」。注意相邻格子之间没有任何可比性——这正是它和「有序数组」最大差别:

9
7
5
4
2
3
1

可以验证 a[1]=9 ≥ a[2]=7 ≥ a[4]=4(一条从根到叶的路径,非递增),但 a[2]=7 与 a[3]=5 的先后纯属巧合;若把 a[3] 改成 8,堆序仍然成立(因为 9 ≥ 8),但数组就不再是「从大到小」了。

⚠️易错点:下标从 0 开始还是从 1 开始?

用 0 开始的下标写 2*i 和 2*i+1:根节点下标为 0 时,它的左儿子应该是 1,但 2*0 = 0——指向自己,直接死循环。

两种正确做法:① 用 1 起始的数组(推荐):手写堆时把 a[0] 空着不用,公式就是干净的 i/2、2i、2i+1。② 用 0 起始:公式变成「父亲 (i-1)/2、左儿子 2i+1、右儿子 2i+2」,代码里到处是 +1 容易出错。
注意:标准库 priority_queue 内部用的是 0 起始的实现,但我们不需要关心。手写堆时一律用 1 起始。

🎬 依次 push 3, 1, 9, 4, 7:看着堆数组一步步长出来(与运行结果一致)
1push 3:放到下标 1 → [3]。根没有父亲,无需上浮
2push 1:放到下标 2 → [3, 1]。1 < 父亲 h[1]=3 → 不上浮,堆序保持
3push 9:放到下标 3 → [3, 1, 9]。9 > h[1]=3 → 交换 → [9, 1, 3]。9 登顶
4push 4:放到下标 4 → [9, 1, 3, 4]。4 > 父亲 h[2]=1 → 交换 → [9, 4, 3, 1]。4 < h[1]=9 → 停
5push 7:放到下标 5 → [9, 4, 3, 1, 7]。7 > h[2]=4 → 交换 → [9, 7, 3, 1, 4]。7 < 9 → 停
6最终堆数组:9 7 3 1 4。验证下标公式:h[4]=1 与 h[5]=4 的父亲都是 h[2]=7 ≥ 它们 ✓;h[2]、h[3] 的父亲是 h[1]=9 ✓
注意堆数组 9 7 3 1 4 不是有序的(h[3]=3 > h[5]=4 就违反「越大越靠前」)——它只保证「父 ≥ 子」。可结合上面的格子图与树形图对照着看。
③上浮与下沉:堆的两个原子操作

堆的所有操作都可以拆成两个原子动作。理解它们的关键是:它们都只沿着「一条根到叶的路径」移动元素,所以代价是树高,即 O(log n)。

shift_up(插入用):往上顶 9 7 5 8 新插入 8 在下标 4,父亲是下标 2 的 7 8 > 7 → 交换,8 升到下标 2 再比父亲 9:8 < 9 → 停止 结果:9 8 5 7 只走了 1 步,最多 log n 步 shift_down(删除用):往下沉 把最后一个元素搬到根,再与「较大的儿子」交换,直到满足堆序
图 2:两种原子操作的方向截然相反
操作与原子动作的对应关系
  • push(x):先把 x 放到数组末尾(下标 ++sz),然后对它做 shift_up。为什么要上浮?因为末尾位置的「父亲」可能比 x 小,违反堆序。
  • pop():先把 最后一个元素搬到根(覆盖掉原本的最大值),然后对根做 shift_down。为什么要下沉?因为末尾那个元素通常很小,放在根上大概率违反堆序。
  • 为什么不直接把末尾元素丢掉、然后上浮某个儿子?那样会破坏完全二叉树的结构;用「末尾补位 + 下沉」这一招,数组始终是「连续的 1..sz」,结构天然保持完全。

总结:push = 加在末尾 + 上浮;pop = 末尾搬根 + 下沉。两条规则涵盖了堆的全部维护逻辑。

C++手写二叉堆(大根堆,数组下标从 1 开始)
const int N = 1000005;
int h[N], sz = 0;              // h[1..sz] 为堆,h[0] 空着不用

// 上浮:与父亲比较,比自己小就交换,一路上顶
void shift_up(int i) {
    while (i > 1 && h[i] > h[i >> 1]) {   // i >> 1 等价于 i / 2
        swap(h[i], h[i >> 1]);
        i >>= 1;
    }
}

// 下沉:与「较大的那个儿子」比较,比自己大就交换,一路下沉
void shift_down(int i) {
    while ((i << 1) <= sz) {                // 只要还有左儿子
        int s = i << 1;                     // s = 左儿子
        if (s + 1 <= sz && h[s + 1] > h[s]) s++;   // 选出较大的儿子
        if (h[i] >= h[s]) break;             // 已经满足堆序,收工
        swap(h[i], h[s]);
        i = s;                              // 继续往下检查
    }
}

void push(int x) { h[++sz] = x; shift_up(sz); }
int  top()  { return h[1]; }
void pop()  { h[1] = h[sz--]; shift_down(1); }
运行结果
push 3, push 1, push 9, push 4, push 7
堆数组视图 : 9 7 3 1 4      ← 注意不是有序的
top() = 9
pop()  → 取出 9,堆变 : 7 4 3 1
再 pop() → 取出 7,堆变 : 4 1 3
连续 pop 的输出 : 9 7 4 3 1   ← 弹出顺序是有序的(降序)
🎬 push 8 的上浮过程(与图 2 同款数据,堆 [9, 7, 5])
1初始大根堆 [9, 7, 5](sz=3)。现在 push(8)
2第一步:8 放到数组末尾 ++sz 即下标 4 → [9, 7, 5, 8]。此刻堆序被破坏:父亲 h[2]=7 < 儿子 8
3shift_up(4):8 > h[4>>1]=h[2]=7 → 交换 → [9, 8, 5, 7],i 变为 2
4继续:8 < h[1]=9 → 条件 h[i] > h[i>>1] 不成立 → 停止上浮
5结果 [9, 8, 5, 7],只走了 1 步。若一路顶到根,最多也只走 log n 步——这就是 push 是 O(log n) 的原因
6再看 pop():末尾 7 搬到根 → [7, 8, 5] → 7 与较大儿子 8 交换 → [8, 7, 5]。方向恰好相反:push 往上浮,pop 往下沉
关键观察:上浮/下沉只沿一条「根到叶」的路径移动元素,其余位置一个都不碰——这就是堆操作只需 O(log n) 的全部秘密。

🔍 代码逐段解析 · 手写二叉堆

第 2 行 int h[N], sz = 0;:h[1..sz] 是堆的有效区间,h[0] 刻意空着不用——这样 i/2、2i、2i+1 三个公式才干净成立(若从 0 开始,2*0=0 指向自己)。

shift_up(i):循环条件 i > 1 && h[i] > h[i >> 1] 有两半——i > 1 保证「还有父亲」(根没有父亲,再比就越界了);h[i > 1] 即与父亲比较,比父亲大就 swap 并 i >>= 1 走到父亲的位置继续。

shift_down(i):三步走——① (i << 1) <= sz 判断「有没有左儿子」;② if (s + 1 <= sz && h[s + 1] > h[s]) s++; 在右儿子存在时选出较大的儿子;③ 与较大儿子比较,h[i] >= h[s] 就 break,否则交换并 i = s 继续下沉。

push/pop/top 三件套:push = 末尾写入 h[++sz] + 上浮;pop = h[1] = h[sz--] 末尾补位 + 下沉;top 直接返回 h[1],O(1)。

易错点 1:shift_down 必须和较大的儿子交换。若和较小的交换,另一个更大的儿子会「压」在新父亲头上,堆序直接破坏(见模块五错误③)。

易错点 2:pop 里 h[1] = h[sz--] 的顺序不能反——先把末尾值抄到根、再让 sz 减一。若先 sz--,抄到根的就成了「丢失的那个元素」的隔壁,且原末尾值永远留在了数组里。

细节 1(为什么和较大的儿子换):假设根是 1,两个儿子是 5 和 8。如果和 5 交换,那么 8 的父亲变成 1,而 1 < 8,堆序被破坏。所以必须和较大的那个交换,才能保证交换后「新父亲 ≥ 两个儿子」。

细节 2(为什么弹出顺序一定有序):每次 pop 都取出当前集合的最大值,所以输出序列天然降序——这正是堆排序的原理。

小根堆怎么改?把所有比较符号反过来:shift_up 改成 h[i] < h[i>>1],shift_down 改成选较小的儿子、且 h[i] <= h[s] 时停止。

④建堆:为什么可以是 O(n) 而不是 O(n log n)

要把 n 个给定的数变成一个堆,直觉上有两种做法。它们看起来只差一个循环的方向,复杂度却差一个数量级。

❌ 逐个插入(n 次 push)

做法:读入一个数就 push 一次。

代价:每次 push 最坏走满树高 log n,共 n 次 → O(n log n)。

问题:前面插入的元素下沉代价小(树还浅),后面插入的元素代价大——但总体仍是 n log n。

✅ 倒着下沉(从 n/2 到 1)

做法:先把 n 个数原样放进数组,然后 for (i = n/2; i >= 1; i--) shift_down(i);

代价:大部分节点(占一半以上)是叶子,根本不用下沉 → O(n)。

优势:数据量大时快 10 倍以上。

O(n) 的证明(竞赛中必背的均摊分析):在一棵 n 个节点的完全二叉树中,高度为 h 的节点最多有 n / 2h+1 个,而让一个高度为 h 的节点下沉,最多走 h 步。所以总代价

总代价 ≤ Σ(h 从 0 到 log n) h · n / 2^(h+1) = (n/2) · Σ h / 2^h ≤ (n/2) · 2 = n

(用到了级数 Σ h/2h = 1/2 + 2/4 + 3/8 + … = 2。)关键在于「节点越少、能下沉的高度越大」——代价大的节点极少,所以总和被压到了线性。

C++建堆的两种写法与堆排序
// ---------- 写法 A:逐个插入,O(n log n) ----------
for (int i = 1; i <= n; i++) { h[++sz] = x[i]; shift_up(sz); }

// ---------- 写法 B:倒着下沉,O(n) ----------
for (int i = 1; i <= n; i++) h[i] = x[i];
sz = n;
for (int i = n / 2; i >= 1; i--) shift_down(i);   // 从最后一个非叶节点倒着来

// ---------- 应用:堆排序(升序输出) ----------
for (int i = 1; i <= n; i++) {
    printf("%d ", h[1]);     // 取堆顶(当前最大值)
    h[1] = h[sz--];           // 末尾补位
    shift_down(1);            // 维护堆序
}
运行结果
输入数组 : 4 2 9 1 7 3
写法 B 建堆后 : 9 7 4 1 2 3        ← 只保证父 ≥ 子
堆排序输出 : 9 7 4 3 2 1           ← 每轮取走堆顶,得到降序
(若要升序输出,把小根堆的堆顶依次取出即可)
⚠️易错点:建堆循环从 n/2 还是从 n 开始?

从 n 开始下沉:当前版本 shift_down(i) 里有 if (2*i <= sz) 的判断,对叶子节点会立刻返回,不会错,但白跑了 n/2 次循环。更糟的是,如果写成了不加判断的版本(直接访问 h[2*i]),从叶子开始就会数组越界。

正确写法:for (int i = n/2; i >= 1; i--) shift_down(i);。为什么是 n/2?因为下标 > n/2 的节点都满足 2*i > n,即全是叶子。最后一个非叶节点的编号恰好是 n/2。

🎬 建堆全过程(输入 4 2 9 1 7 3,写法 B 倒着下沉,GCC 实测)
1原样放入数组:[4, 2, 9, 1, 7, 3],sz = 6。此时堆序完全没保证。最后一个非叶节点 = 6/2 = 3,所以倒着从 i=3 开始
2i=3:h[3]=9,唯一儿子 h[6]=3。9 ≥ 3 已满足堆序 → 不动。数组仍为 [4, 2, 9, 1, 7, 3]
3i=2:h[2]=2,儿子 h[4]=1、h[5]=7,较大的儿子是 7。2 < 7 → 交换 → [4, 7, 9, 1, 2, 3]。2 落到下标 5,已是叶子,结束
4i=1:h[1]=4,儿子 h[2]=7、h[3]=9,较大的儿子是 9。4 < 9 → 交换 → [9, 7, 4, 1, 2, 3]。4 落到下标 3,儿子 h[6]=3,4 ≥ 3 → 停止
5建堆完成:[9, 7, 4, 1, 2, 3](与运行结果一致)。全程只发生 2 次实际交换——因为一半以上的节点是叶子,根本没参与
接着做堆排序:每轮取堆顶 9 → 7 → 4 → 3 → 2 → 1,输出恰好降序。注意建堆后 h[2]=7 > h[3]=4 并没有「排好」——堆序只约束父子,不约束兄弟。
⑤STL priority_queue:四个必须记住的写法

考场上手写堆容易出下标错误,用 STL 又快又稳。但 priority_queue 的模板参数和比较器语义和 sort 恰好相反,这是最大的坑。

写法 1 · 大根堆(默认)

#include <queue>
priority_queue<int> pq;              // 等价于 priority_queue<int, vector<int>, less<int>>
pq.push(3); pq.push(1); pq.push(9);
printf("%d\n", pq.top());          // 9,队首是最大值

写法 2 · 小根堆(要写全三个模板参数)

priority_queue<int, vector<int>, greater<int> > pq;   // 两个 > 之间要有空格
pq.push(3); pq.push(1); pq.push(9);
printf("%d\n", pq.top());          // 1,队首是最小值

写法 3 · 自定义结构体(重载 operator<,直观)

struct Node {
    int id, val;
    // 注意:priority_queue 默认用 less,返回 true 表示「优先级更低」
    // 想让 val 大的优先 → 让 val 小的「优先级更低」→ 返回 a.val < b.val
    bool operator<(const Node& o) const {
        if (val != o.val) return val < o.val;   // val 大的先出
        return id > o.id;                       // val 相同时 id 小的先出
    }
};
priority_queue<Node> pq;

写法 4 · 自定义比较器(用仿函数,不改结构体)

struct Node { int id, val; };

struct cmp {
    bool operator()(const Node& a, const Node& b) const {
        if (a.val != b.val) return a.val > b.val;   // 注意:写 > 才是 val 小的先出
        return a.id < b.id;
    }
};
priority_queue<Node, vector<Node>, cmp> pq;   // 队首:val 最小者

💡 一句话记住「比较器方向相反」这件事:

sort 里的 cmp(a, b) 返回 true 表示「a 应该排在 b 前面」——所以 return a.val < b.val; 得到升序。

priority_queue 里的 cmp(a, b) 返回 true 表示「a 的优先级比 b 低,a 应该排在 b 后面」——所以 return a.val < b.val; 意味着「val 小的排在后面」,即 val 大的先出(大根堆)。

记忆口诀:「sort 的 < 是升序,堆的 < 是大队」——堆的比较器写 < 就是大根堆,写 > 就是小根堆。实在记不住,就记「greater 是小根堆」,反推 less 是大根堆。

附加技巧 · pair 进堆与懒惰删除

// ---- pair 进堆:默认先比 first,first 相同比 second,都是「大的优先」----
priority_queue<pair<int,int> > pq;
pq.push({3, 100});
pq.push({5, 2});
printf("(%d,%d)\n", pq.top().first, pq.top().second);   // (5,2)
// 想按 first 升序?把 first 取负存进去即可:push({-x, y})

// ---- 懒惰删除:需要「删除堆中某个元素」但堆不支持删除中间元素 ----
// 做法:不真删,只是打个标记;等它浮到堆顶时再顺手扔掉
int del[N];                        // del[x] = x 被删了几次
while (!pq.empty() && del[pq.top()]) { del[pq.top()]--; pq.pop(); }
int now = pq.top();                // 此时堆顶才是有效的最大值
运行结果
大根堆弹出顺序 : 9 3 1
小根堆弹出顺序 : 1 3 9
pair 堆顶 : (5,2)
懒惰删除后堆顶 : 有效的最大值(已被删的都被顺手清掉了)

⚠️ 懒惰删除的代价要知道:被标记删除的元素仍然占着堆里的位置,所以最坏情况下堆的大小会膨胀到「总插入次数」而不是「当前有效元素个数」。如果插入次数是 n 而有效元素始终很少,空间会退化到 O(n)。
正确用法:在每次需要读堆顶之前都先执行那个 while 清理循环,保证 pq.top() 一定是有效元素。千万不要只在某处清理一次。

🎬 同一批数据 {3, 1, 9}:默认大根堆 vs greater 小根堆
1写法 1:priority_queue<int> pq;(默认 less,大根堆)。依次 push 3、1、9
2内部堆数组演化:[3] → [3, 1] → 9 > 3 上浮 → [9, 1, 3]。堆顶 pq.top() = 9,弹出顺序 9 3 1
3写法 2:priority_queue<int, vector<int>, greater<int>> pq;。同样 push 3、1、9
4内部演化:[3] → 1 < 3 上浮 → [1, 3] → [1, 3, 9]。堆顶 pq.top() = 1,弹出顺序 1 3 9
5对比结论:同一个 push 序列,只把 less 换成 greater,弹出顺序就完全颠倒——「堆的比较器写 < 是大队、写 > 是小队」
再看写法 3 / 4 的自定义结构体:operator< 里 return a.val < o.val; 等价于 less → 大根堆(val 大的先出);比较器 cmp 里写 return a.val > b.val; 等价于 greater → 小根堆(val 小的先出)。与 sort 的语义恰好相反。

🔍 代码逐段解析 · priority_queue 四种写法

写法 1(默认):priority_queue<int> pq; 完整展开是 priority_queue<int, vector<int>, less<int>>——三个模板参数依次是元素类型、底层容器、比较器。默认 less → 大根堆。

写法 2(小根堆):必须写全三参 priority_queue<int, vector<int>, greater<int> >。易错 1:C++17 之前两个 > 相连必须留空格,>> 会被当成移位运算符报编译错;易错 2:底层容器 vector 不能省——省了它 greater 就没有位置可放。

写法 3(重载 operator<):成员函数签名 bool operator<(const Node& o) const——末尾的 const 不能丢(priority_queue 内部以 const 对象调用比较器);返回 true 表示 this 的优先级更低。return id > o.id; 实现「val 相同时 id 小的先出」。

写法 4(外部仿函数 cmp):适合「不改结构体定义」的场合;priority_queue<Node, vector<Node>, cmp> 第三个参数填类型而不是对象。cmp 的 operator() 返回 true 表示 a 优先级更低 → return a.val > b.val; 得到小根堆。写法 3 / 4 的方向恰好互为镜像,抄模板最容易在这里翻车。

附加技巧(pair 与懒惰删除):pair 进堆默认先比 first 再比 second、都是大的优先;想按 first 升序就存负值 push({-x, y})(Dijkstra 的常用套路)。懒惰删除三要素:del[] 计数数组、读堆顶前的 while 清理循环、清理时消耗标记(--)而非清零。

⑥堆的五类经典应用
应用 1 · 堆排序

建堆 O(n) + n 次弹出 O(n log n) = O(n log n),原地(只需 O(1) 额外空间)、不稳定。优势在于空间,劣势在于缓存不友好(跳跃访问)。

应用 2 · TopK / 动态第 k 大

求「最大的 k 个数」:维护一个大小为 k 的小根堆,堆顶是这 k 个里最小的。新元素比堆顶大就替换。空间 O(k),时间 O(n log k)。

应用 3 · 合并果子 / Huffman

每次合并「当前最小的两堆」。用小根堆维护所有堆的大小,每次取两个最小值合并、把和塞回去。这是贪心 + 堆的经典组合。

应用 4 · Dijkstra / Prim 堆优化

每次需要取出「当前距离最小的未访问点」。用堆维护,把复杂度从 O(n2) 降到 O((n+m) log n)——稀疏图上的标准做法。

应用 5 · 对顶堆求中位数

大根堆管小的一半、小根堆管大的一半,两堆大小差 ≤ 1,此时大根堆堆顶就是中位数。动态插入 O(log n),查询 O(1)。

应用 6 · 多路归并 / 任务调度

把 k 个有序序列的当前头部放进堆,每次取最小值并补充该序列的下一个元素——k 路归并 O(n log k)。任务调度则是「按优先级取下一个要执行的任务」。

🔍 TopK 的两种思路,为什么必须用「小根堆」

错误思路:用大根堆存所有数,弹出 k 次。空间 O(n)、时间 O(n log n)。当 n = 107 而 k = 10 时,这是巨大的浪费。

正确思路:维护一个大小为 k 的小根堆。遍历数组,若堆未满就 push;若堆已满且 x > 堆顶,则 pop() 再 push(x)。

为什么是小根堆?因为我们要「淘汰最没希望的那个候选」——在这 k 个候选里,最小的那个最没希望,而小根堆的堆顶恰好就是它。用大根堆的话堆顶是最大的,反而要开 O(k) 的空间去查找最小候选。

复杂度:每个元素最多一次 push + 一次 pop,O(n log k);空间 O(k)。当 k 远小于 n 时,比 O(n log n) 快得多。

🎬 合并果子贪心推演(4 堆果子:1 2 3 4,与案例 1 同一数据)
14 堆果子的重量 {1, 2, 3, 4} 全部进小根堆,堆顶是最小的 1
2第 1 次合并:弹出两个最小值 1、2,消耗 1+2 = 3;合并出的新堆 3 放回 → 堆变 {3, 3, 4}
3第 2 次合并:弹出 3、3,消耗 6;新堆 6 放回 → 堆变 {4, 6}。注意:合并出的 3 立刻成为候选并再次被选中——这就是「必须用堆动态取最小」的原因
4第 3 次合并:弹出 4、6,消耗 10 → 堆变 {10},只剩一堆,结束
5总消耗 = 3 + 6 + 10 = 19(与案例 1 运行结果一致)。共 n−1 = 3 次合并,每次 O(log n)
为什么小的先合并?每个「原始堆」的重量被计入总代价的次数 = 它在合并树中的深度。1 和 2 位于树的最深层,被重复计了 2 次;4 只计了 1 次。重量小的多算、重量大的少算,总和才最小——这正是哈夫曼树的构造规则。
⑦对顶堆:动态维护中位数

问题:数据一个一个地到来,每来一个(或在某些时刻)就要回答「当前所有数的中位数」。排序会 O(n log n) 且每次都重来,太慢。

对顶堆的设定:

大根堆 L(较小的一半) 堆顶 = 中位数 下面还有更小的数 size(L) = size(R) 或 +1 小根堆 R(较大的一半) 堆顶 = R 中最小 下面还有更大的数 每个元素都 ≥ L 的堆顶 不变式①:max(L) ≤ min(R) —— 左边所有数都不大于右边所有数 不变式②:size(L) = size(R) 或 size(L) = size(R) + 1
图 3:对顶堆的两条不变式
插入与取中位数(三步)
  1. 决定进哪一边:若 L 为空或 x ≤ L.top(),则 x 属于「较小的一半」→ push 进 L;否则 push 进 R。这一步保证了不变式①。
  2. 调整数量:若 L.size() > R.size() + 1,把 L 的堆顶搬到 R;若 R.size() > L.size(),把 R 的堆顶搬到 L。这一步修复不变式②。
  3. 取答案:中位数就是 L.top()。因为 size(L) ≥ size(R),第 size(L) 大的数恰好落在 L 的堆顶。

复杂度:每次插入最多 1 次 push + 1 次跨堆搬运,都是 O(log n);查询 O(1)。

🎬 对顶堆求中位数全过程(依次读入 1, 9, 2, 8, 3, 7, 4,奇数位置输出中位数)
1i=1, x=1:L 为空 → push 进 L。L = {1},R = {}。数量检查:1 ≤ 0+1,且 0 ≤ 1,都通过。输出 L 堆顶 1
2i=2, x=9:9 ≤ L.top()=1?否 → push 进 R。L = {1},R = {9}。数量相等,无需搬运。i 为偶数,不输出
3i=3, x=2:2 ≤ 1?否 → push 进 R。R = {9, 2}(堆顶 2)。此时 size(R)=2 > size(L)=1 → 把 R 堆顶 2 搬到 L。L = {2, 1}(堆顶 2),R = {9}。输出 2
4i=4, x=8:8 ≤ L.top()=2?否 → push 进 R。L = {2, 1},R = {9, 8}(堆顶 8)。size 都是 2,无需搬运。i 为偶数,不输出
5i=5, x=3:3 ≤ 2?否 → push 进 R。R = {9, 8, 3}(堆顶 3)。size(R)=3 > size(L)=2 → 搬 3 到 L。L = {3, 2, 1}(堆顶 3),R = {9, 8}。输出 3
6i=6, x=7:7 ≤ L.top()=3?否 → push 进 R。L = {3, 2, 1},R = {9, 8, 7}(堆顶 7)。size 都是 3,无需搬运。i 为偶数,不输出
7i=7, x=4:4 ≤ 3?否 → push 进 R。R = {9, 8, 7, 4}。size(R)=4 > size(L)=3 → 搬 4 到 L。L = {4, 3, 2, 1}(堆顶 4),R = {9, 8, 7}。输出 4
中位数序列:1   2   3   4。手工验证:读入前 3 个数 {1, 9, 2} 排序为 1, 2, 9,中位数是 2;读入前 5 个数 {1, 9, 2, 8, 3} 排序为 1, 2, 3, 8, 9,中位数是 3;读完全部 7 个数排序为 1, 2, 3, 4, 7, 8, 9,中位数是 4。每一次 L 的堆顶都恰好等于当前中位数。
C++对顶堆模板(动态中位数)
priority_queue<int> L;                              // 大根堆:较小的一半
priority_queue<int, vector<int>, greater<int> > R;   // 小根堆:较大的一半

void add(int x) {
    // ① 决定进哪边
    if (L.empty() || x <= L.top()) L.push(x);
    else                           R.push(x);

    // ② 调整数量,维持 size(L) == size(R) 或 size(L) == size(R)+1
    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(); }
}
int median() { return L.top(); }             // ③ 查询 O(1)
⚠️易错点:两个堆的「方向」和「数量约定」都必须固定

错误写法:一会儿让大根堆存大的一半,一会儿又存小的一半;或者数量约定写成 size(L) == size(R) 但插入时却按 size(L) = size(R)+1 调整——两者不一致会导致中位数算错。

推荐固定约定:① 大根堆 L 永远存较小的一半(元素 ≤ 中位数);② 小根堆 R 永远存较大的一半;③ size(L) = size(R) 或 size(L) = size(R) + 1;④ 中位数永远是 L.top()。四条一旦定好就不要改,所有步骤都围绕它们展开。
若要输出「两数平均」的中位数(偶数个元素时),则当 size(L) == size(R) 时答案为 (L.top() + R.top()) / 2.0。

案例案例1:合并果子(P1090 贪心 + 小根堆) 洛谷真题

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

贪心策略:每次合并当前最小的两堆。为什么呢?合并后的新堆会被「再合并」若干次,所以它的重量会被重复计入总代价。越早合并的堆被计入的次数越多,因此应该让重量小的堆尽可能早、尽可能多地参与合并——这正是「每次取最小的两个」。

这个贪心的正确性可以用哈夫曼树(Huffman Tree)的理论严格证明:合并过程恰好对应构造一棵带权路径长度最小的二叉树,而哈夫曼算法的每一轮操作就是「取两个权值最小的节点合并」。所以本题的策略与哈夫曼编码完全同构。

C++P1090 完整代码(STL 小根堆版)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; scanf("%d", &n);
    priority_queue<int, vector<int>, greater<int> > pq;   // 小根堆
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &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);                    // 合并后的新堆放回
    }
    printf("%lld\n", ans);
    return 0;
}
运行结果
输入:4
      1 2 3 4
输出:19
推演:取 1,2 合并 → 消耗 3,堆变 {3,3,4}
      取 3,3 合并 → 消耗 6,堆变 {4,6}
      取 4,6 合并 → 消耗 10,堆变 {10}
      总消耗 = 3 + 6 + 10 = 19

🔍 为什么不能用「排序一次然后顺序合并」

有人会想:既然每次取最小的两堆,那先排序、然后从头到尾两两合并不就行了?不行。因为合并后的新堆可能比后面还没合并的堆更小,此时新堆应该优先被合并。例如 {1, 2, 3}:先合并 1+2=3 得到新堆 3,此时候选是 {3, 3},而不是把原来的 3 和别的合并。

所以必须用能「动态取最小值」的结构——这正是小根堆。每轮操作 O(log n),共 n-1 轮,总复杂度 O(n log n)。

如果题目改成一个很大的 n:读入后可以用「倒着下沉」一次建堆(O(n)),再执行 n-1 轮合并,总复杂度仍是 O(n log n)(弹出部分占主导)。但如果能把整个数组一次性读进来,建堆用 O(n) 版本比 n 次 push 快约 log n 倍。

⚠️易错点:答案必须用 long long

用 int 累加:当 n 很大(如 104)且每堆重量都到 104 时,总消耗可能达到 108 甚至更高;如果重量上限更大,int 会溢出成负数。

正确写法:long long ans = 0;,并且注意 a + b 如果也是 int,在 push 之前就要考虑溢出——在本题范围内 push 回去的值不会超过总和,但如果重量上限很大(如 2×109),则堆本身也应该用 long long。

🔍 代码逐段解析 · P1090 合并果子(STL 小根堆版)
L5int n; scanf("%d", &n); 读入堆数。L6 声明 priority_queue<int, vector<int>, greater<int>> pq ——三个模板参数一个都不能省,省了就是默认大根堆,整个贪心就反了
L7-10读入循环:每个重量 x 直接 pq.push(x)。此处用 n 次 push(O(n log n))没问题;若追求极限可改「原样存数组 + 倒着下沉」的 O(n) 建堆
L12long long ans = 0; ——累加器必须 long long:n−1 次合并、每次累加两堆之和,总量可达重量总和 × log n 量级
L13while (pq.size() > 1):只要还有至少两堆就继续合并。注意 pq.size() 是无符号类型,与 > 1 比较没问题,但若写 pq.size() - 1 > 0 这类式子会踩无符号下溢的坑
L14-15int a = pq.top(); pq.pop(); 连续做两次「取顶 + 弹出」——先取最小的 a,再取次小的 b。易错:两次都要「先 top 存下来再 pop」,因为 pop() 不返回值,pop 完再 top 拿到的已经是下一个了
L16-17ans += a + b; 累加本次消耗;pq.push(a + b); 把合并后的新堆放回候选集——这一行是贪心得以继续的关键(新堆可能比剩余的堆更小)
L19printf("%lld\n", ans); ——格式符必须是 %lld 配 long long,写成 %d 是未定义行为
复杂度:n−1 轮合并 × 每轮 2 次 pop + 1 次 push(各 O(log n))= O(n log n)。样例 4 / 1 2 3 4 输出 19(GCC 实测一致)。
案例案例2:序列合并(P1631 多路归并 + 堆) 洛谷真题

题目:有两个长度为 n 的递增序列 A 和 B,在 A 与 B 中各取一个数相加,可以得到 n2 个和。求其中最小的 n 个和。

核心洞察:把问题看成一张 n×n 的表格,第 i 行是 A[i] + B[1], A[i] + B[2], …, A[i] + B[n]。因为 B 递增,所以第 i 行的元素是递增的。于是「求全局最小的 n 个和」就变成了「在 n 个有序序列中做 n 路归并,每次取全局最小」。

第 1 步初始化:把每行的第 1 个元素 (A[i]+B[1], i, j=1) 压入小根堆
第 2 步弹出堆顶,得到当前全局最小的和,输出
第 3 步顺着这一行往右推一格,把新元素压入堆
第 4 步重复 n 次,每次输出堆顶 → 得到最小的 n 个和
C++P1631 完整代码(n 路归并)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int n, A[N], B[N];

struct Node {
    int sum, i, j;                 // 和、行号、列号
    // 小根堆:让 sum 小的「优先级更高」→ 让 sum 大的「优先级更低」
    bool operator<(const Node& o) const { return sum > o.sum; }
};
priority_queue<Node> pq;

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &A[i]);
    for (int i = 1; i <= n; i++) scanf("%d", &B[i]);

    // 每行的第 1 个元素入堆
    for (int i = 1; i <= n; i++) pq.push({A[i] + B[1], i, 1});

    for (int k = 1; k <= n; k++) {
        Node t = pq.top(); pq.pop();
        printf("%d ", t.sum);
        if (t.j + 1 <= n)                       // 顺着这一行往右推
            pq.push({A[t.i] + B[t.j + 1], t.i, t.j + 1});
    }
    return 0;
}
运行结果
输入:3
      1 4 7
      2 5 8
输出:3 6 6
(所有 9 个和为 3 6 9 / 6 9 12 / 9 12 15,最小的 3 个是 3 6 6,
  其中 6 = A[1]+B[2] = 1+5,另一个 6 = A[2]+B[1] = 4+2)

💡 为什么这样不会漏掉最优解?

假设全局第 k 小的和是 A[i] + B[j]。因为同一行内是递增的,所以 A[i] + B[1], …, A[i]+B[j-1] 都比它小。而根据第 3 步的规则,只有当前一个元素被弹出后,同一行的下一个元素才会入堆。所以当 A[i]+B[j] 弹出时,比它小的那些同行的元素必然都已经先被弹出了——算法总是弹出当前堆中的最小值,不会出现「更小的元素还在某行里没进堆」的情况。

复杂度:堆里最多同时有 n 个元素,共 push 2n 次、pop n 次,总 O(n log n)。空间 O(n)——不需要真的建出 n×n 的表格。

⚠️易错点:结构体的比较器方向写反

写成 return sum < o.sum;:这会得到一个大根堆,堆顶是最大的和——输出会变成从大到小,完全错误。

记住这条:结构体里重载 operator< 时,return a.x < b.x; 得到的是「x 大的优先」(大根堆)。想要小根堆就要写 return a.x > b.x;。
背法:对照 priority_queue<int>(大根堆)用的是 less<int>,而 less<a>(a,b) = a < b——所以「< → 大根堆」。想反了就加个 greater。

🔍 代码逐段解析 · P1631 序列合并(n 路归并)
L6-7const int N = 100005; int n, A[N], B[N]; ——两个源序列。注意堆里不存 n×n 的表格,只存「每个序列当前的头部」,空间 O(n)
L9-13struct Node { int sum, i, j; ... }; ——堆元素打包三件事:和 sum、来自第几行 i、该行第几列 j。L12 重载 operator< 返回 sum > o.sum:写 > 得到小根堆(sum 小的先出),与直觉相反,是本题第一个坑
L14priority_queue<Node> pq; ——直接用重载好的 operator<,不需要第三个模板参数
L20初始化:每行第 1 个元素 {A[i] + B[1], i, 1} 入堆(共 n 个)。因为 B 递增,A[i]+B[1] 就是第 i 行的最小值——每行的「门面」先站出来竞争
L22-24主循环 n 次:Node t = pq.top(); pq.pop(); 取出全局最小的和并输出 t.sum。堆顶永远是「所有行当前头部」的最小者
L25-26if (t.j + 1 <= n) pq.push({A[t.i] + B[t.j + 1], t.i, t.j + 1}); ——顺着弹出的那行往右推一格。易错:这个 if 不能漏——j 已是最后一列时若继续 push,B[j+1] 会数组越界
复杂度堆中始终 ≤ n 个元素;总 push ≤ 2n 次、pop n 次,每次 O(log n) → O(n log n)。样例 A=1 4 7 / B=2 5 8 输出 3 6 6(GCC 实测一致)
为什么不会漏解?某元素 A[i]+B[j] 能进堆的前提是同行前 j−1 个都已被弹出——而它们都比它小、必然更早弹出。所以「当前堆顶」一定是所有「已曝光候选」的最小者,且真正更小的元素绝不可能还藏在未曝光的行里。
案例案例3:堆优化 Dijkstra(P4779 单源最短路径) 洛谷真题

题目:给定 n 个点 m 条有向边,边权非负。求从源点 s 到所有点的最短路径长度。n, m ≤ 105。

朴素 Dijkstra 的瓶颈:每一轮都要「在所有未确定点里找 dist 最小的那个」,这一步是 O(n) 的,共 n 轮 → O(n2)。当 n = 105 时是 1010,超时。

堆优化:把「找最小」这件事交给小根堆。堆里存 (dist, u),每次弹出 dist 最小的点。因为堆的 push/pop 都是 O(log n),总复杂度降到 O((n + m) log n)——稀疏图(m 与 n 同阶)上的标准解法。

C++P4779 完整代码(链式前向星 + 小根堆)
#include <bits/stdc++.h>
using namespace std;
const int N = 100005, M = 200005, INF = 0x3f3f3f3f;
int n, m, s;
int head[N], nxt[M], to[M], w[M], ec = 0;
int dist[N];
bool vis[N];

void add(int u, int v, int c) { to[++ec] = v; w[ec] = c; nxt[ec] = head[u]; head[u] = ec; }

int main() {
    scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i <= m; i++) {
        int u, v, c; scanf("%d%d%d", &u, &v, &c);
        add(u, v, c);
    }

    memset(dist, 0x3f, sizeof(dist));
    dist[s] = 0;

    // pair 默认按 first 降序 → 存 -dist 就变成「dist 小的优先」
    priority_queue<pair<int,int> > pq;
    pq.push({0, s});

    while (!pq.empty()) {
        int d = -pq.top().first, u = pq.top().second;
        pq.pop();

        // 懒惰删除:这是「过期的旧记录」,直接跳过
        if (d > dist[u]) continue;

        for (int e = head[u]; e; e = nxt[e]) {
            int v = to[e];
            if (dist[u] + w[e] < dist[v]) {       // 松弛成功
                dist[v] = dist[u] + w[e];
                pq.push({-dist[v], v});              // 新记录入堆
            }
        }
    }

    for (int i = 1; i <= n; i++)
        printf("%d ", dist[i] == INF ? 2147483647 : dist[i]);
    return 0;
}
运行结果
输入:4 6 1
      1 2 2
      2 3 2
      2 4 1
      1 3 5
      3 4 3
      1 4 4
输出:0 2 4 3
(1→2 为 2;1→2→3 为 4;1→2→4 为 3)

🔍 堆优化 Dijkstra 的三个关键点

关键点 1 · 懒惰删除必须有。一个点可能被多次松弛、多次入堆(每次入堆时 dist 更小)。堆里会残留同一批点的多条「旧记录」。用 if (d > dist[u]) continue; 把过期记录直接跳过——这就是懒惰删除法。

关键点 2 · 为什么要用「存 -dist」而不是自定义比较器?priority_queue<pair<int,int>> 默认按 first 从大到小排,所以存负数就等价于「dist 从小到大优先」。这样省掉了写比较器的代码,是最常见的竞赛写法。另一种写法是 priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>> >,直接存 (dist, u)。

关键点 3 · 复杂度到底是多少?每条边最多引发一次成功的松弛,所以最多 push m 次;每次 push/pop 是 O(log n) → O(m log n)。加上每个点被弹出一次,总计 O((n+m) log n)。

⚠️ 负权边不能用 Dijkstra!Dijkstra 贪心的前提是「已确定的最短距离不会再被更新」。有负权边时这个前提不成立,必须换成 Bellman-Ford / SPFA / Johnson。

🔍 代码逐段解析 · P4779 堆优化 Dijkstra(链式前向星 + 小根堆)
L5-7head[N]、nxt[M]、to[M]、w[M]、ec 五件套是链式前向星:第 e 条边的终点在 to[e]、同起点下一条边在 nxt[e]、起点头指针 head[u]。ec 从 0 起、先自增再存(to[++ec]),所以下标从 1 开始——这保证 for (e = head[u]; e; e = nxt[e]) 能用 e == 0 当终止条件
L18memset(dist, 0x3f, sizeof(dist)); ——0x3f 按字节填充 int 得 0x3f3f3f3f ≈ 109:既足够大(当「无穷大」),又保证 两倍不溢出、还便于 if (dist[u] + w[e] < dist[v]) 里直接相加(参见 s2-06 的 memset 字节陷阱)
L22-23priority_queue<pair<int,int>> pq; pq.push({0, s}); ——默认大根堆按 first 从大到小,所以存 -dist:堆顶 first 最大 = 真实 dist 最小。起点 (0, s) 先入堆
L26-27int d = -pq.top().first, u = pq.top().second; pq.pop(); ——取堆顶并还原真实距离(负号变回正)。易错:先把 top 的两个分量都抄下来再 pop,pop 之后再访问 pq.top() 就是下一个元素了
L30if (d > dist[u]) continue; ——懒惰删除的判断:堆里可能残留同一个点的多条旧记录(dist 更大),弹出来发现「过期的旧距离」比当前 dist[u] 大,直接跳过,不展开它的边
L32-38松弛循环:枚举 u 的每条出边,dist[u] + w[e] < dist[v] 则更新 dist[v] 并把新记录 (-dist[v], v) 入堆。旧记录不删——留给 L30 的懒惰删除过滤
L41-42输出:dist[i] == INF 时打印 2147483647(题目约定的不可达输出)。易错:不能直接输出 INF 本身以外随意编的数,要按题面要求来
复杂度:每条边最多引发一次成功松弛 → push ≤ m 次,每次 O(log n),总计 O((n+m) log n)。课件样例 4 6 1 输出 0 2 4 3(GCC 实测一致)。
案例案例4:中位数(P1168 对顶堆) 洛谷真题

题目:给出 n 个数(n 为奇数),依次读入。每当已经读入的个数为奇数时,输出目前为止所有数组成的序列的中位数。

朴素做法:每次读入后用 nth_element 或排序求中位数,总复杂度 O(n2 log n) 或 O(n2)——n = 105 时超时。对顶堆做法:O(n log n)。

C++P1168 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; scanf("%d", &n);
    priority_queue<int> L;                              // 大根堆:较小的一半
    priority_queue<int, vector<int>, greater<int> > R;   // 小根堆:较大的一半

    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &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(); }

        // ③ 奇数位置输出中位数
        if (i % 2 == 1) printf("%d\n", L.top());
    }
    return 0;
}
运行结果
输入:7
      1 9 2 8 3 7 4
输出:1
      2
      3
      4

💡 「对顶堆」为什么能保证中位数在 L 的堆顶?

设当前共 i 个数。由 不变式①(max(L) ≤ min(R))可知:L 里的每个数都 ≤ R 里的每个数,所以整个序列排序后,L 的元素恰好占据前 size(L) 个位置。

又由 不变式② 可知 size(L) = ⌈i/2⌉。当 i 是奇数时,⌈i/2⌉ = (i+1)/2,即中位数的位置(第 (i+1)/2 小)。而第 size(L) 小的元素正是 L 中最大的那个,即 L.top()。证毕。

拓展:若要求「第 k 小」而不是中位数,可以把两堆的容量约定改成 size(L) = k(L 存最小的 k 个),此时 L.top() 就是第 k 小。这正是「动态第 k 小」的通用解法。

🔍 代码逐段解析 · P1168 中位数(对顶堆完整版)
L6-7两个堆的声明方向不能搞反:L 是默认大根堆(存较小的一半,堆顶是这一半的最大值),R 是 greater 小根堆(存较大的一半,堆顶是这一半的最小值)。两个堆顶「面对面」,中间夹着的就是中位数
L13if (L.empty() || x <= L.top()) L.push(x); else R.push(x); ——决定进哪边。易错:L.empty() 必须放在 &&/|| 的前面(短路求值先判空),否则第一个数就会访问空堆的 top(),未定义行为
L17if (L.size() > R.size() + 1) { R.push(L.top()); L.pop(); } ——L 比 R 多出不止 1 个时,把 L 堆顶搬到 R。注意 L.size() 返回无符号数,代码里显式转了 (int) 再比较,避免 size() - 1 式的无符号下溢
L18if (R.size() > L.size()) { L.push(R.top()); R.pop(); } ——反过来 R 比 L 多时搬回 L。两条 if 顺序不能对调:必须先处理「L 过多」再处理「R 过多」,两个 if 最多各执行一次,最终保证 size(L) = size(R) 或 = size(R)+1
L21if (i % 2 == 1) printf("%d\n", L.top()); ——奇数位置输出。此时 size(L) = (i+1)/2 恰好是中位数的位置,所以 L.top() 就是答案。易错:输出的是 L.top() 不是 R.top(),两堆方向弄反的话所有答案都错
样例 7 / 1 9 2 8 3 7 4 输出 1 2 3 4(GCC 实测一致;上面「对顶堆求中位数全过程」动画已逐步推演过)。总复杂度 O(n log n)。
模块 03

实践应用

三道递进的编程题,覆盖「有界堆」「多关键字堆」「懒惰删除」三种最常用的堆技巧。

A基础模板普及/提高-

数据流逐个数到来。请维护「当前所有数的第 k 小」,每来一个数就输出一次。要求空间 O(k)。n ≤ 106。

查看参考答案

思路:维护一个大小为 k 的大根堆 L,让 L 里始终装着「目前最小的 k 个数」。此时 L.top() 就是第 k 小。

插入规则:先 push(x),再检查 size(L) > k——若是就把堆顶(L 中最大的,也就是第 k+1 小)弹出。这样 L 里永远是「最小的 k 个」。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, k; scanf("%d%d", &n, &k);
    priority_queue<int> L;                 // 大根堆,只保留最小的 k 个

    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);

        L.push(x);                              // 先无条件塞进去
        if ((int)L.size() > k) L.pop();      // 多了就把最大的踢掉

        if ((int)L.size() == k) printf("%d\n", L.top());
        else                       printf("-1\n");   // 不足 k 个数,无解
    }
    return 0;
}

验证(GCC 实测):输入 7 3 / 5 2 8 1 9 3 7,依次输出 -1 -1 8 5 5 3 3。逐位核对:前 3 个数 {5,2,8} 排序为 2 5 8,第 3 小是 8;第 4 个数 1 进来后 L 踢掉 8 变 {5,2,1},第 3 小是 5;第 5 个数 9 被踢,仍是 5;第 6 个数 3 进来踢掉 5 变 {3,2,1},是 3;第 7 个数 7 进来又被踢,仍是 3。

为什么用大根堆而不是小根堆?因为我们要「淘汰最大的候选」。L 中任意时刻有 k 个元素,新元素进来后必须踢掉一个——踢掉最大的那个,剩下的自然是最小的 k 个。而大根堆的堆顶就是最大值,O(log k) 就能取到。
对比另一种做法:「用大根堆存所有数,弹 k-1 次取堆顶」——那是 O(n log n) 时间、O(n) 空间,且无法回答「每来一个数就输出」。本题的写法是 O(n log k) 时间、O(k) 空间。

逐段解析:① L5 读 n、k,L6 声明 priority_queue<int> L——注意是默认大根堆:大小为 k 的大根堆维护「最小的 k 个」,堆顶恰好是第 k 小;② L9 L.push(x) 先无条件塞进去(先污染后治理,比分情况讨论简洁);③ L10 if ((int)L.size() > k) L.pop(); 超员就踢堆顶——踢掉的是「k+1 个候选中最大的」,剩下的自然是最小的 k 个;④ L12-13 size == k 时输出 L.top(),否则输出 -1。易错 1:第 1~k−1 个数输出 -1(不足 k 个无第 k 小),从第 k 个起才有答案;易错 2:L.size() 与 k 比较要转 (int),防无符号比较陷阱。

B多关键字堆普及/提高-

有 n 个任务,每个任务有「到达时间 r、执行耗时 t、优先级 p」。同一时刻只能执行一个任务,调度规则:先到先服务;若当前时刻有多个任务在等待,则选优先级数值最小的执行。请输出每个任务的编号、开始执行时间和结束时间。

查看参考答案

思路:这是典型的事件模拟 + 优先队列。按到达时间把任务排序;维护一个「当前时间 now」,若队列为空且还有任务没到,就把 now 直接跳到下一个任务的到达时间。

#include <bits/stdc++.h>
using namespace std;

struct Task {
    int id, r, t, p;
    bool operator<(const Task& o) const {
        if (p != o.p) return p > o.p;   // 小根堆:p 小的优先
        return id > o.id;              // p 相同时编号小的优先
    }
};

int main() {
    int n; scanf("%d", &n);
    vector<Task> arr(n);
    for (int i = 0; i < n; i++) {
        scanf("%d%d%d", &arr[i].r, &arr[i].t, &arr[i].p);
        arr[i].id = i + 1;
    }
    sort(arr.begin(), arr.end(), [](const Task& a, const Task& b) {
        return a.r < b.r;
    });

    priority_queue<Task> pq;
    int i = 0; long long now = 0;

    while (i < n || !pq.empty()) {
        // 把所有「已经到达」的任务推进堆
        if (pq.empty()) now = max(now, (long long)arr[i].r);
        while (i < n && arr[i].r <= now) pq.push(arr[i++]);

        Task cur = pq.top(); pq.pop();
        printf("%d start=%lld end=%lld\n", cur.id, now, now + cur.t);
        now += cur.t;
    }
    return 0;
}

验证:输入 3 个任务 (r,t,p) = (0,3,5), (1,2,1), (5,4,2):
now=0 时只有任务 1 到达 → 执行任务 1,[0, 3)。
now=3 时任务 2 已在等待(r=1),堆里只有任务 2 → 执行任务 2,[3, 5)。
now=5 时任务 3 恰好到达 → 执行任务 3,[5, 9)。

易错:① now 必须是 long long(总耗时可能超过 int 范围);② 队列为空时必须把 now 跳到下一个任务的到达时间,否则会出现「CPU 空转」式的死循环;③ 推进任务的 while 条件用的是 arr[i].r <= now(小于等于),恰好等于 now 的任务也应该能参与本次竞争;④ 比较器里 p > o.p 是小根堆(p 小的优先),写反就成了「优先级数值大的先执行」。

逐段解析:① struct Task 的 operator< 是双关键字堆:先比 p > o.p(p 小的优先级高 → 小根堆),p 相同比 id > o.id(编号小的先执行,保证先来先到);② L799 sort 按 a.r < b.r 把任务按到达时间排序——堆只负责「已到达的任务里挑谁」,到达顺序由排序负责,两者分工明确;③ L806 if (pq.empty()) now = max(now, (long long)arr[i].r); CPU 空转急救:没有任务可执行时,把时钟直接跳到下一个任务的到达时间;④ L807 while (i < n && arr[i].r <= now) pq.push(arr[i++]); 把所有「已到达」的任务一口气推入堆(<= 而不是 <,恰好在 now 时刻到达的任务也能参与本轮竞争);⑤ L809-811 取堆顶执行:printf("%d start=%lld end=%lld\n", cur.id, now, now + cur.t); now += cur.t;。易错:now 必须 long long(Σt 可超 int),printf 格式符相应用 %lld。

C综合编程普及+/提高

初始有 n 个数全部在堆中。接下来有 q 次操作,每次是「插入一个数 x」或「删除一个数 x」(保证 x 当前存在)。每次操作后输出当前堆中的最大值。n, q ≤ 105。

查看参考答案

难点:堆只能删除堆顶,不支持删除任意元素。所以「删除 x」必须用懒惰删除法:不真删,只打标记;等它浮到堆顶时再顺手扔掉。

#include <bits/stdc++.h>
using namespace std;
const int V = 1000005;
int del[V];                       // del[x] = 值 x 被「待删除」的次数(x 不大时可直接用值当下标)

priority_queue<int> pq;             // 大根堆

// 清理堆顶所有「已被标记删除」的元素
void clean() {
    while (!pq.empty() && del[pq.top()]) {
        del[pq.top()]--;              // 消耗一个删除标记
        pq.pop();
    }
}

int main() {
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        pq.push(x);
    }
    int q; scanf("%d", &q);
    while (q--) {
        int op, x;
        scanf("%d%d", &op, &x);
        if (op == 1) pq.push(x);       // 插入
        else              del[x]++;       // 删除:只打标记,不真删

        clean();                          // 取值前必须清理一次
        printf("%d\n", pq.empty() ? -1 : pq.top());
    }
    return 0;
}

验证:初始 {5, 1, 9}。执行「删除 9」→ 标记 del[9]=1;调用 clean():堆顶是 9 且被标记 → 弹掉,del[9] 减为 0;新堆顶 5 → 输出 5。再执行「插入 7」→ 堆 {7,5,1},堆顶 7 无标记 → 输出 7。

三个必须注意的点:
① 每次取堆顶之前都要 clean(),不能只在某处清理一次——因为被标记的元素可能一直沉在堆底,直到某天被别的元素「挤」到堆顶。
② del[x] 是「计数」而不是「布尔」。因为同一个值可能出现多次,也可能被删除多次(每次删除对应一次出现),用计数才能正确处理重复值。
③ 空间代价:被标记的元素仍占堆中位置。q 次插入最多让堆膨胀到 n+q 个元素,空间 O(n+q)。如果值域很大(如 109),del 数组要换成 map<int,int> 或先离散化。
更好的做法:如果题目允许,用两个堆(一个"插入堆"、一个"删除堆")做「双堆对消」也很常见——插入堆的堆顶若等于删除堆的堆顶,就同时弹掉,否则就是有效答案。

逐段解析:① L829-832:int del[V]; 按值打标记(要求值域 ≤ 106,值大就要换 map<int,int> 或离散化),priority_queue<int> pq 默认大根堆;② L835-840 clean():while (!pq.empty() && del[pq.top()]) { del[pq.top()]--; pq.pop(); }——堆顶若带删除标记就弹掉并消耗一个标记(不是清零!同一值可能剩多次出现),循环到堆顶有效或堆空为止;③ L852-853:op==1 直接 pq.push(x),否则 del[x]++——删除操作只花 O(1),真正的删除被推迟到堆顶;④ L855-856:每次操作后先 clean 再输出,堆空输出 -1。易错 1:clean 必须放在每次取堆顶之前(含输出前),漏一次就可能输出已删除的值;易错 2:del[x]-- 写成 = 0 会在「同值多次插入多次删除」时把还没删够的标记一起清掉。

模块 04

总结提升

把堆的知识收拢成一张表,再用三个拓展案例把视野推到省选层次。

📌核心要点总结
① 优先队列是 ADT,二叉堆是实现

ADT 只约定「能 push、能取最值、能 pop」;二叉堆是满足「父优于子」的完全二叉树,是最高效的通用实现。

② 完全二叉树 + 数组 = 零指针

下标从 1 开始,父亲 i/2、左儿子 2i、右儿子 2i+1。最后一个非叶节点是 n/2。

③ 两个原子操作

shift_up:与父亲比较,比自己小就交换(插入用)。shift_down:与较大的儿子比较,比自己大就交换(删除用)。两者都只走一条根到叶的路径,O(log n)。

④ 建堆是 O(n) 不是 O(n log n)

从 i = n/2 倒着下沉。因为「高度大的节点数量极少」,级数求和后总代价只有 2n。

⑤ STL 的比较器方向与 sort 相反

priority_queue 默认 less → 大根堆;greater → 小根堆。结构体里 return a.x < b.x; 得到大根堆。两个 > 相连要加空格。

⑥ 五大应用 + 三大技巧

应用:堆排序、TopK、Huffman(合并果子)、Dijkstra、对顶堆中位数。技巧:有界堆(大小为 k)、多关键字比较器、懒惰删除(+ 计数标记)。

🔗 与上一讲的接续:上一讲单调队列解决「有保质期的最值」——候选集合是一个滑动的窗口;本讲的堆解决「没有保质期的最值」——候选集合可以随时插入、随时被标记删除。
再往前,set/map 解决的是「需要有序遍历 + 查找前驱后继」的场景。三者各有所长:要窗口 → 单调队列;要动态全局最值 → 堆;要有序 + 查找 → 平衡树。

🚀拓展案例

拓展 1 · k 叉哈夫曼树(P2168 荷马史诗)

问题:有 n 个单词,第 i 个出现 w[i] 次。要用 k 进制编码给它们编码(每位有 k 种可能),要求任意编码互不为前缀。求编码总长度的最小值,以及在总长度最小的前提下最长编码的最短长度。

与合并果子的关系:2 叉哈夫曼树(合并果子)就是 k = 2 的特例。一般 k 叉哈夫曼的做法是:每次取出权值最小的 k 个节点合并,新增节点的权值 = 这 k 个之和。用小根堆维护即可。

第一个坑 · 补虚节点:每次合并减少 k-1 个节点,要从 n 个减到 1,需要减少 n-1 个。若 (n-1) mod (k-1) ≠ 0,最后一轮会凑不满 k 个节点。解决方法是预先补上若干个权值为 0 的虚节点,使得 (n - 1) mod (k - 1) == 0。

第二个坑 · 最长编码最短:当有多个权值相同的候选时,应优先选当前深度较小的节点合并(因为合并后的新节点深度 +1)。所以堆里存的应该是 pair<权值, 深度>,并在权值相同时比较深度——恰好利用 pair 默认「先比 first 再比 second」的特性。

C++k 叉哈夫曼树的核心片段
typedef pair<long long, int> PII;   // (权值, 深度)
priority_queue<PII, vector<PII>, greater<PII> > pq;   // 小根堆,权值相同时深度小的优先

for (int i = 1; i <= n; i++) pq.push({w[i], 0});

// 补虚节点:让 (n-1) 能整除 (k-1)
while ((pq.size() - 1) % (k - 1) != 0) pq.push({0, 0});

long long ans = 0; int maxd = 0;
while (pq.size() > 1) {
    long long s = 0; int d = 0;
    for (int i = 0; i < k; i++) {          // 取出最小的 k 个
        s += pq.top().first;
        d = max(d, pq.top().second);
        pq.pop();
    }
    ans += s;
    maxd = max(maxd, d + 1);
    pq.push({s, d + 1});                  // 新节点深度 = 子树最大深度 + 1
}
printf("%lld\n%d\n", ans, maxd);
运行结果
输入:4 2
      1 1 2 2
输出:12
      2
(2 叉哈夫曼:{1,1}→2,堆变 {2,2,2};{2,2}→4,堆变 {2,4};
  {2,4}→6,堆变 {6}。总长度 = 2+4+6 = 12,最大深度 2)

🔍 代码逐段解析 · k 叉哈夫曼核心片段

第 1~2 行:typedef pair<long long, int> PII; 堆元素是 (权值, 深度) 二元组;greater<PII> 小根堆按 pair 默认规则比较——先比 first 再比 second,于是「权值相同时深度小的先出堆」,恰好实现「优先合并浅节点」。

第 4 行:叶子深度初始为 0(pq.push({w[i], 0}))。

第 7 行 while ((pq.size() - 1) % (k - 1) != 0) pq.push({0, 0});:补虚节点。每次合并减少 k−1 个节点,要把 n 个减到 1 必须满足 (n−1) 能被 (k−1) 整除;不能整除就补权值为 0 的虚节点(它们被安排在最深层,不影响总代价)。易错:pq.size() 是无符号类型,当 size 为 0 时 size()-1 会下溢成天文数字——本行因为堆里至少 1 个元素而安全,但改写时要小心。

第 10~15 行主循环:每次弹出 k 个最小节点,累加 s、取最大深度 d;ans += s(每个新内部节点的权值都要计入总长——权值和 = 编码长度和);pq.push({s, d + 1}) 新节点深度 = 子树最大深度 + 1;maxd = max(maxd, d + 1) 维护第二问答案。

易错点 2:ans 与权值必须 long long——荷马史诗数据规模下总长可达 1018 级别附近的量级,int 必爆。

验证:输入 4 2 / 1 1 2 2 输出 12 / 2(GCC 实测一致:3 次合并消耗 2+4+6=12)。

拓展 2 · 堆优化 Prim 求最小生成树

朴素 Prim 的瓶颈:每一轮要从「未加入点集」中找 lowcost 最小的点,O(n) 一次,共 n 轮 → O(n2)。

堆优化:用小根堆维护 (lowcost, u),每次弹出堆顶(最小边权)并按同样的「懒惰删除」思路过滤过期记录。复杂度 O(m log n)。

priority_queue<PII, vector<PII>, greater<PII> > pq;   // (边权, 点)
pq.push({0, 1});                                    // 任选 1 号点作起点
int cnt = 0; long long ans = 0;

while (!pq.empty() && cnt < n) {
    int d = pq.top().first, u = pq.top().second; pq.pop();
    if (vis[u]) continue;          // 懒惰删除:已加入的点直接跳过
    vis[u] = true; cnt++; ans += d;

    for (int e = head[u]; e; e = nxt[e]) {
        int v = to[e];
        if (!vis[v]) pq.push({w[e], v});     // 把与 u 相连的边都入堆
    }
}
if (cnt < n) puts("orz");        // 图不连通,无生成树
else         printf("%lld\n", ans);

💡 注意这里的「懒惰删除」和 Dijkstra 略有不同。Dijkstra 用 d > dist[u] 判断过期;Prim 用 vis[u] 判断过期。两者本质相同——都是「弹出的元素是否仍然是有效的候选」。写的时候只要想清楚「什么情况下这个堆顶已经没用了」,就能写出正确的过滤条件。

🔍 代码逐段解析 · 堆优化 Prim 片段

第 1~2 行:小根堆存 (边权, 点),pq.push({0, 1}) 把起点以 0 代价送入——「任选 1 号点作起点」, Prim 从任意点开始结果相同。

第 4 行 while (!pq.empty() && cnt < n):两个终止条件——堆空(所有候选边用尽)或已选够 n 个点。cnt < n 这个条件兼做防死循环:图不连通时堆可能先空,靠 cnt < n 与堆空一起兜底。

第 6~8 行:弹堆顶,if (vis[u]) continue; 懒惰删除(同一点可能带多条不同边权的记录入堆,只有第一次弹出有效);首次弹出才 vis[u] = true; cnt++; ans += d;。

第 10~12 行:把 u 的所有连边 (w[e], v) 入堆——易错 1:不判 !vis[v] 直接 push 也正确(靠 vis 过滤),但堆会更大;判了更省空间;易错 2:判断「图不连通」用 cnt < n 而不是堆空——两者配合才严谨。

拓展 3 · 堆 + 二分的组合技(P1462 通往奥格瑞玛的道路)

问题:一张带权无向图,每个点有一个「过路费」f[i],每条边有「血量消耗」w[i]。要从 1 号点走到 n 号点,总血量消耗不超过 B,且使得所经过点的过路费最大值尽可能小。求这个最小值。

二分什么?二分「过路费的上限 x」。对于固定的 x,只允许经过 f[i] ≤ x 的点,然后跑一次堆优化 Dijkstra 求 1 到 n 的最小血量消耗。若 ≤ B 则可行,否则不可行。

为什么可以二分?「x 越大,可用的点越多,最短路的血量消耗单调不增」——单调性成立。

🧩 提炼的套路:「求「最大值最小」→ 二分答案 → check 内部跑堆优化最短路」是提高组和省选的经典三段式。本题的堆优化 Dijkstra 是 check 的核心组件,所以说「堆是图论算法的发动机」并不夸张。

🤔思考与启发

思考 1:建堆为什么能做到 O(n)?请不看书,用自己的语言复述「级数求和」的证明思路。如果要建一个「小根堆」,这个方法还成立吗?

提示:核心是「高度越大的节点数量越少,而它们才是代价大的那批」。这个方法对大小根堆都成立,只是 shift_down 里的比较符号不同。

思考 2:如果既需要「高效取最值」又需要「高效查找任意元素 / 删除任意元素」,堆就不够用了。应该换用什么数据结构?

提示:需要「有序 + 查找 + 增删」→ 平衡树(std::set / std::map / 手写 Treap、FHQ-Treap)。它们能在 O(log n) 内完成「查找、插入、删除、求前驱后继、求第 k 小」。代价是常数比堆大、代码更长。能用堆解决的绝不用平衡树——这是考场上的效率原则。

思考 3:为什么堆「不支持高效删除任意元素」,却仍然能满足绝大多数竞赛需求?

提示:因为绝大多数场景的删除需求都能转化为「懒惰删除」——不真删,只打标记,等它到堆顶再扔。代价是空间膨胀(最多到「总插入次数」)。只要空间允许,这就是最省事的做法。真正需要「可并堆 / 可删堆」的场景(如左偏树、配对堆)属于更高级的内容。

思考 4:对顶堆为什么能同时支持「取最小」「取最大」「取中位数」,而单调队列不能?

提示:对顶堆的每一半内部是有序结构(堆只保证父子序,但两侧拼起来就等价于把序列分成了「小的一半」和「大的一半」)。而单调队列是按时间顺序维护的,元素之间只有「谁先进入」的关系,没有全局的位置信息。所以单调队列只能回答「区间最值」,不能回答「第 k 小」这类需要位置信息的问题。

模块 05

学习检测

五个最容易翻车的点 + 一张速查表 + 一张笔记卡。

⚠️高频错误诊断
①以为 priority_queue 默认是小根堆

症状:写了 priority_queue<int> pq; 然后 pq.top(),期望拿到最小值,结果拿到的是最大值;或者合并果子这类「取最小」的题目答案莫名其妙偏大。

事实:C++ 的 priority_queue 默认是大根堆(用 less 比较,即「大的先出」)。要小根堆必须写全三个模板参数:priority_queue<int, vector<int>, greater<int> > pq;
记忆法:「默认大队」——priority_queue 的默认行为是「最大值优先」。实在不放心,就在代码里明确写出 less / greater,可读性也更好。

②比较器的方向写反(与 sort 混淆)

症状:自定义结构体的堆,弹出顺序正好和预期相反。或者同一份 cmp 用在 sort 里是对的,用在堆里就是错的。

根本原因:sort 的 cmp(a,b)==true 表示「a 排在 b 前面」;priority_queue 的 cmp(a,b)==true 表示「a 排在 b 后面(优先级更低)」。语义正好相反。
实用口诀:「堆的比较器写 < 就是大根堆」——因为默认参数就是 less。想让数值小的优先,就把 < 改成 >,或者直接用 greater。
更稳的做法:结构体里重载 operator<,并在注释里写清「返回 true 表示 this 的优先级更低」,以后回看不会忘。

③shift_down 与「随便一个儿子」交换

症状:手写堆在小数据量下看起来正常,数据一多就乱序,或者偶尔出现「父子关系正确但整体不满足堆序」。

原因:下沉时必须与较大的那个儿子交换(大根堆)。若与较小的儿子交换,另一个儿子就会变成「比自己大的父亲的孩子」,堆序被破坏。
正确写法:先用两步选出较大的儿子,再与它比较:
int s = i<<1; if (s+1 <= sz && h[s+1] > h[s]) s++; if (h[i] >= h[s]) break;
自检方法:写完堆后,用 for (i=2..sz) assert(h[i] <= h[i/2]); 验证一次,一秒就能发现方向错误。

④建堆循环从 n 开始而不是 n/2

症状:程序偶尔崩溃(数组越界),或者结果正确但耗时明显偏高。

原因:下标 > n/2 的节点都是叶子,对它们调用 shift_down 是纯浪费。如果 shift_down 里没有 2*i <= sz 的保护,从叶子开始就会访问 h[2*i] 而越界。
正确写法:for (int i = n/2; i >= 1; i--) shift_down(i);。记住「最后一个非叶节点 = n/2」这个结论,比死记循环起点更可靠。

⑤懒惰删除时忘记「每次取值前都清理」

症状:用「标记删除」解决的题目,偶尔输出了一个已经被删除的值。

原因:被标记的元素可能沉在堆底,要等别的元素被弹出后才浮到堆顶。如果只在某一处清理一次,之后取到的堆顶仍可能是过期元素。
正确写法:把清理逻辑封装成一个 clean() 函数(内部是 while (!pq.empty() && del[pq.top()]) { del[pq.top()]--; pq.pop(); }),然后在每一次读 pq.top() 之前都调用它。
另外注意:del 数组必须是「计数」(可能同一个值被插多次、删多次),不能写成布尔标记。

📋速查表

表 1 · 手写堆模板(大根堆,下标从 1 开始)

int h[N], sz = 0;

void shift_up(int i) {
    while (i > 1 && h[i] > h[i >> 1]) { swap(h[i], h[i >> 1]); i >>= 1; }
}
void shift_down(int i) {
    while ((i << 1) <= sz) {
        int s = i << 1;
        if (s + 1 <= sz && h[s + 1] > h[s]) s++;      // 选较大的儿子
        if (h[i] >= h[s]) break;
        swap(h[i], h[s]); i = s;
    }
}
void push(int x) { h[++sz] = x; shift_up(sz); }
int  top()  { return h[1]; }
void pop()  { h[1] = h[sz--]; shift_down(1); }
// 建堆:for (int i = n/2; i >= 1; i--) shift_down(i);   O(n)
// 小根堆:把所有比较符号反向(> 变 <,选较小的儿子)

表 2 · STL 四种写法

需求写法
大根堆(默认)priority_queue<int> pq;
小根堆priority_queue<int, vector<int>, greater<int> > pq;
结构体(重载 operator<)返回 a.x < b.x → x 大的优先;返回 > → x 小的优先
结构体(外部比较器)priority_queue<Node, vector<Node>, cmp> pq;,cmp 里返回值与 sort 语义相反
pair 按 first 升序存 (−x, y),或 greater<pair<int,int>>
Dijkstra 最常用priority_queue<pair<int,int>> pq; 存 (−dist, u)

表 3 · 五种「用堆做最值」的场景对照

场景堆的类型核心操作复杂度
取全局最大 / 最小大根堆 / 小根堆top(),push,popO(log n)
TopK(最大的 k 个)大小为 k 的小根堆大于堆顶就替换O(n log k)、空间 O(k)
动态第 k 小大小为 k 的大根堆超容量就弹堆顶O(n log k)、空间 O(k)
动态中位数对顶堆(大 + 小)插入后平衡两堆数量O(log n) 插、O(1) 查
合并果子 / Huffman小根堆每次取两个最小合并回填O(n log n)

表 4 · 复杂度速记

操作复杂度备注
top()O(1)堆顶就是答案
push(x)O(log n)末尾插入 + shift_up
pop()O(log n)末尾搬根 + shift_down
逐个插入建堆O(n log n)n 次 push
倒着下沉建堆O(n)从 n/2 到 1
查找任意元素O(n)堆不支持高效查找
删除任意元素O(n) 或懒惰删除推荐用「标记 + 清理」
📌笔记卡
堆 = 只保证「父优于子」的完全二叉树
父 i/2,左 2i,右 2i+1,最后一个非叶是 n/2
push 往上浮,pop 往下沉(与较大的儿子换)
默认大队!小根堆要写 greater
比较器方向与 sort 相反,堆里写 < 就是大队
TopK 用小根堆,第 k 小用大根堆——都在淘汰「最没希望的」
删不掉的就先标记,浮到顶再扔(懒惰删除)
本讲最小可交付

能手写大根堆的 shift_up / shift_down;能说清为什么建堆是 O(n);能默写 TopK 与对顶堆的模板。

本讲典型陷阱

默认堆方向记反、比较器与 sort 混淆、下沉时选错儿子、建堆循环起点错误、懒惰删除忘记每次清理。

下讲衔接

下一讲进入 ST 表(Sparse Table)——用「倍增」预处理出所有 2k 长度区间的答案,实现 O(1) 的任意区间最值查询。它与堆、单调队列的适用边界会在下讲做一次完整对比。

课后练习

分层练习作业

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

🟢 A组 · 基础巩固(4题)

A1概念题

在数组 h[1..10] 表示的大根堆中,h[6] 的父亲是谁?h[7] 的兄弟是谁?哪些节点是叶子?

查看答案

h[6] 的父亲:6 / 2 = 3,即 h[3]。

h[7] 的兄弟:7 是奇数,说明它是 3 的右儿子;它的兄弟是 h[6](左儿子)。

叶子节点:满足 2i > 10 即 i > 5,所以 h[6] ~ h[10] 都是叶子。最后一个非叶节点是 10/2 = 5。

延伸:在 n = 10 的堆中,非叶节点只有 5 个(下标 1~5),叶子有 5 个。叶子占比接近一半——这正是「倒着下沉建堆」能到 O(n) 的直观原因:一半的节点根本不需要做任何事。

A2模板默写

不看笔记,写出大根堆的 shift_up、shift_down、push、pop 四个函数。再写出「倒着下沉建堆」的循环。

查看答案
int h[N], sz = 0;

void shift_up(int i) {
    while (i > 1 && h[i] > h[i >> 1]) { swap(h[i], h[i >> 1]); i >>= 1; }
}
void shift_down(int i) {
    while ((i << 1) <= sz) {
        int s = i << 1;
        if (s + 1 <= sz && h[s + 1] > h[s]) s++;
        if (h[i] >= h[s]) break;
        swap(h[i], h[s]); i = s;
    }
}
void push(int x) { h[++sz] = x; shift_up(sz); }
void pop()  { h[1] = h[sz--]; shift_down(1); }

// 倒着下沉建堆 O(n)
for (int i = n / 2; i >= 1; i--) shift_down(i);

自检清单:① shift_up 的循环条件是 i > 1(根没有父亲);② shift_down 的循环条件用左儿子存在判断;③ 选儿子时先判断右儿子是否存在;④ pop 里必须先 h[1] = h[sz--] 再 shift_down,顺序不能反;⑤ 数组下标从 1 开始,h[0] 空着。

A3推演题

依次把 5, 3, 8, 1, 9, 2 插入大根堆,请画出每一步的数组表示,最后连续弹出并写出弹出顺序。

查看答案

插入过程(数组 h[1..sz]):

push 5 → [5](i=1,无父亲,停)
push 3 → [5, 3](3 < 5,不上浮)
push 8 → [5, 3, 8] → 8 > h[1]=5,交换 → [8, 3, 5]
push 1 → [8, 3, 5, 1](1 < h[2]=3,不上浮)
push 9 → [8, 3, 5, 1, 9] → 9 > h[2]=3,交换 → [8, 9, 5, 1, 3] → 9 > h[1]=8,交换 → [9, 8, 5, 1, 3]
push 2 → [9, 8, 5, 1, 3, 2](2 < h[3]=5,不上浮)

最终堆数组:9 8 5 1 3 2(注意 h[2]=8 > h[3]=5 只是巧合,堆不保证兄弟有序)。

连续弹出(每次把末尾搬到根再下沉):

弹出 9 → 末尾 2 搬根 → [2,8,5,1,3] → 与较大儿子 8 交换 → [8,2,5,1,3] → 2 与较大儿子 3 交换 → [8,3,5,1,2],取走 9
弹出 8 → 末尾 2 搬根 → [2,3,5,1] → 与 5 交换 → [5,3,2,1],取走 8
弹出 5 → 末尾 1 搬根 → [1,3,2] → 与 3 交换 → [3,1,2],取走 5
弹出 3 → 末尾 2 搬根 → [2,1],取走 3;弹出 2,取走 2;最后弹出 1
弹出顺序:9   8   5   3   2   1(必然是降序)

A4辨析题

堆和平衡树(set)都能「动态取最值」,两者如何选择?

查看答案

priority_queue(堆)能做到的:取全局最值 O(1)、插入 O(log n)、删除最值 O(log n)。代码短、常数小、空间紧凑。

set 能做到的(堆做不到的):查找任意元素 O(log n)、删除任意元素 O(log n)、求前驱 / 后继、求第 k 小(用 pbds 或手写平衡树)、按顺序遍历。

选择原则:
① 只需要「动态取最值」→ 用堆(更简单、更快)。
② 需要「查找 / 删除任意元素 / 前驱后继」→ 用 set / map。
③ 只是偶尔需要删除任意元素 → 优先考虑懒惰删除 + 堆,避免引入平衡树的复杂度和常数。
④ 性能敏感时:堆的常数大约是平衡树的 1/2 ~ 1/3,n 达到 106 时差距明显。

🟡 B组 · 提高训练(4题)

B1编程题

给定一个大小为 n 的无序数组,求其中最大的 k 个数之和。n ≤ 106,k ≤ 100。

查看答案

思路:维护一个大小为 k 的小根堆。遍历数组:堆未满就 push;堆已满且 x > 堆顶 就 pop() 再 push(x)。遍历结束后把堆中所有元素求和。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, k; scanf("%d%d", &n, &k);
    priority_queue<int, vector<int>, greater<int> > pq;   // 小根堆,保留最大的 k 个

    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        if ((int)pq.size() < k) pq.push(x);
        else if (x > pq.top()) { pq.pop(); pq.push(x); }
    }

    long long sum = 0;
    while (!pq.empty()) { sum += pq.top(); pq.pop(); }
    printf("%lld\n", sum);
    return 0;
}

复杂度分析:每个元素最多一次 push、一次 pop,都是 O(log k),总 O(n log k);空间 O(k)。当 k = 100 时 log k ≈ 7,比 O(n log n) 的排序快几倍。
易错:① 求和要用 long long;② 边界情况 k > n(此时全部元素都取)要能正常工作——上面的写法天然支持(堆大小到 n 为止);③ 若 k = 0,直接输出 0。

逐段解析:① L5 声明 priority_queue<int, vector<int>, greater<int>> pq ——大小为 k 的小根堆保留「目前最大的 k 个」,堆顶是这 k 个里最小的(最没希望的那个);② L8-10 主判断:if ((int)pq.size() < k) pq.push(x); 堆没满直接进;else if (x > pq.top()) { pq.pop(); pq.push(x); } 满了且 x 比守门员大,就「替换」——注意是 pop 完再 push,两步 O(log k);③ L13-14 求和:long long sum(k 个 109 相加必超 int),while (!pq.empty()) { sum += pq.top(); pq.pop(); } 逐个弹出累加。易错:②里 x > pq.top() 用严格大于即可——相等时替换不改变答案,还白费两次 O(log k)。

B2编程题

有 n 根不同长度的木板,每次把两根接成一根,消耗的体力等于两段长度之和。求把所有木板接成一根的最小总体力。与合并果子有什么不同?

查看答案

结论:数学上完全相同,都是「每次合并最小的两段」,都是小根堆 + 贪心。区别只在故事背景(一个是「合并」,一个是「切割」的逆过程),代码一字不改。

priority_queue<long long, vector<long long>, greater<long long> > pq;
for (int i = 1; i <= n; i++) { long long x; scanf("%lld", &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);
}
printf("%lld\n", ans);

常用数据验证:输入 4 / 3 5 7 9 → 取 3+5=8(消耗 8),堆 {7,8,9};取 7+8=15(消耗 15),堆 {9,15};取 9+15=24(消耗 24);总计 8+15+24 = 47。

延伸思考:如果是「把一根长木板切成 n 段,每段长度给定,每次切割消耗当前木板的长度」——那正是本题的逆过程。切割时应该从长的开始切(等价于合并时先合并短的)。把问题「反过来看」是竞赛中非常常用的转化技巧。

逐段解析:① L1 堆声明 priority_queue<long long, vector<long long>, greater<long long>> pq; ——与 P1090 唯一的区别是元素类型从 int 换成 long long:两段长度之和可达 2×109 级别,堆元素类型和读入格式 %lld 必须同步升级,只改 ans 不改堆照样溢出;② 读入循环逐个 push;③ 合并循环与 P1090 逐字相同:a = pq.top(); pq.pop(); b = pq.top(); pq.pop(); ans += a + b; pq.push(a + b); ——先取的两个 top 都要先存再弹,pop 不返回值;④ 循环条件 pq.size() > 1:剩一堆即停。易错:若 n=1(只有一根木板)循环一次都不执行,直接输出 0——这是合法边界,不要额外处理错。

B3编程题

给定 n 个数,求「前 i 个数中的最大值 − 前 i 个数中的最小值」随 i 变化的最大值。n ≤ 105。

查看答案

思路:这题不需要堆——只需要维护前缀最大值和前最小值两个变量,一遍扫描即可,O(n)。

int mn = a[1], mx = a[1];
long long ans = 0;
for (int i = 1; i <= n; i++) {
    mn = min(mn, a[i]);
    mx = max(mx, a[i]);
    ans = max(ans, (long long)mx - mn);
}

这道题的价值在于「识别何时不需要堆」。前缀集合只会「变大」,不会「删元素」,所以最值一旦确定就永远确定——用两个变量就够。
什么时候才需要堆?当集合会发生删除(比如「窗口滑动」),或者需要求第 k 小 / 中位数这类不能只用两个变量维护的信息时。堆的价值在于「动态」,静态问题不需要它。

逐段解析:① int mn = a[1], mx = a[1]; 两个变量分别维护「前 i 个数的最小值 / 最大值」;② 循环体三行的顺序有讲究:先 mn = min(mn, a[i]); mx = max(mx, a[i]); 把 a[i] 吸收进来,再 ans = max(ans, (long long)mx - mn); 用更新后的差值刷新答案——本题前缀集合「只增不删」,mn 与 mx 可以来自不同位置,先吸收再算差不会错;③ (long long)mx - mn 的强制转换:a[i] 达到 ±2×109 级别时 int 相减会溢出。易错:若审题看成「任意区间 [l,r] 内的最大差」,两变量法立刻失效,需要单调队列等结构——「前缀」与「区间」一字之差,解法天壤之别。

B4编程题

合并 k 个有序链表/数组为一个有序序列。k ≤ 103,总元素个数 ≤ 105。

查看答案

思路:k 路归并。把每个序列的当前头部元素放进小根堆,每次弹出堆顶(全局最小)输出,再从该序列补上下一个元素。堆里最多同时有 k 个元素。

struct Node { int val, id, idx; };    // 值、来自哪个数组、在该数组中的位置
struct cmp { bool operator()(const Node& a, const Node& b) const { return a.val > b.val; } };
priority_queue<Node, vector<Node>, cmp> pq;      // 小根堆

for (int i = 0; i < k; i++)
    if (!arr[i].empty()) pq.push({arr[i][0], i, 0});

while (!pq.empty()) {
    Node t = pq.top(); pq.pop();
    printf("%d ", t.val);
    if (t.idx + 1 < (int)arr[t.id].size())
        pq.push({arr[t.id][t.idx + 1], t.id, t.idx + 1});
}

复杂度:每个元素进出堆各一次,每次 O(log k),总 O(N log k)(N 为总元素数)。
两种实现方式的取舍:① 「堆里存完整元素」(上面写法)——每次比较要复制结构体,常数略大;② 「堆里只存下标/指针」——比较时通过指针访问原数据,常数更小,是链表版本的首选。
对照:本题就是案例2(P1631 序列合并)的一般化版本——那里 k = n、每个序列长度也是 n,且序列是「虚拟生成」的(A[i] + B[j]),所以只存了下标而没有真的建出 k 个数组。

逐段解析:① struct Node { int val, id, idx; } 三元组:值、来源数组编号、来源数组内下标——id 和 idx 是「补充下一个元素」的导航信息,丢了就无法继续归并;② cmp 返回 a.val > b.val:外部比较器写 > 得小根堆(val 小的先出),与 sort 语义相反;③ 初始化 if (!arr[i].empty()) pq.push({arr[i][0], i, 0}); ——易错:空数组不能入堆,否则后面 arr[t.id][t.idx+1] 会越界;④ 主循环:弹出堆顶输出 t.val,if (t.idx + 1 < (int)arr[t.id].size()) 判断该数组还有没有下一个元素,有就 pq.push({arr[t.id][t.idx + 1], t.id, t.idx + 1});。易错:arr[t.id].size() 是无符号数,与 int 相减或直接比较可能触发无符号陷阱,代码里显式转了 (int)。

🔴 C组 · 综合挑战(4题)

C1综合编程

用两个堆实现「双堆对消」的删除法:一个「插入堆」、一个「删除堆」。请实现 insert(x)、erase(x)、getMax() 三个操作,并说明它与「计数标记法」的优劣。

查看答案
priority_queue<int> addpq;      // 插入堆
priority_queue<int> delpq;      // 删除堆

void insert(int x) { addpq.push(x); }
void erase(int x)  { delpq.push(x); }

int getMax() {
    // 两个堆顶相同 → 这个值被「插入一次 + 删除一次」抵消,同时弹掉
    while (!delpq.empty() && addpq.top() == delpq.top()) {
        addpq.pop();
        delpq.pop();
    }
    return addpq.top();
}

正确性:设当前有效集合的最大值为 M。若 delpq 的堆顶等于 addpq 的堆顶,说明这个值「插过一次、也删过一次」,相互抵消,可以从两个堆里同时移除。循环结束后,若两堆顶不同,则 addpq 的堆顶必然是一个没有被删除过的值——而且它是所有未删除值中最大的(因为比它大的值要么不存在,要么都已抵消)。

关键前提:erase(x) 必须保证 x 当前存在(题目通常这样保证)。否则会「删掉了不存在的值」,导致抵消逻辑错位。

两种删除法的优劣对比:
① 计数标记法:需要一个 del[值] 数组(要求值域较小)或 map。优点是「删除」操作 O(log n) 且不占堆空间;缺点是空间依赖值域,或需要额外 map。
② 双堆对消法:不需要任何标记数组,天然支持大值域。缺点是要维护两个堆,空间最坏是 O(插入次数 + 删除次数);且 getMax() 里的 while 是嵌套在调用中的,必须每次调用都执行。
共同点:两者都是「懒惰删除」思想——真正的清理发生在「需要读答案」的时刻。记住这个共同点,就不会忘记在取值前清理。

逐段解析:① addpq(插入堆)与 delpq(删除堆)同为默认大根堆——方向必须一致才能「堆顶对堆顶」地对消;② insert(x) 一行:进 addpq;erase(x) 一行:进 delpq——删除 O(1),同样只打「欠条」;③ getMax() 的 while (!delpq.empty() && addpq.top() == delpq.top()) { addpq.pop(); delpq.pop(); } 是核心:两堆顶相等 = 这个最大值「插过一次、删过一次」,同时从两边弹掉(一次对消一个,循环继续检查新的堆顶);循环结束时 addpq.top() 就是有效最大值。易错 1:while 条件要先判 !delpq.empty() 再取 delpq.top(),顺序不能反;易错 2:getMax() 在空集时 addpq.top() 未定义,题目若允许查询空集要加 empty() 判断返回 -1。

C2综合编程

有 n 个任务,每个任务有截止时间 d[i] 和收益 p[i](每个任务耗时 1 个单位时间)。同一时间只能做一个任务,求能获得的最大总收益。n ≤ 105。

查看答案

思路(反悔贪心 + 小根堆):按截止时间从小到大排序。遍历任务,先把它「加进来」(push 进小根堆并累加收益);如果堆的大小超过了当前任务的截止时间,说明「时间不够做完这么多任务了」——此时把收益最小的那个任务踢出去(pop 堆顶并减去它的收益)。

#include <bits/stdc++.h>
using namespace std;
struct T { int d, p; } a[100005];

int main() {
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d%d", &a[i].d, &a[i].p);

    sort(a + 1, a + n + 1, [](const T& x, const T& y) { return x.d < y.d; });

    priority_queue<int, vector<int>, greater<int> > pq;   // 小根堆,存已选任务的收益
    long long ans = 0;

    for (int i = 1; i <= n; i++) {
        pq.push(a[i].p);
        ans += a[i].p;
        if ((int)pq.size() > a[i].d) {      // 名额超了
            ans -= pq.top();                     // 踢掉收益最小的
            pq.pop();
        }
    }
    printf("%lld\n", ans);
    return 0;
}

为什么正确?按截止时间排序后,处理到第 i 个任务时,所有截止时间 ≤ a[i].d 的任务都已进入候选池。这些任务的「可用时间槽」只有 a[i].d 个,所以最多只能选 a[i].d 个——选收益最大的那批即可。堆的大小恰好等于「当前已选任务数」,超了就淘汰收益最小的,等价于「保留前 a[i].d 大」。

要点:① 这叫「反悔贪心」——先贪心地全都选上,发现超限就把最差的踢掉,相当于「反悔」;② 复杂度 O(n log n);③ 收益要用 long long;④ 若截止时间可能有重复,排序按 d 升序即可,逻辑不变。

逐段解析:① struct T { int d, p; } 按 x.d < y.d 升序排序——保证处理第 i 个任务时所有「更紧急」的任务都已进堆,从而 pq.size()(已选任务数)与 a[i].d(可用时间槽数)可直接比较;② 循环体前两行 pq.push(a[i].p); ans += a[i].p; ——先贪心地无脑全选,这是「反悔」的前半步;③ if ((int)pq.size() > a[i].d) { ans -= pq.top(); pq.pop(); } ——名额不够就反悔:踢掉已选中收益最小的(小根堆堆顶),并从 ans 里扣回。易错 1:踢一次就够——堆大小每次只 +1,最多超员 1 个;易错 2:ans 全程 long long(Σp 可达 105 × 109)。
验证(GCC 实测):5 个任务 (1,10)(1,8)(1,6)(2,7)(3,1) → 答案 18(选 (1,10)+(2,7)+(3,1),放弃 (1,8) 和 (1,6))。

C3综合编程

设计一个数据结构,支持:插入一个数、删除一个数、查询当前最小值、查询当前最大值、查询当前中位数。所有操作 O(log n)。

查看答案

思路:三堆方案。用对顶堆维护中位数(大根堆 L 存小的一半 + 小根堆 R 存大的一半),再加一个「删除堆」做懒惰删除。因为删除可能发生在任意位置,所以每个堆都要配一个对应的「删除堆」做对消。

priority_queue<int> L, Ldel;                            // 大根堆 + 其删除堆
priority_queue<int, vector<int>, greater<int> > R, Rdel;   // 小根堆 + 其删除堆

// 把某个堆顶与它对应的删除堆顶抵消掉
void clean(priority_queue<int>& q, priority_queue<int>& d) {
    while (!d.empty() && !q.empty() && q.top() == d.top()) { q.pop(); d.pop(); }
}
void clean2(priority_queue<int, vector<int>, greater<int> >& q,
            priority_queue<int, vector<int>, greater<int> >& d) {
    while (!d.empty() && !q.empty() && q.top() == d.top()) { q.pop(); d.pop(); }
}

// 每次操作后先清理,再调整两堆平衡
void rebalance() {
    clean(L, Ldel); clean2(R, Rdel);
    while (L.size() > R.size() + 1) { R.push(L.top()); L.pop(); clean(L, Ldel); }
    while (R.size() > L.size())                 { L.push(R.top()); R.pop(); clean2(R, Rdel); }
}

复杂度与难点:
① 复杂度:每次插入 / 删除后做一次 rebalance,搬运次数 O(1)(每次最多搬一个元素),每次搬运 O(log n),所以总体 O(log n)。top 查询 O(1)。
② 难点 1 · 删除时要「猜」它在哪一堆:先清空两个堆的失效堆顶;若 x ≤ L.top() 就 Ldel.push(x),否则 Rdel.push(x)。这个判断必须在清理之后做,否则 L.top() 可能是已失效的值。
③ 难点 2 · 平衡时的搬运会带来「新的失效元素」:把 L 顶搬到 R 时,那个值就不再属于 L 了,但它可能还在 Ldel 里等着——所以每次搬运后都要立刻清理。
④ 工程结论:这个结构虽然能过题,但维护成本高、容易写错。如果题目允许,用 pbds 里的平衡树或手写 FHQ-Treap 会更简洁——一次插入、一次删除、一次 find_by_order 就搞定全部操作。这也是「能用堆就别上平衡树,但反过来有时也必须认了」的现实。

逐段解析:① 四个堆的声明:L/Ldel 是大根堆对,R/Rdel 是 greater 小根堆对——删除堆必须与主堆同向,堆顶才能「对消」;② clean(q, d) / clean2(q, d):while (!d.empty() && !q.empty() & &q.top() == d.top()) { q.pop(); d.pop(); } 把主堆顶与删除堆顶相等的值成对弹掉(两个版本只是堆类型不同,逻辑相同);③ rebalance() 的顺序固定:先 clean 两堆、再搬移、搬完立刻再 clean——因为把 L 顶搬到 R 后,那个值就不再属于 L 了,Ldel 里如果恰好存着它就会形成「假失效」;④ 删除操作:必须先 clean 再判断 x ≤ L.top() 决定进 Ldel 还是 Rdel——若不先 clean,L.top() 可能是已失效的值,导致 x 被扔进错误的删除堆。易错:每次插入/删除后都要调 rebalance,且 rebalance 内部的两个 while 各自搬一个元素就够(最多失衡 1)。

C4思维拓展

如果要求「在 k 路归并的过程中,还必须支持动态插入新的有序序列」,堆还够用吗?如果要求「查询第 k 小」呢?分别该用什么结构?

查看答案

场景 A · 动态插入新的有序序列 → 堆仍然够用。因为「路数 k」变化只是堆里元素个数的变化,push 一个新的头部元素即可。复杂度不变,仍是每元素 O(log k)。

场景 B · 查询第 k 小 → 堆不够用。堆只知道堆顶(全局最值),无法回答「第 k 小」。可选方案有三种:

方案 1 · 大小为 k 的大根堆(若 k 固定):维护「最小的 k 个」,L.top() 就是第 k 小。O(n log k),空间 O(k)。最简单,优先选它。
方案 2 · 对顶堆推广(若 k 会变化,但每次只变 1):把「两堆容量差 ≤ 1」的约定改成「L 恰好装最小的 k 个」。k 增大就往 L 挪,k 减小就从 L 挪出去。O(log n) 每次调整。
方案 3 · 平衡树 / 值域树状数组(若 k 任意变化且要求在线):pbds 的 tree.find_by_order(k-1),或「离散化 + 树状数组二分」求第 k 小,都是 O(log n)。通用性最强,但实现成本最高。

提炼的决策流程:

① 只要「动态最值」→ 堆。
② 要「固定 k 的第 k 小」→ 大小为 k 的堆。
③ 要「k 缓慢变化的中位数/第 k 小」→ 对顶堆(双堆)。
④ 要「任意 k 的第 k 小 + 动态增删」→ 平衡树 / 树状数组二分。
核心原则:能用堆就不用平衡树;堆解决不了的,先想对顶堆能不能救;都不行才上平衡树。

🏆 洛谷原题精选(12道)

以下 12 道题覆盖堆的全部典型用法(模板、Huffman、多路归并、对顶堆、任务调度、图论堆优化),按难度递增排列。

1
P3378
【模板】堆普及/提高-
2
P1090
[NOIP2004 提高组] 合并果子普及-
3
P1334
瑞瑞的木板(Huffman 变体)普及/提高-
4
P1631
序列合并(多路归并)普及/提高-
5
P1168
中位数(对顶堆)普及/提高-
6
P1801
黑匣子(对顶堆 + 时间戳)普及/提高-
7
P2085
最小函数值(多路归并)普及+/提高
8
P2278
[HNOI2003] 操作系统(任务调度)普及+/提高
9
P1629
邮递员送信(正反两遍 Dijkstra)普及+/提高
10
P4779
【模板】单源最短路径(标准版)普及+/提高
11
P1462
通往奥格瑞玛的道路(二分 + 堆优化)提高+/省选-
12
P2168
[NOI2015] 荷马史诗(k 叉哈夫曼)省选/NOI-