题单 · 习题解析

洛谷 P3378 【模板】堆

★★ 正文那道题去掉最后一问,规模从 2×10⁵ 涨到 **10⁶** —— 所以这一页不重讲算法,只做三件正文做不了的事;★★★ 最值钱的一条:**顶格随机会骗人到「错的选择反而更快」** —— 同样 10⁶ 次操作、同样照题面的值域,三种操作各 1/3 随机时「有序数组」只要 **0.05 秒**(比手写堆的 0.07 还快),换成「前半插入、后半删查交替」就是 **10.94 秒**(搬动的元素数差 **1000.0 倍**,秒表差 219 倍 —— 两把尺子又打架);⇒ 机理两行能证:**三种操作等概率 ⇒ 堆的大小是一条在 0 处反射的随机游走,量级 √n**(实测最大 908 / 平均 237);★★ 题面那半行 `1 ≤ x < 2³¹` ⇒ `int` **恰好够、余量为 0**,而 `0x3f3f3f3f` = 10.6 亿**比一半的合法 x 还小**(和[第 35 章 P1886](/sol/p1886/) 同一个形状);★★★ **值域这一个旋钮把两个 bug 推向相反方向,而顺手写的 `1e9` 那一档两个都抓不到**(它比 0x3f3f3f3f 小 5.8%)—— 本书第一次量到「两头都要、中间是空的」;⚠ 两层触发条件都写不成 ≡(268→41、296→89);⚠ 读入 11.9 MB:默认 cin 280 ms / 关同步 55 / scanf 75 / 快读 16 ⇒ **值 4~5 倍,但不写也过**

原题:洛谷 P3378出自 第 37 章 堆与 priority_queue 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P3378,日期见页头。两边不一致时信原站。

题目描述

给定一个数列,初始为空,请支持下面三种操作:

  1. 给定一个整数 x,请将 x 加入到数列中。
  2. 输出数列中最小的数。
  3. 删除数列中最小的数(如果有多个数最小,只删除 1 个)。

输入格式

第一行是一个整数,表示操作的次数 n

接下来 n 行,每行表示一次操作。每行首先有一个整数 op 表示操作类型。

  • op = 1,则后面有一个整数 x,表示要将 x 加入数列。
  • op = 2,则表示要求输出数列中的最小数。
  • op = 3,则表示删除数列中的最小数。如果有多个数最小,只删除 1 个。

输出格式

对于每个操作 2,输出一行一个整数表示答案。

数据规模与约定

  • 对于 30% 的数据,保证 n ≤ 15
  • 对于 70% 的数据,保证 n ≤ 10⁴
  • 对于 100% 的数据,保证 1 ≤ n ≤ 10⁶1 ≤ x < 2³¹op ∈ {1, 2, 3}

时限 1 秒,内存 512 MB。

输入输出样例

输入

5
1 2
1 5
2
3
2

输出

2
5

★ 五步:插 2、插 5、问最小(2)、删最小、再问(5)。 ⚠ 那句「如果有多个数最小,只删除 1 个」样例里一次都没考到 —— 第 ⑤ 步会说它值多少钱。

1算法本身,本章第 5、6、7 步已经讲完了

p3378.cpp★ 这一版就能 AC(手写堆;顶格最坏形状本机 31 毫秒 / 时限 1 秒)
// P3378【模板】堆 —— ★ 这一版就能 AC(手写小根堆,正文第 5、6 步原样搬过来)
//
// 和正文那道题的差别只有两处,**都不在算法上**:
// ① 没有「最后一行把剩下的倒出来」那一问(正文是自己加的,为的是让 bug 露头);
// ② 规模从 2×10⁵ 涨到 **10⁶**,而时限只有 1 秒 ⇒ 读入那笔账要算(第 ⑥ 步)。
//
// ⚠ 题面写的是 `1 ≤ x < 2^31` —— **恰好是 int 的正数半边,一个值都不剩**:
// int 的上限 2147483647 就是 2^31 − 1,所以 int 够用,**余量为 0**。
// ⇒ 任何「拿一个大常数当哨兵」的写法在这道题上都是错的(第 ④ 步 p3378Inf.cpp)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000006;
int a[MAXN]; // ★ a[1..sz] 才是堆,a[0] 空着不用
int sz = 0;
/** 上浮:比爸爸小就往上(空穴法,每层只有一次赋值) */
void up(int i) {
int x = a[i];
while (i > 1 && a[i >> 1] > x) { a[i] = a[i >> 1]; i >>= 1; }
a[i] = x;
}
/** 下沉:两个儿子里挑**小**的那个换 */
void down(int i) {
int x = a[i];
while ((i << 1) <= sz) {
int c = i << 1;
if (c < sz && a[c + 1] < a[c]) c++; // ⚠ c < sz 才有右儿子
if (a[c] >= x) break;
a[i] = a[c];
i = c;
}
a[i] = x;
}
int main() {
ios::sync_with_stdio(false); // ★ 第 ⑥ 步量过:这一句在这道题上值 3.4 倍
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
string out;
for (int i = 0; i < n; i++) {
int op;
cin >> op;
if (op == 1) {
int x;
cin >> x;
a[++sz] = x;
up(sz);
} else if (op == 2) {
out += to_string(a[1]); // 堆顶就是最小值,O(1)
out += '\n';
} else {
a[1] = a[sz--]; // 拿最后一格填到根上,再沉下去
if (sz) down(1);
}
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p3378Stl.cpp★ 比赛里真正会写的那一版:priority_queue(顶格 23 毫秒)

正文那道题就是这一道多问了一句(最后把剩下的按序倒出来)。 所以这一页不重讲算法,它要做的是另外三件事:

① 把「那我维护一个有序数组不就行了」放到考场规模上量一遍(第 ③ 步)—— ★★★ 而结论会拐个弯:照题面顶格随机撒 10⁶ 个操作,有序数组比正解还快。 ② 把题面那半行 1 ≤ x < 2³¹ 乘一遍(第 ④ 步); ③ 数一数这道题要读多少字节(第 ⑥ 步)。

2第一反应:拿一个变量记着最小值

p3378Var.cpp✗ 「不就是求最小吗」—— 官方样例第二问就死(打出 1061109567)
// ✗ 第一反应:「不就是求最小吗,用一个变量记着就行了」
//
// 插入时 mn = min(mn, x),查询就输出 mn —— 前两问都对。
// ⚠ 到第三个操作(删除最小)就露馅了:**删掉之后 mn 该变成谁?这一版答不上来。**
// (它只能把 mn 重新设回 INF,于是后面的查询全错。)
//
// ★★ 这一版存在的意义是把这道题真正的难点摆出来:
// 难的不是「求最小」,是「**删掉最小之后还要能接着求最小**」——
// 一个变量记不住第二小是谁,而重新扫一遍就是 O(n)。
//
// ⚠ 顺带演示题面那句 `x < 2^31` 的分量:这里的 INF 写的是 0x3f3f3f3f = 1 061 109 567,
// 它**比一半以上的合法 x 还小**(第 ④ 步细说)。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
int mn = INF;
string out;
for (int i = 0; i < n; i++) {
int op;
cin >> op;
if (op == 1) { int x; cin >> x; mn = min(mn, x); }
else if (op == 2) { out += to_string(mn); out += '\n'; }
else mn = INF; // ⚠ 删掉之后只能认输
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

插入时 mn = min(mn, x),查询输出 mn —— 前两个操作它完全正确。 到第三个操作(删除最小)就答不上来了:删掉之后,第二小是谁? 一个变量记不住,而重新扫一遍是 O(n)

★★ 这道题真正的难点,是那个「删」字

「求最小」本身不难(一个变量、一次扫描都行)。 难的是删掉最小之后还要能接着求最小 —— 而且要来 10⁶ 次。

⇒ 这正是本章第 3、4 步那句话的考场版: 一点结构都不攒的话,每次都得重新找一遍。

★ 顺带,它在样例上打出来的那个 1061109567 不是随便一个数 —— 它就是 0x3f3f3f3f,第 ④ 步整节都在说它。

3★★★ 「那我维护一个有序数组」—— 顶格随机上它比正解还快

p3378Sorted.cpp⚠ 答案永远正确 —— 顶格随机 0.05 秒,换个形状 10.94 秒
★★★ 同样是题面顶格 10⁶ 次操作、同样照题面的值域 —— 只换操作的形状
A 机 · WSL2 · 8 核 / 7 G · 2026-08-31 · 独占 · 顶格 n = 10⁶ 堆的最大 / 平均大小 有序数组搬动的元素个数 秒表(时限 1 秒)
三种操作各三分之一,全随机 908 / 237 156 207 595 0.05 秒
前一半全插入,后一半删查交替 500 000 / 312 500 156 186 236 672 10.94 秒 ⇒ 超时 10.9 倍
⇒ 两档之比 551 倍 1000.0 倍 219 倍

★★★ 顺手照题面顶格随机跑一遍,得到的结论是「有序数组 0.05 秒,比手写堆的 0.07 秒还快」。 而机理是能证的,两行:

三种操作等概率 ⇒ 堆的大小是一条在 0 处反射的随机游走, 它的量级是 √n ≈ 1000,不是 n。实测最大 908、平均 237 —— 一次都没大起来过。 ⇒ 有序数组每次只搬两百来个元素,那当然快。

⇒ 这是「顶格 ≠ 最坏」在本书最锋利的一次:前面几轮撞见的都是 「暴力在顶格随机上跑得完」,这一次是错的选择在顶格随机上反而更快

★ 两把尺子又打架了:次数差 1000 倍,秒表只差 219 倍

搬动的元素个数是机器无关的(两次跑逐位相同),它给的比值是 1000.0; 秒表给的是 219。差在哪儿说得清:

一次搬 30 万个元素的 memmove,摊到每个元素上比一次搬 200 个便宜得多 (连续内存、能向量化)。⇒ 次数和秒表量的从来不是同一件事,两个都要报

⚠ 而那个 10.94 秒没有写成断言(一条要跑 11 秒)—— 断言钉的是算出来的搬动数 156 186 236 672。 ★ 「算的和跑的是同一个量」配了自检:档 4 上真跑一遍用树状数组算一遍 必须给出同一个数(p3378Count.cpp 里的 same,两条路一行代码都不共享)。 ⇒ 想复现那 11 秒:./p3378Count full

4⚠ 题面那半行 1 ≤ x < 2³¹:int 恰好够,而且一个哨兵都不剩

★★ 把三个常数摆在一起,这一节就说完了
题面的上限 2³¹ − 1 2 147 483 647
int 的上限 2 147 483 647 ⇒ ★ 恰好够,余量为 0
0x3f3f3f3f(人人都在用的那个 INF) 1 061 109 567 ⇒ ⚠ 比一半的合法 x 还小

⇒ ★★★ 这道题上任何「拿一个大常数当哨兵」的写法都是错的 —— 和第 35 章 P1886 是同一个形状:那道题 aᵢ ∈ [−2³¹, 2³¹)int 的 42.9 亿个值全用光了,这道题用光的是正数那一半。

p3378Inf.cpp✗ 把数组预填成 INF,靠「空位一定比真实值大」省掉边界判断

那个写法本身很聪明:整片数组预填 INF,于是 down()不用再判 sz —— 走到堆外面碰到的一定是 INF,「它比谁都大」,循环自己就停了。 ⚠ 而这道题把「它比谁都大」变成了假话:一个 20 亿的数往下沉时, 会觉得旁边那个 10.6 亿的空位更小,于是把哨兵换上来 —— 堆里凭空多出一个 1061109567

5★★★ 值域这一个旋钮,把两个 bug 推向相反的方向

p3378Set.cpp✗ 拿 set 当堆用 —— 题面特意提醒的那句话被它吃掉了
p3378Max.cpp✗ priority_queue 少写第三个模板参数 ⇒ 大根堆(样例当场挡住:5 2)
★★★ 对拍 300 轮 —— 而这张表最值钱的是「档 1」那一行
300 轮(n 随机 8~20) 有重复的数 x > 10.6 亿 ✗ set 吃重复 ✗ INF 哨兵 ✗ 大根堆 ✗ 一个变量
档 0:值域 1~10(顺手写的小数据) 268 0 41 0 239 152
⚠ 档 1:值域 1~10⁹(顺手写的「大」数据) 0 0 0 0 244 160
★ 档 2:照题面 1 ~ 2³¹−1 0 296 0 89 244 225
★ 档 3:照题面 + 删除密集 0 289 0 111 229 240

★★★ 档 1 是最差的一档:两个 bug 一个都抓不到。 而它恰恰是顺手写生成器时最容易写下的那一行 —— rng() % 1000000000。 理由是一句算术:

10⁹ 比 0x3f3f3f3f = 1 061 109 567 小 5.8% ⇒ 第一层触发条件永远不成立; 而值域一大,十来个数里撞出重复的概率又约等于 0 ⇒ 另一个 bug 的第一层也不成立。

⇒ ★★ 同一个旋钮(值域)把两个 bug 推向相反方向第 23 章 P1049 那条的第七次), 而它们中间那一档谁都抓不到 —— 这是本书第一次量到「两头都要,中间是空的」。

⚠ 两个 bug 的第二层,都不能写成「≡」
第一层(触发条件成立) 真被抓
set 吃重复(档 0) 268 41 6.5 倍
INF 哨兵(档 2) 296 89 3.3 倍

两层之间差的那一截都说得清:重复的数还得真的影响某一次输出或删除x > 10.6 亿的数还得真的在某次 down() 里沉到堆的边界上。 ⇒ 「≡」是量出来的运气,不是一条规律 —— 写不成就老实报两层。

★ 「样例是一测就死的过滤器」这条规律,这一页又成立了

四个错法里样例挡住了两个(大根堆打出 5 2、一个变量打出 2 1061109567), 放过了两个(set / INF 哨兵)。 ⇒ 挡住的两个都是「每一组都错」型,放过的两个都是「偶尔才错」型 —— 而真正让你 WA 在第 7 个点上的,恰恰是后一种。

6⚠ 和算法无关的那一条:这道题要读 12.5 MB

p3378Cin.cpp算法一个字没改,只是把那两句关同步删掉了
★★ 四种读法,量的是绝对毫秒数(时限 1 秒)

输入最大的形状是「n = 10⁶ 行全是插入」:2 × 10⁶ 个整数、12 482 560 字节(11.9 MB)

A 机 · WSL2 · 2026-08-31 · 独占 · 读同一份 11.9 MB 毫秒
默认 cin(同步开着) 280
★ 关同步的 cin 55
scanf 75
手写 fread 快读 16

★★ 结论不是「一定要写快读」 —— 算法那一头只要 30 毫秒, 就算用最慢的默认 cin,端到端也只有 300 毫秒出头,离 1 秒还远。 ⇒ 这道题上读入优化值 4~5 倍,但不写也过得去

⚠ 而这条结论跨题不能搬:第 6 章 P2367 要读 2 × 10⁷ 个数、时限 1 秒, 那道题scanf 都不够倍数跨题几乎不变,分数线画在绝对时间上。 ★ 顺带又一次:关同步的 cinscanf 还快(55 vs 75)。

7度量程序、读入台架和生成器

p3378Count.cpp度量程序(本页除读入表外的数字都出自它;★ ./p3378Count full 复现那 10.94 秒)
p3378Io.cpp读入台架(第 ⑥ 步那张表)
p3378Gen.cpp(六个档位)数据生成器

8一页纸

算法 本章第 5~7 步原样搬过来,这一页不重讲
★ 哪一版能过 手写堆 31 ms、priority_queue 23 ms、multiset 195 ms —— 三种都能过
★★★ 顶格随机会骗人 同样 10⁶ 次操作,只换形状:有序数组 0.05 秒 → 10.94 秒(搬动数差 1000 倍
⇒ 机理能证 三种操作等概率 ⇒ 堆的大小是在 0 处反射的随机游走,量级 √n(实测最大 908)
★★ 题面那半行 1 ≤ x < 2³¹int 恰好够、余量为 00x3f3f3f3f 比一半的 x 还小
★★★ 值域这个旋钮 把两个 bug 推向相反方向,而顺手写的 1e9 那一档两个都抓不到
⚠ 两层触发条件 268 → 41(6.5 倍)、296 → 89(3.3 倍)——都写不成 ≡
⚠ 读入 11.9 MB:默认 cin 280 ms / 关同步 55 / scanf 75 / 快读 16 ⇒ 值 4~5 倍,但不写也过