0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1886,日期见页头。两边不一致时信原站。
题目描述
有一个长为 n 的序列 a,以及一个大小为 k 的窗口。现在这个窗口从左边开始向右滑动,
每次滑动一个单位,求出每次滑动后窗口中的最小值和最大值。
例如,对于序列 [1, 3, -1, -3, 5, 3, 6, 7] 以及 k = 3,有如下过程:
| 窗口位置 | 最小值 | 最大值 |
|---|---|---|
[1 3 -1] -3 5 3 6 7 |
-1 | 3 |
1 [3 -1 -3] 5 3 6 7 |
-3 | 3 |
1 3 [-1 -3 5] 3 6 7 |
-3 | 5 |
1 3 -1 [-3 5 3] 6 7 |
-3 | 5 |
1 3 -1 -3 [5 3 6] 7 |
3 | 6 |
1 3 -1 -3 5 [3 6 7] |
3 | 7 |
输入格式
输入一共有两行,第一行有两个正整数 n, k;第二行有 n 个整数,表示序列 a。
输出格式
输出共两行,第一行为每次窗口滑动的最小值;第二行为每次窗口滑动的最大值。
数据范围
对于 50% 的数据,1 ≤ n ≤ 10⁵;
对于 100% 的数据,1 ≤ k ≤ n ≤ 10⁶,aᵢ ∈ [−2³¹, 2³¹)。
时限 1 秒,内存 500 MB。
输入输出样例
输入
8 3 1 3 -1 -3 5 3 6 7
输出
-1 -3 -3 -3 3 3 3 3 5 5 6 7
⚠ 这组样例里有负数 —— 记住这句话,第 ⑤ 步要用它。
1★ 关键一步:队列里存下标,队尾扔掉「已经没戏的」
// P1886【模板】单调队列 / 滑动窗口 —— ★ 这一版就能 AC//// 两问用同一套骨架,只把比较符号反过来:// 最小值:队尾弹掉「不比 a[i] 小」的 ⇒ 队列里的值从队头到队尾递增,队头就是最小// 最大值:队尾弹掉「不比 a[i] 大」的 ⇒ 队头就是最大//// ★ 队列里存的是**下标**,不是值 —— 因为判「有没有滑出窗口」要拿下标算(本章第 9 步)。//// ⚠ 题面写着 aᵢ ∈ [−2³¹, 2³¹) —— 那正好是 int 的**全部**范围,一点余量都没有。// ⇒ ① 存得下,用 int 就够;② 但**任何自己造的哨兵值都是错的**(本页第 ③ 步)。#include <bits/stdc++.h>using namespace std;
static const int N = 1000006;static int a[N], q[N];
static char ibuf[1 << 16];static int ipos, ilen;static inline char gc() { if (ipos == ilen) { ilen = (int)fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (ilen <= 0) return 0; } return ibuf[ipos++];}static inline int rd() { char c = gc(); while (c && c != '-' && (c < '0' || c > '9')) c = gc(); bool neg = false; if (c == '-') { neg = true; c = gc(); } // ⚠ 这一行不能少,见本页第 ⑤ 步 long long x = 0; while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = gc(); } return (int)(neg ? -x : x); // ★ 用 long long 中转:−2³¹ 的绝对值放不进 int}
static char obuf[1 << 16];static int opos;static inline void flushOut() { fwrite(obuf, 1, opos, stdout); opos = 0; }static inline void pc(char c) { if (opos == (int)sizeof(obuf)) flushOut(); obuf[opos++] = c; }static inline void wr(int x) { if (x < 0) { pc('-'); } unsigned v = x < 0 ? (unsigned)0 - (unsigned)x : (unsigned)x; // ★ 同理,别写 -x char t[12]; int c = 0; if (!v) t[c++] = '0'; while (v) { t[c++] = char('0' + v % 10); v /= 10; } while (c) pc(t[--c]);}
int main() { int n = rd(), k = rd(); for (int i = 1; i <= n; i++) a[i] = rd();
for (int pass = 0; pass < 2; pass++) { int head = 1, tail = 0; for (int i = 1; i <= n; i++) { /* 队尾:把「轮不到了」的弹掉 —— 它比 a[i] 差,还比 a[i] 先出窗 */ while (head <= tail && (pass == 0 ? a[q[tail]] >= a[i] : a[q[tail]] <= a[i])) tail--; q[++tail] = i; /* 队头:滑出窗口的丢掉 —— ★ 判据是下标,不是值 */ if (q[head] <= i - k) head++; if (i >= k) { wr(a[q[head]]); pc(i == n ? '\n' : ' '); } } } flushOut(); return 0;}点「运行 ▶」看结果
新来的 a[i] 一进来,队尾那些「又不比它小、又比它先出窗」的元素就永远轮不到当答案了 ——
当场弹掉。于是队列里的值从队头到队尾递增,队头就是窗口最小值。
求最大值把两个比较符号反过来就行。
★ 队列里存的是下标不是值 —— 因为「有没有滑出窗口」只能拿下标算:
if (q[head] <= i - k) head++; // ★ 判据是下标本章第 9 步那句话在这道题上是逐字生效的:队列里比的是值,窗口边界是拿下标判的。 第 ④ 步就是把这句话写反的后果。
2★★★ 题面那半行 aᵢ ∈ [−2³¹, 2³¹) 是这道题最狠的一句 —— 它把 int 用光了
第 32 章、第 33 章连着两章都在算同一道题:
0x3f3f3f3f 够不够大?—— 那几道题的答案分别是「不够(差 2.02 倍)」「恰好够(余量 6.1%)」
「恰好够(余量 4.0%)」。
这道题连算都不用算:
题面允许的 aᵢ 取值个数 |
[−2³¹, 2³¹) ⇒ 4 294 967 296 个 |
int 一共有几个值 |
4 294 967 296 个 |
| ⇒ 还剩几个能当哨兵 | ★★★ 0 个 |
⇒ 这道题里没有任何一个 int 值可以拿来当「不可能出现的数」。
0x3f3f3f3f、1e9、INT_MAX、-1 —— 一个都不行。
// P1886 · 错法 ①:⚠⚠ **参照物自己错了** —— 暴力里用 0x3f3f3f3f 当哨兵//// int best = 0x3f3f3f3f; for (...) best = min(best, a[j]);//// 这是写暴力时最顺手的一行,前面三十几章也一直这么写。// ⚠ 可这道题的 aᵢ 能取到 int 的**每一个值** —— 只要窗口里的数全都大于// 0x3f3f3f3f = 1 061 109 567,它就会把这个哨兵当成最小值打出来。// ⇒ 本页第 ③ 步:**「INF 该写多大」这道算术题,在这道题上无解**。#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, k; if (!(cin >> n >> k)) return 0; vector<int> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i]; for (int pass = 0; pass < 2; pass++) { for (int i = k; i <= n; i++) { int best = (pass == 0) ? 0x3f3f3f3f : -0x3f3f3f3f; // ✗ 自己造的哨兵 for (int j = i - k + 1; j <= i; j++) best = (pass == 0) ? min(best, a[j]) : max(best, a[j]); cout << best << (i == n ? '\n' : ' '); } } return 0;}点「运行 ▶」看结果
上面那一版不是「题解里的错法」,是你写对拍时最顺手的那份暴力:
int best = 0x3f3f3f3f;
for (int j = i - k + 1; j <= i; j++) best = min(best, a[j]);前面三十几章一直这么写,从来没出过事。可只要某个窗口里的数全都大于 1 061 109 567, 它就会把这个哨兵当成最小值打出来。
| 300 轮 | 档 0 正数 1~20 | 档 1 含负数 | ★★ 档 2 全值域 | 档 3 值域 1~3 |
|---|---|---|---|---|
| 「某个窗口的数全都越过哨兵」 | ★ 0 | ★ 0 | 60 | ★ 0 |
| ⇒ 参照物真的算错了 | ★ 0 | ★ 0 | ★ 60 | ★ 0 |
★★ 触发条件 ≡ 抓获数,四档一个不差(又一次)。
⚠ 而它只在照题面全值域造数据那一档才现形 —— 另外三档是精确的 0。
⇒ ★★★ 这一页因此多出一条一般性的提醒:
顶格不只要顶被测程序的顶,也要顶参照物的顶。
正解(单调队列)根本没有哨兵这回事 —— 只有暴力需要一个初值,于是错的是暴力。
★ 修法是一句话:拿窗口里的第一个数当初值(p1886Brute.cpp 就是这么写的)。
3★★ 同一件事在读和写两侧各来一次:−2³¹ 的绝对值放不进 int
aᵢ 能取到 −2 147 483 648,而 +2 147 483 648 不是一个 int。
所以快读里累加要用 long long、输出取绝对值要走 unsigned:
long long x = 0; … ; return (int)(neg ? -x : x); // 读
unsigned v = x < 0 ? (unsigned)0 - (unsigned)x : (unsigned)x; // 写⇒ 和上一步是同一件事的两个面:题面把 int 用满了,于是每一处「顺手取个负」都要重看一遍。
★ 而正解本身不需要任何额外类型 —— 它只是搬运 a[] 里的数,从不做算术。
4★★ 拿值判出窗:本章第 9 步那句话写反了
// P1886 · 错法 ③:判「有没有滑出窗口」用**值**,不用**下标**//// 队列里存的是下标,可有人顺手存了值,于是出窗只能靠「队头等于刚滑走的那个数」来判:// if (val[head] == a[i - k]) head++;//// ⚠ 它在「窗口里没有重复元素」时看着是对的 —— 而一旦窗口里有两个相等的数,// 刚滑走的那个和留在窗口里的那个长得一模一样,它就会**把还在窗口里的那个弹掉**。// ★ 本章第 9 步专门强调过:**队列里比的是值,窗口边界是拿下标判的** —— 两件事。#include <bits/stdc++.h>using namespace std;
static const int N = 1000006;static int a[N], val[N];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, k; if (!(cin >> n >> k)) return 0; for (int i = 1; i <= n; i++) cin >> a[i];
for (int pass = 0; pass < 2; pass++) { int head = 1, tail = 0; string out; for (int i = 1; i <= n; i++) { while (head <= tail && (pass == 0 ? val[tail] >= a[i] : val[tail] <= a[i])) tail--; val[++tail] = a[i]; if (i > k && head <= tail && val[head] == a[i - k]) head++; // ✗ 拿值判出窗 if (i >= k) { out += to_string(val[head]); out += (i == n ? '\n' : ' '); } } cout << out; } return 0;}点「运行 ▶」看结果
队列里存了值就没法算下标,于是只能这么判出窗:if (val[head] == a[i-k]) head++;。
窗口里一旦有两个相等的数,刚滑走的那个和还留着的那个长得一模一样 ⇒ 它会把留着的那个弹掉。
| 300 轮 | 档 0 | 档 1 含负数 | 档 2 全值域 | ★ 档 3 值域 1~3 |
|---|---|---|---|---|
| 某个窗口内有重复元素(第一层) | 215 | 167 | ★ 0 | 273 |
| ⇒ 真被抓(第二层) | 103 | 60 | ★ 0 | ★ 258 |
| 比 | 2.1 倍 | 2.8 倍 | — | ★ 1.06 倍 |
★ 第一层写得太粗:「窗口里有重复」还不够,得恰好是队头那个值被误判掉才会错。 ★★ 而档 2 那个 0 是结构性的:全值域随机里两个数撞上的概率是 40 亿分之一 —— ⇒ 同一份「照题面顶格」的数据,把上一步那个 bug 逼了出来,却把这一步这个藏得死死的。 ⇒ 又一次「为一个 bug 精心造的档位,正是另一个 bug 的盲区」。
5★★ 快读忘了负号 —— 对拍 0 次,官方样例一测就死
// P1886 · 错法 ④:手写快读**忘了负号**//// 前面三十几章的题,aᵢ 清一色是正数,快读也就一直这么写:// while (c < '0' || c > '9') c = gc(); // 跳过所有非数字 —— 连负号一起跳了// ⇒ 这道题的 aᵢ 可以是负的,`-3` 被读成 `3`,**一声不吭**。//// ⚠⚠ 而顺手写的生成器**只造正数** —— 于是这个 bug 在默认那一档是**精确的 0**// (本页第 ⑤ 步)。算法一个字没错,输入读错了。#include <bits/stdc++.h>using namespace std;
static const int N = 1000006;static int a[N], q[N];
static char ibuf[1 << 16];static int ipos, ilen;static inline char gc() { if (ipos == ilen) { ilen = (int)fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (ilen <= 0) return 0; } return ibuf[ipos++];}static inline int rd() { char c = gc(); while (c && (c < '0' || c > '9')) c = gc(); // ✗ 负号在这里被吃掉了 int x = 0; while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = gc(); } return x;}
int main() { int n = rd(), k = rd(); for (int i = 1; i <= n; i++) a[i] = rd();
for (int pass = 0; pass < 2; pass++) { int head = 1, tail = 0; string out; for (int i = 1; i <= n; i++) { while (head <= tail && (pass == 0 ? a[q[tail]] >= a[i] : a[q[tail]] <= a[i])) tail--; q[++tail] = i; if (q[head] <= i - k) head++; if (i >= k) { out += to_string(a[q[head]]); out += (i == n ? '\n' : ' '); } } fputs(out.c_str(), stdout); } return 0;}点「运行 ▶」看结果
前面三十几章的题,aᵢ 清一色是正数;快读也就一直写成
「跳过所有不是数字的字符」。这道题的 aᵢ 可以是负的,-3 就被读成了 3,一声不吭。
| 300 轮 | 档 0 正数 | ★ 档 1 含负数 | ★ 档 2 全值域 | 档 3 值域 1~3 |
|---|---|---|---|---|
| 输入里真的有负数 | ★ 0 | 300 | 300 | ★ 0 |
| ⇒ 真被抓 | ★ 0 | ★ 300 | ★ 300 | ★ 0 |
★★ 触发条件 ≡ 抓获数,四档一个不差 —— 这一页第二次。
⇒ ★★★ 而这一条最值钱的地方是:官方样例挡住了它,对拍的默认档没有。
那组样例里就摆着 -1 和 -3。
这是「样例可能比对拍还狠」的第二次现场 ——
先跑官方样例,再写生成器;顺手写的生成器带着你上一道题的习惯。
6★★★ 一个看着像 bug、其实完全等价的写法 —— 而队列长 6.8 万倍,秒表只差 1 毫秒
while (... a[q[tail]] > a[i]) tail--; // 这一版:相等的留着
while (... a[q[tail]] >= a[i]) tail--; // 正解:相等的也弹掉
留下来的那个和 a[i] 值相等,而这道题只要「最小值 / 最大值」这个数、不要下标
⇒ 队头是哪一个都打出同样的数。四个档 1200 轮,0 次不一致。
草稿里写着「但队列会长很多,顶格会慢」。队列确实长得离谱,慢却几乎没慢:
顶格 n = 10⁶ / k = n/2 / 值域只有 3 |
正解(弹掉相等) | 保留相等 | 倍数 |
|---|---|---|---|
| 队列平均长度 | 1.83 | 124 962.59 | ★ 68 285 倍 |
| 耗时 | 6 ms | 7 ms | ★ 1.17 倍 |
★★★ 理由一行就能说完:均摊分析和「弹不弹相等的」没有关系 ——
每个下标仍然只进队一次、出队一次,总功仍然是 O(n)。队列长只是「同时待着的多」,
而那个数组本来就得开到 n。⇒ 连内存都不多花。
⇒ ★★ 于是这一页给「两把尺子会打架」添了最极端的一点: 一把尺子差 68 285 倍,另一把差 1.17 倍。 ⚠ 而结论不是「队列长度这把尺子没用」,是它量的根本不是时间 —— 两把尺子都要报,然后说清楚各自量的是什么。
7★★ 第二条正确的路:multiset —— 它做参照物很好,交上去会 TLE
同一个 n = 10⁶,只拧 k(p1886Count.cpp 的 kcurve):
k |
1 | 10 | 1000 | n/2 |
|---|---|---|---|---|
| 单调队列 | 4 ms | 7 ms | 7 ms | 7 ms |
multiset |
17 ms | 52 ms | 102 ms | ★ 743 ms |
★★ multiset 版自己跟自己差 43.7 倍,而单调队列从头到尾没动过 ——
它的每一步都是 O(1),和窗口多大完全无关。
⇒ 这和第 12 章 P1923(堆的大小是 k+1,k=0 最快、k=n/2 自己跟自己差 57 倍)
是同一个形状的第二次:题面写着 1 ≤ k ≤ n,
⇒ 只能按最坏的 k 选写法。
端到端 · 顶格 n = 10⁶ / k = n/2 · 3 次取最小 |
时限 1 秒 |
|---|---|
单调队列(p1886.cpp) |
★ 47 ms |
multiset(p1886Set.cpp) |
⚠ 1757 ms ⇒ TLE |
★ 但它不是没用:两版输出逐字节相同,而它跑得动顶格 —— ⇒ 拿它当顶格对拍的参照物正合适(P1803 那条: 「对拍只能跑小数据」的主语从来不是对拍,是「你把暴力当成了唯一的参照物」)。
8⚠ 边界差一:而漏掉的那 27 轮能数到底
// P1886 · 错法 ②:窗口还没满就开始输出(差一)//// if (i >= k) 写成 if (i >= 1) —— 于是前 k−1 个「窗口」只有一部分元素也被打了出来。// ⇒ 输出会多出 k−1 个数,两行都多。// ★ 这是本章第 10 步那句「边界比模板本身更容易写错」的第一种形态。#include <bits/stdc++.h>using namespace std;
static const int N = 1000006;static int a[N], q[N];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, k; if (!(cin >> n >> k)) return 0; for (int i = 1; i <= n; i++) cin >> a[i];
for (int pass = 0; pass < 2; pass++) { int head = 1, tail = 0; string out; for (int i = 1; i <= n; i++) { while (head <= tail && (pass == 0 ? a[q[tail]] >= a[i] : a[q[tail]] <= a[i])) tail--; q[++tail] = i; if (q[head] <= i - k) head++; out += to_string(a[q[head]]); // ✗ 少了 if (i >= k) out += (i == n ? '\n' : ' '); } cout << out; } return 0;}点「运行 ▶」看结果
if (i >= k) 写丢了,前 k−1 个「半个窗口」也被打了出来。四个档全是 273 / 300。
| 300 轮 | 档 0 | 档 1 | 档 2 | 档 3 |
|---|---|---|---|---|
生成器抽到 k = 1 的轮数 |
27 | 27 | 27 | 27 |
| 「窗口没满就输出」漏掉的轮数 | 27 | 27 | 27 | 27 |
★ 一行就能证:k = 1 时 i >= k 就是 i >= 1,那句判断本来就恒成立
⇒ 两版逐字节相同,是能证的 0。
⇒ 又一次「说清楚它算了什么」 —— 说清楚之后,抓获率是白送的推论。
9★ 对拍这一页
参照物是那份修好了初值的暴力(第 ② 步)。⚠ 而这一页的教训之一就是: 参照物本身先得过一遍题面的顶格。
300 轮(n 随机 8k 随机 1 |
档 0 正数 1~20 | 档 1 含负数 | ★★ 档 2 全值域 | ★ 档 3 值域 1~3 |
|---|---|---|---|---|
| 单调队列(正解) | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
multiset |
★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 队尾保留相等 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| ⚠ 暴力用 0x3f3f3f3f 当初值 | ★ 0 | ★ 0 | 60 | ★ 0 |
| 窗口没满就输出 | 273 | 273 | 273 | 273 |
| 拿值判出窗 | 103 | 60 | ★ 0 | 258 |
| 快读忘了负号 | ★ 0 | 300 | 300 | ★ 0 |
| 错法 | 抓获率(最狠的那一档) | 官方样例挡住了吗 |
|---|---|---|
| 窗口没满就输出 | 273 / 300 | ★ 挡住(每行多出 2 个数) |
| 快读忘了负号 | 300 / 300 | ★ 挡住(样例里有 -1 和 -3) |
| 拿值判出窗 | 258 / 300 | ⚠ 放过 —— 样例的每个窗口里都没有重复元素 |
暴力用 0x3f3f3f3f |
60 / 300 | ⚠ 放过 —— 样例里最大的数是 7,够不着哨兵 |
⇒ 两个「放过」都是结构上问不出这个问题,而不是概率低。 ★ 而「快读忘了负号」是对拍的默认档抓不到、官方样例一测就死 —— 两道防线各有各的盲区,缺一不可。
10度量程序和生成器
11一页纸
| ★★ 关键的一步 | 队列存下标,队尾扔掉「又不更优、又更早出窗」的;两问只差比较符号 |
| ★★★ 题面把 int 用光了 | [−2³¹, 2³¹) 占掉全部 4 294 967 296 个值 ⇒ 一个哨兵都不剩 |
| ★★★ 中招的是参照物 | 顺手写的暴力 best = 0x3f3f3f3f 在全值域档错 60 / 300(触发 ≡ 抓获) |
| ⇒ 一条新提醒 | 顶格不只要顶被测程序的顶,也要顶参照物的顶 |
| ★★ 样例比对拍还狠 | 「快读忘了负号」对拍默认档 0 / 300,而官方样例里就摆着 -1 |
| ★★★ 草稿被打回 | 「保留相等会慢」错了 —— 队列长 68 285 倍,秒表只差 1.17 倍(均摊和它无关) |
★★ 谁更快的主语是 k |
multiset 自己跟自己差 43.7 倍(17 → 743 ms),单调队列纹丝不动(4 → 7) |
⇒ 而 k 是输入给的 |
顶格端到端 47 ms vs 1757 ms ⇒ multiset 只配当参照物 |
| ★ 边界差一 | 273 / 300,⚠ 漏掉的 27 轮 ≡ k = 1 的 27 轮(那句判断恒成立,能证) |
| ★ 拿值判出窗 | 两层:窗口内有重复 215 / 真被抓 103;⚠ 全值域那一档是结构性的 0 |