0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1801,日期见页头。两边不一致时信原站。
题目描述
Black Box 是一种原始的数据库。它可以储存一个整数数组,还有一个特别的变量 i。
最开始的时候 Black Box 是空的,而 i = 0。这个 Black Box 要处理一串命令。
命令只有两种:
ADD(x):把x元素放进 Black Box;GET:i加1,然后输出 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 个。
现在用两个整数数组来表示命令串:
a₁, a₂, …, a_m:一串将要被放进 Black Box 的元素。例如上面的例子中a = [3,1,-4,2,8,-1000,2]。u₁, u₂, …, u_n:表示第u_i个元素被放进了 Black Box 里后就出现一个GET命令。 例如上面的例子中u = [1,2,6,6]。输入数据不用判错。
输入格式
第一行两个整数 m 和 n,表示元素的个数和 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 就把前面那一段排一遍序
// 第一反应:每来一次 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;}点「运行 ▶」看结果
思路直白:第 i 次 GET 要第 i 小 ⇒ 把前 u[i] 个数抄出来排序,取第 i − 1 个下标。
O(nm log m),顶格 n = m = 2 × 10⁵ 想都别想。
2★ 顺着往下改一步:排序换成 nth_element —— 而这一步正好压在分数线上
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 黑匣子 —— ★ 这一版就能 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;}点「运行 ▶」看结果
本章第 12 步那招「维护一个大小为 k 的大根堆」,堆顶就是第 k 小。
可那道题的 k 是固定的,而这道题每次 GET 都要把 k 加一。
k长大一个 ⇒ 得往L里补一个人。补谁?——「第 k+1 小」。 而这个人此刻正躺在「没被选中的那一堆」里 —— 一个堆记不住他是谁, ⇒ 必须再有一个小根堆 R 把剩下的人管起来,它的堆顶就是「下一个该轮到谁」。
★ 于是两个堆顶背靠背卡着一条缝,那条缝就是答案的位置:
L 的堆顶 = 第 k 小,R 的堆顶 = 第 k+1 小。
每个元素一辈子只在两边挪常数次 ⇒ O((n + m) log m)。
4⚠ 题面那张 11 行的表里藏着一件事:u 可以相等
// ✗ 错法一:把那句 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;}点「运行 ▶」看结果
脑子里最顺的模型是「一个 ADD 配一个 GET」,写出来就是 if。
⚠ 而题面那张表的第 9、10 两行连着两个 GET(u = [1, 2, 6, 6])——
u 里相邻两项相等,就是「同一个位置连着问好几次」。
★ 官方样例正好考到了这件事:这一版打出 3 3 3 3(正解是 3 3 1 2),一测就死。
⇒ 而它在对拍里是 297 ~ 300 / 300,五个档全在这个量级 —— 又一次
「样例挡住的都是每组都错的那种」。
5★★★ 一个对照档同时把两个 bug 变成精确的 0 —— 而且是同一个原因
| 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。 两个值相等 ⇒L和R这两个多重集一个元素都没变,只是换了个副本。
★ 而这个「精确的 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 的单调性。
题面 |aᵢ| ≤ 2 × 10⁹,int 的上限是 2 147 483 647 ⇒ 够,余量只有 7.4%。
⇒ 和隔壁 P3378 正好凑一对:那道题 x < 2³¹ ⇒ 余量为 0。
⚠ 两道题都够用,但都不剩下哪怕一个能当哨兵的值 ——
所以这两页的正解里,一个 0x3f3f3f3f 都没有。
7度量程序和生成器
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%(隔壁 P3378 是 0) |