题单 · 习题解析

洛谷 P1801 黑匣子

★★ 关键的一步:**两个堆背靠背,中间卡着第 k 小**(大根堆 L 装最小的 k 个 / 小根堆 R 装其余)—— 而这道题非要两个堆不可,因为 `k` 每次 GET 都要长大一个,**得知道「第 k+1 小是谁」**;★★ 「去掉一个 log」在这道题上不是锦上添花:30% 那一档排序 **3.73 秒**、nth_element **0.27 秒**,而时限 **0.5 秒** ⇒ **正好是 0 分和 30 分的分界线**(机器无关的尺子 Σu[i]:1万/2万/4万 = 1.0/4.0/16.0 亿,翻倍就翻四倍);⚠ 题面那张 11 行的表里 `u = [1,2,6,6]` 有**两个 6** ⇒ 那句必须是 `while`(样例一测就死,297~300/300);★★★ 而这一页最值钱的是**一个对照档同时把三个 0 交代清楚**:档 1 / 档 2 里所有 ADD 都发生在第一次 GET 之前 ⇒ `x < L.top()` 那个分支**一次都没执行**,而三个待测的写法全写在那个分支里;★★ 「前提成立 ≡ 被抓」四个档一个不差(0/0/180/17);⚠ 命门是「u 单调不降」—— 违反之后**对顶堆自己错 287/300**(堆只能加不能减);★ int 余量 **7.4%**

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

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

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

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

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

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

题目描述

Black Box 是一种原始的数据库。它可以储存一个整数数组,还有一个特别的变量 i。 最开始的时候 Black Box 是空的,而 i = 0。这个 Black Box 要处理一串命令。

命令只有两种:

  • ADD(x):把 x 元素放进 Black Box;
  • GETi1,然后输出 Black Box 中第 i 小的数。

记住:第 i 小的数,就是 Black Box 里的数的按从小到大的顺序排序后的第 i 个元素。

我们来演示一下一个有 11 个命令的命令串。(如下表所示)

序号 操作 i 数据库 输出
1 ADD(3) 0 3 /
2 GET 1 3 3
3 ADD(1) 1 1,3 /
4 GET 2 1,3 3
5 ADD(-4) 2 -4,1,3 /
6 ADD(2) 2 -4,1,2,3 /
7 ADD(8) 2 -4,1,2,3,8 /
8 ADD(-1000) 2 -1000,-4,1,2,3,8 /
9 GET 3 -1000,-4,1,2,3,8 1
10 GET 4 -1000,-4,1,2,3,8 2
11 ADD(2) 4 -1000,-4,1,2,2,3,8 /

现在要求找出对于给定的命令串的最好的处理方法。ADD 命令共有 m 个,GET 命令共有 n 个。 现在用两个整数数组来表示命令串:

  1. a₁, a₂, …, a_m:一串将要被放进 Black Box 的元素。例如上面的例子中 a = [3,1,-4,2,8,-1000,2]
  2. u₁, u₂, …, u_n:表示第 u_i 个元素被放进了 Black Box 里后就出现一个 GET 命令。 例如上面的例子中 u = [1,2,6,6]。输入数据不用判错。

输入格式

第一行两个整数 mn,表示元素的个数和 GET 命令的个数。

第二行共 m 个整数,从左至右第 i 个整数为 aᵢ,用空格隔开。

第三行共 n 个整数,从左至右第 i 个整数为 uᵢ,用空格隔开。

输出格式

输出 Black Box 根据命令串所得出的输出串,一个数字一行。

数据规模与约定

  • 对于 30% 的数据,1 ≤ n, m ≤ 10⁴
  • 对于 50% 的数据,1 ≤ n, m ≤ 10⁵
  • 对于 100% 的数据,1 ≤ n, m ≤ 2 × 10⁵|aᵢ| ≤ 2 × 10⁹保证 u 序列单调不降

时限 0.5 秒,内存 500 MB。

输入输出样例

输入

7 4
3 1 -4 2 8 -1000 2
1 2 6 6

输出

3
3
1
2

★ 这组样例就是上面那张 11 行表:u = [1, 2, 6, 6] —— ⚠ 最后两个 u 是一样的(第 6 个元素放进去之后连着来了两次 GET)。 第 ④ 步会说,这两个 6 值多少钱。

1第一反应:每次 GET 就把前面那一段排一遍序

p1801Sort.cpp答案永远对 —— 而 30% 那一档要 3.73 秒 / 时限 0.5 秒
// 第一反应:每来一次 GET,就把前 u[i] 个数**排一遍序**,取第 i 个
//
// 答案完全正确,而且好写。它的问题只有一个字:**慢**。
// 每次 GET 都排一遍 ⇒ O(nm log m)。
// 题面顶格 n = m = 2×10^5 ⇒ 想都别想。
//
// ★ 那它能不能靠题面那句「对于 30% 的数据,n, m ≤ 10^4」拿 30 分?——**不能**:
// 本机实测 m = n = 10^4 要 **3.73 秒**,而时限只有 **0.5 秒**(第 ② 步那张表)。
// ⚠ 而只要把 sort 换成 nth_element,同一档就是 **0.27 秒** —— 那 30 分就到手了。
// ⇒ **「去掉一个 log」在这道题上不是锦上添花,它正好压在 0 分和 30 分的分界线上。**
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m, n;
if (!(cin >> m >> n)) return 0;
vector<int> a(m + 1), u(n + 1);
for (int i = 1; i <= m; i++) cin >> a[i];
for (int i = 1; i <= n; i++) cin >> u[i];
string out;
for (int i = 1; i <= n; i++) {
vector<int> v(a.begin() + 1, a.begin() + 1 + u[i]);
sort(v.begin(), v.end()); // ⚠ 每次都从头排一遍
out += to_string(v[i - 1]);
out += '\n';
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

思路直白:第 iGET 要第 i 小 ⇒ 把前 u[i] 个数抄出来排序,取第 i − 1 个下标。 O(nm log m),顶格 n = m = 2 × 10⁵ 想都别想。

2★ 顺着往下改一步:排序换成 nth_element —— 而这一步正好压在分数线上

p1801Nth.cpp去掉那个 log(第 12 章的快速选择)—— 30% 那一档 0.27 秒,够得着
★★ 「去掉一个 log 只值十几倍」听着不值钱 —— 这道题上它是 0 分和 30 分的差别
A 机 · WSL2 · 8 核 / 7 G · 2026-08-31 · 独占 · m = n 随机 每次排一遍 每次 nth_element ★ 对顶堆
10⁴(题面 30% 那一档) 3.73 秒 0.27 秒 0.01 秒
2 × 10⁴ 15.97 秒 1.92 秒 0.01 秒
4 × 10⁴ 超过 40 秒 9.10 秒 0.01 秒
2 × 10⁵(顶格) —— —— 0.03 秒

时限是 0.5 秒。 ⇒ 排序那版连 30 分都拿不到,换成 nth_element 就拿到了(余量 1.9 倍)。

★ 而这两版慢在同一件事上,那件事有一把机器无关的尺子:它们要碰多少个元素(Σu[i])。

m = n 碰到的元素个数 比上一档
10 000 99 990 060
20 000 399 960 217 4.00 倍
40 000 1 599 945 820 4.00 倍

规模翻一倍、工作量翻四倍 —— 这就是 O(N²) 的签名(P5019 那把尺子)。 外推到顶格 2 × 10⁵4 × 10¹⁰;而对顶堆在顶格只做 n + m = 400 000 次堆操作。

3★ 正解:两个堆背靠背,中间卡着第 k 小

p1801.cpp★ 这一版就能 AC(对顶堆,顶格 0.03 秒 / 时限 0.5 秒)
// P1801 黑匣子 —— ★ 这一版就能 AC:**对顶堆**(两个堆背靠背卡住第 k 小)
//
// ============ 一句话 ============
// 左边一个**大根堆** L:装着「到现在为止最小的 k 个」,堆顶是这 k 个里最大的那个
// —— 也就是**第 k 小**,`O(1)` 就能报出来;
// 右边一个**小根堆** R:装着剩下的全部,堆顶是「第 k+1 小」。
// 两个堆顶背靠背卡着一条缝,那条缝就是答案的位置。
//
// ============ 两个操作各干什么 ============
// ADD(x):x 该进哪边?—— 和 L 的堆顶比一下。
// 比它小 ⇒ x 挤进了「最小的 k 个」,那就把 L 的堆顶(原来的第 k 小)挤到 R 去;
// 否则 ⇒ 直接扔进 R。
// GET :k 要 +1 ⇒ L 得长大一个 ⇒ 把 R 的堆顶(第 k+1 小)搬过来,然后输出 L 的堆顶。
//
// ⇒ 每个元素一辈子只在两个堆之间挪常数次,总共 O((n + m) log m)。
//
// ★ 这就是[本章第 12 步](/ch/37-heap/)那招「大小为 k 的堆」的**在线版**:
// 那儿的 k 是固定的、数据是一趟流过;这儿的 k 每次 GET 都要长大一个,
// 所以光有 L 不够 —— **得有 R 帮它记住「下一个该轮到谁」**。
//
// ⚠ 题面 |aᵢ| ≤ 2×10⁹,而 int 的上限是 2 147 483 647 ⇒ **够,余量只有 7.4%**。
// ⚠ u 序列单调不降是**命门**(第 ⑤ 步量过):它保证 ADD 只往前走,从不回头。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m, n;
if (!(cin >> m >> n)) return 0;
vector<int> a(m + 1), u(n + 1);
for (int i = 1; i <= m; i++) cin >> a[i];
for (int i = 1; i <= n; i++) cin >> u[i];
priority_queue<int> L; // 大根堆:最小的 k 个
priority_queue<int, vector<int>, greater<int>> R; // 小根堆:其余的
string out;
int p = 1; // 已经 ADD 到第 p−1 个
for (int i = 1; i <= n; i++) {
while (p <= u[i]) { // ⚠ 必须是 while:同一个 u 可能来好几次 GET
int x = a[p++];
if (!L.empty() && x < L.top()) { // x 挤进了「最小的 k 个」
R.push(L.top());
L.pop();
L.push(x);
} else {
R.push(x);
}
}
L.push(R.top()); // k 长大一个:把第 k+1 小搬过来
R.pop();
out += to_string(L.top()); // ★ 大根堆的堆顶就是第 k 小
out += '\n';
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 为什么非要两个堆 —— 一个堆答不了这道题

本章第 12 步那招「维护一个大小为 k 的大根堆」,堆顶就是第 k 小。 可那道题的 k 是固定的,而这道题每次 GET 都要把 k 加一。

k 长大一个 ⇒ 得往 L 里补一个人。补谁?——「第 k+1 小」。 而这个人此刻正躺在「没被选中的那一堆」里 —— 一个堆记不住他是谁, ⇒ 必须再有一个小根堆 R 把剩下的人管起来,它的堆顶就是「下一个该轮到谁」。

★ 于是两个堆顶背靠背卡着一条缝,那条缝就是答案的位置: L 的堆顶 = 第 k 小,R 的堆顶 = 第 k+1 小。 每个元素一辈子只在两边挪常数次 ⇒ O((n + m) log m)

4⚠ 题面那张 11 行的表里藏着一件事:u 可以相等

p1801If.cpp✗ 那句 while 写成了 if —— 一次 ADD 之后只处理一个 GET
// ✗ 错法一:把那句 while 写成了 if —— **一次 ADD 之后只处理一个 GET**
//
// 为什么会这么写:脑子里的模型是「一个 ADD 配一个 GET」,
// 而题面第 11 行那张表里 **第 9、10 两行是连着两个 GET**(u = [1, 2, 6, 6],两个 6)。
//
// ⚠ 一旦 u 里出现相等的相邻项,这一版就少推进了 —— 它把「还没 ADD 够」的状态当成了 ADD 够了。
// ★ 官方样例正好考到了这件事(`1 2 6 6`),**一测就死**。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m, n;
if (!(cin >> m >> n)) return 0;
vector<int> a(m + 1), u(n + 1);
for (int i = 1; i <= m; i++) cin >> a[i];
for (int i = 1; i <= n; i++) cin >> u[i];
priority_queue<int> L;
priority_queue<int, vector<int>, greater<int>> R;
string out;
int p = 1;
for (int i = 1; i <= n; i++) {
if (p <= u[i]) { // ⚠ 这里本该是 while
int x = a[p++];
if (!L.empty() && x < L.top()) { R.push(L.top()); L.pop(); L.push(x); }
else R.push(x);
}
if (R.empty()) { out += "?\n"; continue; } // ★ 演示用的出口:不给它死循环 / 崩溃的机会
L.push(R.top());
R.pop();
out += to_string(L.top());
out += '\n';
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

脑子里最顺的模型是「一个 ADD 配一个 GET」,写出来就是 if。 ⚠ 而题面那张表的第 9、10 两行连着两个 GETu = [1, 2, 6, 6])—— u 里相邻两项相等,就是「同一个位置连着问好几次」。

★ 官方样例正好考到了这件事:这一版打出 3 3 3 3(正解是 3 3 1 2),一测就死。 ⇒ 而它在对拍里是 297 ~ 300 / 300,五个档全在这个量级 —— 又一次 「样例挡住的都是每组都错的那种」

5★★★ 一个对照档同时把两个 bug 变成精确的 0 —— 而且是同一个原因

p1801Bal.cpp✗ 新人挤进 L 时,忘了把 L 的堆顶挤到 R 去
p1801Le.cpp⚠ 「相等的往哪边放」—— 看着像 bug,五个档 1500 轮一次都没错
★★★ 对拍 300 轮 × 五个档
300 轮 ★ 前提:x < L.top() 那个分支执行过 ✗ 忘了挤出去 ⚠ 相等挤进 L ✗ 方向反过来 ✗ while 写成 if ✗ 第一行读反
档 0:顺手(值域 −10~10) 178 176 0 252 300 255
★ 档 1:u 大量相等 0 0 0 0 297 255
★ 档 2:GET 全挤在最后 0 0 0 0 300 255
档 3:值域顶格 2 × 10⁹ 180 180 0 261 300 255
⚠ 档 4:故意违反题面 17 17 0 0 298 255

★★★ 档 1、档 2 那一竖列的三个 0,是同一个原因,而且一句话能说完:

这两档里所有 ADD 都发生在第一次 GET 之前 ⇒ 那时候 L 还是空的 ⇒ x < L.top() 那个分支一次都没执行过。 而「忘了挤出去」「相等往哪边放」「方向反过来」这三件事全都写在那个分支里

⇒ 一个对照档同时给三个 0 交了代(第 33 章 P1266 那条的第二次现场)。 ★★ 而「前提成立的轮数 ≡ 被抓的轮数」在四个档上一个不差(0 / 0 / 180 / 17), 只有档 0 差 2 轮(那两轮分支执行了,但没轮到影响输出)。

⚠ 至于 p1801Le.cpp 那一列的五个 0 —— 它不是 bug,两行能证:

x == L.top() 时,这一版把 L.top() 挤到 R、再把 x 放进 L。 两个值相等 ⇒ LR 这两个多重集一个元素都没变,只是换了个副本。

★ 而这个「精确的 0」配了自检:把同一处比较的方向反过来(p1801Gt.cpp), 档 0 / 档 3 当场被抓 252 / 261 —— 说明那批数据不是问不出这个问题。

6⚠ 那句「保证 u 序列单调不降」是命门,不是噪声

★★ 造一档违反它 —— 塌掉的不是数据,是算法本身
300 轮 · 档 4(u 从大到小,其余照题面)
u 真的往回退的轮数 298 / 300
⇒ ★ 对顶堆自己算错(和「每次重新扫一遍」不一致) 287 / 300

⇒ 对顶堆靠的是「ADD 只往前走,从不回头」:p 这个指针一路推进, 推过去的元素再也拿不回来u 一旦往回退,就等于要求它「把刚才那几个 ADD 撤销」—— 而堆只能加不能减(和第 36 章那句「并查集只能合并不能拆」同一个形状)。

★★ 而这一档同时干了第二件事:它是「相等往哪边放那个 0」的又一个对照 —— 在这么离谱的数据上 p1801Le.cpp 仍然是 0,因为它压根不依赖 u 的单调性。

★ 顺手把 int 那笔账乘一遍

题面 |aᵢ| ≤ 2 × 10⁹int 的上限是 2 147 483 647够,余量只有 7.4%。 ⇒ 和隔壁 P3378 正好凑一对:那道题 x < 2³¹余量为 0。 ⚠ 两道题都够用,但都不剩下哪怕一个能当哨兵的值 —— 所以这两页的正解里,一个 0x3f3f3f3f 都没有。

7度量程序和生成器

p1801Count.cpp度量程序(本页除耗时表外的数字都出自它)
p1801Gt.cpp✗ 自检用的:把那处比较的方向整个反过来
p1801Order.cpp✗ 第一行读成了 n m(样例挡住)
p1801Gen.cpp(六个档位)数据生成器

8一页纸

★ 哪一版能过 对顶堆(顶格 0.03 秒 / 时限 0.5 秒);nth_element 只够 30 分,排序 0 分
★★ 为什么一个堆不够 k 每次要长大一个 ⇒ 得知道「第 k+1 小是谁」⇒ R 就是干这个的
★★ 去掉一个 log 值多少 30% 那一档 3.73 秒 → 0.27 秒(时限 0.5)⇒ 0 分和 30 分的差别
★ 机器无关的尺子 Σu[i]:1万 / 2万 / 4万 = 1.0 / 4.0 / 16.0 亿 ⇒ 翻倍就翻四倍O(N²) 的签名
⚠ 题面那张表 u = [1,2,6,6] 里那两个 6 ⇒ 必须是 while(样例一测就死)
★★★ 三个 0 一个原因 档 1 / 档 2 里所有 ADD 都在第一次 GET 之前 ⇒ 那个分支一次没执行
★★ 触发 ≡ 抓获 178/0/0/180/17 vs 176/0/0/180/17 —— 四个档一个不差,档 0 差 2 轮
⚠ 命门 u 单调不降」违反之后对顶堆自己错 287/300 —— 堆只能加不能减
★ int 的账 |aᵢ| ≤ 2×10⁹ ⇒ 余量 7.4%(隔壁 P33780