第 34 章结尾白纸黑字写了两件事:
★ 关键一步是均摊分析:每个元素进出各一次,所以那个「看起来有两层循环」的东西其实是
O(n)—— 接的是第 7 章双指针那一段。 ⚠ 还有一笔欠了很久的账:第 24 章说过「多重背包还能做到O(nW)(单调队列优化), 等第 35 章讲完单调队列再回来收尾」。
第 6 步还①(而且第 7 步把它做成了画面),第 11 步还②。
⚠ 这一章有两道新题:柱状图最大矩形(单调栈)、滑动窗口最值(单调队列)。 它们看着毫无关系,其实是同一句话的两个说法 —— 第 9 步会把这句话挑明。
★ 第 11 步是第三块,但它不是第三道新题:题目是第 24 章那道多重背包, 这里换的只是解法(把转移式子写开,它就是刚学的滑动窗口最大值)。 ⇒ 这一章因此比别的章长,别一口气读。天然的断点有三个: 第 6 步(单调栈讲完)、第 9 步(两道题合成一句话)、第 11 步(还第 24 章那笔账)。 读到第 11 步却想不起多重背包长什么样,回第 24 章比硬看有用得多。
1一句话问题
有 n 根紧挨着的柱子,宽度都是 1,第 i 根高
h[i](0 ≤ h[i] ≤ 10⁹,n ≤ 2×10⁵)。 在这个柱状图里能勾出的最大矩形面积是多少?矩形必须由连续的若干根柱子构成,高度取其中最矮的那根(不能悬空、不能超出柱子)。
所以一个矩形只要说清两件事:从哪根到哪根(宽),这段里最矮的是多少(高)。 反过来也成立:每一个「最大矩形」,都可以说成「以某一根柱子的高度为高」的那一个 —— 因为最矮的那根就在里面,把高再抬一点点就会露出空隙。
★ 这句话是全章的入口:与其枚举「哪一段」,不如对每一根柱子问一句 「以我这个高度为高,最宽能铺到哪儿?」
⚠ 面积最大可到 2×10⁵ × 10⁹ = 2×10¹⁴,必须 long long。
这件事对拍永远查不出来(第 8 步有现场)。
2手算一遍:默认那张图
8
2 1 5 0 3 5 5 6
↑ ★ 一根高 0 的柱子(题面允许),它把柱状图切成了互不相干的两段
↑↑ ★ 两根一样高的柱子(并列)
↑ ★ 结尾这一段是递增的 —— 记住这个特征,第 8 步有一个 bug 专门死在这儿一根一根问「以我为高,能铺多宽」:
| 柱子 | 高 | 往左能到 | 往右能到 | 宽 | 面积 |
|---|---|---|---|---|---|
| 1 | 2 | 1 | 1 | 1 | 2 |
| 2 | 1 | 1 | 3 | 3 | 3 |
| 3 | 5 | 3 | 3 | 1 | 5 |
| 4 | 0 | 1 | 8 | 8 | 0 |
| 5 | 3 | 5 | 8 | 4 | 12 |
| 6 | 5 | 6 | 8 | 3 | 15 |
| 7 | 5 | 6 | 8 | 3 | 15 |
| 8 | 6 | 8 | 8 | 1 | 6 |
答案 15(第 6~8 根,高 5 宽 3)。
★ 请留意第 4 行那根 0 高柱:它能铺满整整 8 格,可高是 0,面积还是 0 —— 它唯一的作用是把左右两段隔开(左边最好的是 5,右边最好的是 15)。 ⚠ 顺带解释一件事:正解末尾要放一根「比谁都矮」的哨兵,写的是 −1 而不是 0 —— 因为题面允许 h = 0,写 −1 才严格比所有柱子矮,不用多想一步。
3标准答案:把定义直接翻译成代码
// 柱状图里最大的矩形 —— 标准答案:枚举所有区间 O(n²)//// 这份代码为什么存在:它是对拍的**标准答案**,所以第一要务不是快,是**思路和正解完全不同**。//// 正解(fast.cpp)想的是「对每一根柱子,它能往左右扩到哪里」;// 这一份想的是「**每一段连续的柱子**都试一遍」:// 一段 [l, r] 能勾出的矩形,高是这一段里**最矮的那根**,宽是 r-l+1。// 把所有 O(n²) 段都试一遍,取最大。//// ★ 注意 `mn` 是**边扫边更新**的(min 是可以增量维护的),所以是 O(n²) 而不是 O(n³)。//// ⚠ 更要紧的是:这两层循环里**一次 break 都没有**,n² 就老老实实是 n²。// (第 25、30、31 章那条「要证明暴力慢,先确认它真的走到底了」——// 这一章还有一份 expand.cpp 专门演示反面:那份带 break 的暴力在随机数据上快得像 O(n)。)//// 输入:第一行 n;第二行 n 个整数 h[1..n](0 ≤ h[i] ≤ 10⁹)// 输出:一个整数 —— 最大矩形面积//// ⚠ 面积最大可到 n × max(h) = 2×10⁵ × 10⁹,**必须 long long**(int 会溢出)。// 而这件事**对拍在小数据上永远查不出来** —— 见 wrongInt.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 1); for (int i = 1; i <= n; i++) cin >> h[i];
long long ans = 0; for (int l = 1; l <= n; l++) { long long mn = LLONG_MAX; for (int r = l; r <= n; r++) { mn = min(mn, h[r]); // 这一段里最矮的那根 ans = max(ans, mn * (r - l + 1)); // 高 × 宽 } } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
正解想的是「对每根柱子,左右第一个更矮的在哪」。 要是标准答案也这么想,两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 9、15、34 章那条)。
所以这一份换个思路:每一段连续的柱子都试一遍,高取这段里最矮的。
mn 边扫边更新,所以是 O(n²) 而不是 O(n³)。
★ 更要紧的是:它两层循环里一次 break 都没有 —— n² 就老老实实是 n²。 下一步你会看到,这件事一点都不多余。
4★ 另一份「更聪明」的暴力,以及它怎么假装自己不慢
// 柱状图 —— 另一份「看起来更聪明」的 O(n²) 暴力:以每根柱子为高,往左右扩//// 这份代码为什么存在:★ 它是本教材第四次演示「**暴力会假装自己不慢**」,// 而且这一次那个旋钮既不是规模、也不是密度,是**数据的形状**。//// 它的思路和正解(fast.cpp)其实一模一样 ——「对每根柱子求左右第一个更矮的」——// 只是老老实实地一根一根往外挪://// while (l >= 1 && h[l] >= h[i]) l--; // ← ★ 这两句里各有一个 break 的意思:// while (r <= n && h[r] >= h[i]) r++; // 「一碰到更矮的就停」//// 最坏情况确实是 O(n²)(比如 h 单调不降时,每根都要一路扩到头)。// 可**随机数据上它快得像 O(n)**:随便扔一根柱子下去,左右两边往往走一两格就碰到更矮的了。//// 本机实测(./genBig n 0 = 随机,./genBig n 1 = 单调不降),见正文第 13 步那张表:// 随机 n = 200000:0.02 秒(两个 while 一共挪了 4097 万步)// 单调不降 n = 200000:★ 7.10 秒(挪了 200.2 亿步 —— 正好是 n²/2)// ★ 同一份代码、同样的 n,差 489 倍。//// ⚠ 所以拿它做「暴力有多慢」的对比表时,**数据形状选错,整张表就是废的**:// 第 25 章是容量给小了(免费剪枝),第 30 章是图太稀疏,第 31 章是「找到第一个就 break」,// 这一章是**数据不够单调**。四次的共同点只有一句:// ★ **量之前,先确认暴力真的把该做的活都做了。**//// 用法:// ./expand 读入柱状图,输出最大面积(和 brute.cpp / fast.cpp 必须一致)// ./expand count 多输出一个数:★ 两个 while 一共挪了多少步(正文那张表就是它)
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { ios::sync_with_stdio(false); cin.tie(nullptr); bool showCount = (argc > 1 && string(argv[1]) == "count");
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i];
long long ans = 0, steps = 0; for (int i = 1; i <= n; i++) { int l = i, r = i; while (l - 1 >= 1 && h[l - 1] >= h[i]) { l--; steps++; } // ★ 一碰到更矮的就停 while (r + 1 <= n && h[r + 1] >= h[i]) { r++; steps++; } steps += 2; // 那两次「停下来」的判断也算 ans = max(ans, h[i] * (r - l + 1)); }
cout << ans; if (showCount) cout << " " << steps; cout << "\n"; return 0;}点「运行 ▶」看结果
这份代码的思路和正解一模一样,只是老老实实地一根一根往外挪:
while (l - 1 >= 1 && h[l - 1] >= h[i]) l--; // ★ 一碰到更矮的就停
while (r + 1 <= n && h[r + 1] >= h[i]) r++;
最坏情况当然是 O(n²)。可随机数据上它快得像 O(n):
| 数据形状 | 两个 while 一共挪了多少步 | 耗时 |
|---|---|---|
随机高度(./genBig 200000 0) |
40 970 641 | 0.02 秒 |
单调不降(./genBig 200000 1) |
20 020 219 900(= n²/2) | 7.10 秒 |
差 489 倍。而这两份数据的 n 一模一样。
★ 「暴力假装自己不慢」的第四张脸,而这一次那个旋钮是「数据的形状」。 前三张:容量太小 → 免费剪枝(第 25 章)、图太稀疏(第 30 章)、 「找到第一个就 break」(第 31 章)。 四次的共同点只有一句:量之前,先确认暴力真的把该做的活都做了。
⚠ 所以这一章的耗时对比表(第 13 步)必须同时给出两种形状, 只报一列的话,无论报哪一列都是在骗人。
5★ 关键一步:换个问法,题目就变成「左右第一个更矮的在哪」
第 1 步那句话把题目变成了:
对每一根柱子,求出它左边第一个更矮的、右边第一个更矮的分别在哪。
单调栈就是干这个的。从左往右扫,栈里存下标,对应高度递增:
while (!st.empty() && h[st.back()] >= h[i]) { // 新来的 i 比栈顶矮(或一样高)
int t = st.back(); st.pop_back();
int left = st.empty() ? 0 : st.back(); // ← 左边第一个比 h[t] 矮的
ans = max(ans, h[t] * (i - left - 1)); // ← 右边第一个比 h[t] 矮的就是 i
// 宽 = i − left − 1,那个 −1 是「两端那两根更矮的不算」
}
st.push_back(i);★ 被弹出的那一刻,两个边界同时揭晓 —— 这是整个算法唯一需要理解的地方:
- 右边第一个更矮的,就是当前这个 i(它正因为更矮才把 t 挤出去);
- 左边第一个更矮的,就是 t 被弹掉之后的新栈顶(因为栈里是递增的, 在 t 下面的那个一定比 t 矮;而中间那些更高的,早就被 t 自己弹掉了)。
★ 「栈里存的是下标不是高度」这件事,理由就在这一行:宽度要靠下标相减。
// 柱状图里最大的矩形 —— 正解:单调栈 O(n)//// 这份代码为什么存在:它是本章的主角。//// ★ 换一个问法就全通了:**以第 t 根柱子的高度为高**的最大矩形,宽能有多少?// 往左能扩到「左边第一根**比它矮**的柱子」的右边,往右扩到「右边第一根比它矮的」的左边。// 于是问题变成:**对每一根柱子,求出它左右两侧第一个更矮的柱子在哪。**// (★ 请注意:问的是**位置**,不是值 —— 所以栈里存的必须是**下标**。)//// 单调栈就是干这个的:栈里的下标,对应的高度**从栈底到栈顶严格递增**。// 新来一根 cur,把栈顶所有「不比 cur 矮」的都弹掉 ——// 对每个被弹出的 t,此刻的两件事同时揭晓:// · 右边第一个比它矮的,就是**当前这个 i**;// · 左边第一个比它矮的,就是**它被弹掉之后的新栈顶**(因为栈里是递增的)。// 所以宽 = i − 新栈顶 − 1(★ 那个 −1 是「不含两端那两根更矮的」,wrongWidth.cpp 就错在这里)。//// ★★ 关键一步是**均摊分析**:这里明明有两层循环(for 里套 while),但它是 O(n) ——// 因为每根柱子**一辈子只入栈一次、只出栈一次**,那句 while 转的总次数不会超过 n。// 这和第 7 章双指针「两个指针都只往前走,所以是 O(n)」是同一件事。// count.cpp 把这两个次数数出来给你看。//// ⚠ 末尾那根 −1 的哨兵:扫完之后栈里通常还剩一串(越往栈顶越高),// 它们的右边界还没揭晓。放一根「比所有柱子都矮」的哨兵,就能把它们全逼出来,// 主循环一个字都不用改。忘了这件事就是 wrongTail.cpp。// ⚠ 为什么写 −1 而不是 0:题目允许 h = 0,写 −1 才**严格**比所有柱子矮,一句话就说得清。// (这道题写 0 其实也对 —— 高 0 的柱子面积也是 0,漏算了也不影响最大值 ——// 但那要多想一步。★ 哨兵的意义就是「不用多想一步」,别给自己留这种账。)//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1; // 哨兵:比任何柱子都矮,把栈里剩下的全逼出来
long long ans = 0; vector<int> st; // ★ 存的是**下标**,对应高度严格递增 st.reserve(n + 1);
for (int i = 1; i <= n + 1; i++) { while (!st.empty() && h[st.back()] >= h[i]) { int t = st.back(); st.pop_back(); long long left = st.empty() ? 0 : st.back(); // 左边第一个比 h[t] 矮的下标(没有就当 0) long long w = i - left - 1; ans = max(ans, h[t] * w); } st.push_back(i); }
cout << ans << "\n"; return 0;}点「运行 ▶」看结果
扫完之后栈里通常还剩一串(越往栈顶越高)—— 它们的右边界从来没揭晓过, 因为再没有更矮的柱子来把它们弹出去了。
在末尾放一根「比谁都矮」的哨兵(h = −1),主循环一个字都不用改, 它们就会被一口气全逼出来。默认那张图上,哨兵那一步一次弹掉了 4 根。
⚠ 忘了它,就是第 8 步那个 wrongTail.cpp。
6★★ 兑现预告①:两层循环,为什么却是 O(n)
for 里套着 while,凭什么说它是 O(n)?
错误的理由(很多人第一次是这么想的):「每来一个新元素就弹一个,进出才平衡」——
这个理由不但错,而且会让人把 while 写成 if(第 8 步那个 wrongOnce.cpp)。
正确的理由只有一句:
★ 每根柱子一辈子只入栈一次、只出栈一次。 所以那句
while转的总圈数不会超过总出栈次数,也就是不超过 n —— 哪怕某一步一口气弹掉了 n−1 根,也不要紧,因为那 n−1 根再也不会回来了。
这就叫均摊:单看某一步可能很贵,但整趟下来总量是封顶的。 第 7 章双指针那句「两个指针都只往前走,所以是 O(n)」,说的是同一件事。
// 柱状图 —— 把三种写法的**工作量**数出来(★ 均摊分析的证据)//// 这份代码为什么存在:「单调栈是 O(n)」这句话,光看代码是不服气的 ——// 明明 for 里面套了个 while。所以这里不讲道理,直接数://// · 入栈次数、出栈次数 —— ★ **入栈 n+1 次(含哨兵)、出栈 n 次**(哨兵只进不出,没人来弹它),// 不管数据长什么样,一次不多一次不少。这就是均摊分析那句「每个元素进出各一次」的**实物**。// · while 的判断次数 —— 它比出栈次数只多一点点(每次 for 循环最多多判一次「不弹了」)。// · 枚举区间的暴力(brute.cpp):n(n+1)/2 次,一次都少不了。// · 往外扩的暴力(expand.cpp):★ 这个数**随数据形状剧烈变化** ——// 随机数据上它和 n 一个量级,单调不降时才涨到 n²/2。//// ⚠ 第 29、32、34 章那条「**量之前先确认「你量的就是它」**」在这里的落法:// 这份程序只做「数数」这一件事,不做任何输出格式化、也不重复读入 ——// 三种写法读的是同一份输入、数的是同一件事(「碰了多少次数据」)。//// 输入:同 brute.cpp// 输出:一张表(正文第 6 步引用了其中三个数)
#include <bits/stdc++.h>using namespace std;
/** 含中文的列不能用 setw(它数字节,一个汉字 3 字节只占 2 格宽)—— 第 26 章那个 padDisp */static string padDisp(const string& s, int width) { int disp = 0; for (size_t i = 0; i < s.size();) { unsigned char c = s[i]; if (c < 0x80) { disp += 1; i += 1; } else if ((c >> 5) == 6) { disp += 1; i += 2; } else if ((c >> 4) == 14) { disp += 2; i += 3; } else { disp += 2; i += 4; } } return s + string(max(0, width - disp), ' ');}
int main() { int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1;
/* ① 单调栈:入栈 / 出栈 / while 判断各多少次 */ long long pushes = 0, pops = 0, tests = 0, ans = 0; { vector<int> st; for (int i = 1; i <= n + 1; i++) { while (true) { tests++; // 每转一圈都要判一次(包括最后那次「不弹了」) if (st.empty() || h[st.back()] < h[i]) break; int t = st.back(); st.pop_back(); pops++; long long left = st.empty() ? 0 : st.back(); ans = max(ans, h[t] * (i - left - 1)); } st.push_back(i); pushes++; } }
/* ② 枚举区间的暴力:n(n+1)/2 次,没有任何提前退出 */ long long bruteSteps = (long long)n * (n + 1) / 2;
/* ③ 往外扩的暴力:★ 这个数完全取决于数据长什么样 */ long long expandSteps = 0; for (int i = 1; i <= n; i++) { int l = i, r = i; while (l - 1 >= 1 && h[l - 1] >= h[i]) { l--; expandSteps++; } while (r + 1 <= n && h[r + 1] >= h[i]) { r++; expandSteps++; } expandSteps += 2; }
printf("n = %d,答案 = %lld\n\n", n, ans); printf("%s%s\n", padDisp("写法", 30).c_str(), padDisp("碰数据的次数", 16).c_str()); printf("%s\n", string(46, '-').c_str()); printf("%s%lld\n", padDisp("★ 单调栈:入栈", 30).c_str(), pushes); printf("%s%lld\n", padDisp("★ 单调栈:出栈", 30).c_str(), pops); printf("%s%lld\n", padDisp(" 单调栈:while 判断", 30).c_str(), tests); printf("%s%lld\n", padDisp(" 枚举所有区间(brute)", 30).c_str(), bruteSteps); printf("%s%lld\n", padDisp(" 往左右扩(expand)", 30).c_str(), expandSteps); printf("\n★ 入栈 %lld 次 = n+1(含哨兵)、出栈 %lld 次 = n —— 每根柱子进出各一次," "和数据长什么样毫无关系。\n", pushes, pops); printf(" 这就是「两层循环却是 O(n)」的全部理由:那句 while 转的总次数被出栈次数管死了。\n"); return 0;}点「运行 ▶」看结果
默认那张图(n = 8)上:
| 写法 | 碰数据的次数 |
|---|---|
| ★ 单调栈:入栈 | 9(= n+1,含哨兵) |
| ★ 单调栈:出栈 | 8(= n,哨兵只进不出,没人来弹它) |
| 单调栈:while 判断 | 17 |
| 枚举所有区间(brute) | 36 |
| 往左右扩(expand) | 32 |
把柱状图换成单调递增、单调递减、全都一样高……入栈永远是 n+1、出栈永远是 n。
而 expand 那一行会从几十跳到几百亿(第 4 步那张表)。
★ 一个是「和数据无关的常数级工作量」,一个是「随数据形状剧烈变化」—— 这就是 O(n) 和 O(n²) 在计数器上的样子。
7★ 动画一:柱子和栈同屏,看那两个计数器
左边是柱状图,右边是栈。每弹出一根,就把它对应的矩形当场画出来(虚线框)—— 因为「被弹出」的那一刻,正是它两个边界同时揭晓的那一刻。
| 柱状图 | 入栈 | 出栈 | 结算的矩形数 |
|---|---|---|---|
默认那张 2 1 5 0 3 5 5 6 |
9 | 8 | 8 |
单调不降 1 2 3 4 5 6 |
7 | 6 | 6 |
单调不增 6 5 4 3 2 1 |
7 | 6 | 6 |
全都一样高 4 4 4 4 4 |
6 | 5 | 5 |
★ 四种形状差别巨大,三个计数器却只跟着 n 走。 这就是上一步那句话的画面版。
(这三个数都钉在 check:viz 里。)
⚠ 但请注意它们过程完全不同:单调不降时前面一根都弹不掉、全靠哨兵一次弹光; 单调不增时每来一根就弹一根。总量一样,节奏完全不一样 —— 均摊说的正是这件事。
// 柱状图 —— 把单调栈**每一步**的栈内容打出来(动画就是照着这张表画的)//// 这份代码为什么存在:check:viz 拿它和网页动画**逐行**对(第 23 章 trace.cpp 的同款)。// 只比最终答案是不够的 —— 那样「答案蒙对、过程画错」根本发现不了。//// 每一步打三样东西:// · 这一步弹出了几根(★ 这个数就是均摊分析的证据:它加起来正好是 n+1)// · 弹完之后栈里剩哪些**下标**(★ 对应的高度必须严格递增,动画那边也逐帧验这一条)// · 当前的答案//// ⚠ 这份程序 cout 和 printf 混用,所以**绝对不能关 ios::sync_with_stdio**// (第 26、28 章各踩过一次:两边各自缓冲,表格会跑到结语后面去)。//// 输入:同 brute.cpp// 输出:一行一步,末尾一行汇总
#include <bits/stdc++.h>using namespace std;
/** 含中文的列不能用 %-Ns(它数字节,一个汉字 3 字节却只占 2 格宽)—— 第 26 章那个 padDisp */static string padDisp(const string& s, int width) { int disp = 0; for (size_t i = 0; i < s.size();) { unsigned char c = s[i]; if (c < 0x80) { disp += 1; i += 1; } else if ((c >> 5) == 6) { disp += 1; i += 2; } else if ((c >> 4) == 14) { disp += 2; i += 3; } else { disp += 2; i += 4; } } return s + string(max(0, width - disp), ' ');}
int main() { int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1; // 哨兵
long long ans = 0, pushes = 0, pops = 0; vector<int> st;
printf("%s%s%s%s%s\n", padDisp("步", 6).c_str(), padDisp("新来", 8).c_str(), padDisp("弹出", 10).c_str(), padDisp("栈内下标(高度递增)", 26).c_str(), "ans"); printf("%s\n", string(64, '-').c_str());
for (int i = 1; i <= n + 1; i++) { int popped = 0; while (!st.empty() && h[st.back()] >= h[i]) { int t = st.back(); st.pop_back(); popped++; pops++; long long left = st.empty() ? 0 : st.back(); ans = max(ans, h[t] * (i - left - 1)); } st.push_back(i); pushes++;
string in = (i <= n) ? ("h=" + to_string(h[i])) : "哨兵"; string body; for (int x : st) { if (!body.empty()) body += " "; body += to_string(x); } if (body.empty()) body = "空"; printf("%s%s%s%s%lld\n", padDisp("步 " + to_string(i), 6).c_str(), padDisp(in, 8).c_str(), padDisp("弹出 " + to_string(popped) + " 根", 10).c_str(), padDisp("栈 [" + body + "]", 26).c_str(), ans); }
printf("%s\n", string(64, '-').c_str()); printf("★ 一共入栈 %lld 次、出栈 %lld 次(n = %d)—— 每根柱子进出各一次," "哨兵只进不出(没人来弹它)。\n", pushes, pops, n); cout << "★ 这就是「for 里套 while 却是 O(n)」的全部理由:那句 while 转多少圈," "被出栈次数管死了。\n"; return 0;}点「运行 ▶」看结果
check:viz 拿这张表和动画逐行对(第 23 章那条)—— 不只比最终答案,
每一步「弹出了几根、栈里剩哪些下标、当前 ans」都要一致。
否则「答案蒙对、过程画错」根本发现不了。
8四种把它写错的方式,外加一种「怎么写都对」
// ✗ 错误版本一:宽度只从被弹出的那根算起 —— 忘了它往左还能扩//// 这份代码为什么存在:它是单调栈**最常见**的手滑,而且症状极温和 ——// 答案只是偏小一点,不崩溃、不越界。//// ✓ long long w = i - left - 1; // left = 弹掉它之后的**新栈顶**(左边第一个更矮的)// ✗ long long w = i - t; // t = 被弹出的那根自己//// 写成 `i - t` 相当于说「这根柱子只能从它自己站的地方往右铺到 i」,// 可它明明还能**往左**铺 —— 一直铺到左边第一根比它矮的柱子为止// (那些被它更早弹掉的、比它高的柱子,站的地方它当然也能站)。//// ★ 它对生成器提了一个具体要求:**答案所在的那个矩形,宽不能总是 1。**// 如果最大矩形就是某一根柱子自己(w = 1),这一份算出来的也是 1,一模一样。// ⚠ 但我原以为「值域一宽就会变成这样、于是它隐身」—— **实测不成立**:// 纯随机、值域 [0,999] 的档位 0 上它已经是 217 / 300。// 真正把它抬起来的是**造台阶**(连续等高段):档位 2 抬到 256,撤掉台阶的对照档位 5 掉到 176。// ★ **先跑再写。**(正文第 12 步那两张表。)//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1;
long long ans = 0; vector<int> st; for (int i = 1; i <= n + 1; i++) { while (!st.empty() && h[st.back()] >= h[i]) { int t = st.back(); st.pop_back(); long long w = i - t; // ✗ 只往右铺,忘了往左还能扩 ans = max(ans, h[t] * w); } st.push_back(i); } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
图 A 上它给 12(正解 15)。
w = i − t 相当于说「这根柱子只能从它自己站的地方往右铺」,
可它明明还能往左铺 —— 那些被它更早弹掉的、比它高的柱子,站的地方它当然也能站。
⚠ 它挑数据:如果最大矩形正好就是某一根柱子自己(宽 1),两种写法算出来一样。
// ✗ 错误版本二:忘了那根哨兵 —— 扫完就走,栈里剩下的一概不管//// 这份代码为什么存在:它错的不是循环体,是**收尾**。//// 扫到最后,栈里通常还剩一串柱子(从栈底到栈顶越来越高),// 它们的**右边界一直没揭晓** —— 因为再也没有比它们矮的柱子来把它们弹出去了。// 正解的做法是在末尾放一根 h = −1 的哨兵,把它们全逼出来;// 这一份直接 `for (i = 1; i <= n; i++)` 扫完就输出。//// ★ 它的症状非常挑数据:**只有当答案落在「结尾那一段递增的柱子」上时才现形。**// 所以随机数据抓得到它,但抓获率上不去(正文第 12 步那张表:结尾一段递增的概率就是它的上限);// 而「顺手让数据整体递减」的生成器会让它**永远 0 / 300**。//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i];
long long ans = 0; vector<int> st; for (int i = 1; i <= n; i++) { // ✗ 没有那根 i = n+1 的哨兵 while (!st.empty() && h[st.back()] >= h[i]) { int t = st.back(); st.pop_back(); long long left = st.empty() ? 0 : st.back(); ans = max(ans, h[t] * (i - left - 1)); } st.push_back(i); } // ✗ 栈里还剩一串没结算,就这么走了 cout << ans << "\n"; return 0;}点「运行 ▶」看结果
图 A 上它给 5 —— 正好是左半段的答案。
★ 因为图 A 结尾那一段(3 5 5 6)是递增的,它们全留在栈里没人结算, 而答案 15 恰恰就在那一段里。 ⚠ 所以这个 bug 只在「答案落在结尾那段递增上」时才现形 —— 生成器要是顺手让数据整体递减,它就永远 0 / 300。
// ✗ 错误版本三:while 写成了 if —— 一次只弹一根//// 这份代码为什么存在:★ 它是**均摊分析**那一节最好的反面教材。//// 很多人第一次写单调栈会不自觉地写成 `if`,理由听起来还挺有道理:// 「每来一个新元素就弹一个,进出才平衡,这样才是 O(n) 嘛。」//// ⚠ 这个理由**完全是反的**。均摊分析说的是:// 总出栈次数 ≤ 总入栈次数 = n,所以**哪怕某一步一口气弹掉了 n−1 根**,// 整体还是 O(n) —— 因为那 n−1 根之后再也不会回来了。// 「一步之内不能干太多活」根本不是 O(n) 的要求,// ★ **要求的是「一辈子只能干一次」。**//// 而写成 if 之后,栈里就不再是单调的了(该弹的没弹干净),// 后面每一次「左边第一个更矮的」都可能读到一个**比它高**的下标 —— 宽度整个算错。//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1;
long long ans = 0; vector<int> st; for (int i = 1; i <= n + 1; i++) { if (!st.empty() && h[st.back()] >= h[i]) { // ✗ 本该是 while int t = st.back(); st.pop_back(); long long left = st.empty() ? 0 : st.back(); ans = max(ans, h[t] * (i - left - 1)); } st.push_back(i); } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
图 A 上它给 6(正解 15)。
★ 写成 if 的人,心里那个理由通常是「一次弹一个才平衡,这样才是 O(n)」——
这个理由整个是反的(第 6 步)。而代价是:该弹的没弹干净,
栈从此不再单调,后面每一次「左边第一个更矮的」都可能读到一个比它更高的下标。
★ 把动画切到这一档,你会肉眼看见栈不再递增 —— 这个 bug 是能看出来的。
// ✗ 错误版本四:弹出条件的方向写反了(维护成了单调递**减**栈)//// 这份代码为什么存在:单调栈只有一个参数需要想清楚 —— **栈里到底该单调递增还是递减**。// 想反了,代码照样跑得欢,答案照样是个正数。//// ✓ while (h[st.back()] >= h[i]) // 弹掉「不比新来的矮」的 → 栈内递增// ✗ while (h[st.back()] <= h[i]) // 弹掉「不比新来的高」的 → 栈内递减//// ★ 方向由**你要问的问题**决定,不是背的:// 这道题要的是「左右第一个**更矮**的柱子」,所以栈里必须是「越往上越高」,// 新来一根更矮的,才能把上面那些高的挤下去、当场把它们的右边界定下来。// 要是问的是「左右第一个更**高**的」(比如「每天之后第一个更暖和的日子」),// 方向就要整个反过来。//// ⚠ 这份代码为了不越界,把哨兵也改成了一个很大的数(否则递减栈永远弹不空)。// ★ **错误版本也要干净:一份只错一件事**(第 34 章那条),// 不能让它顺带炸在别的地方,否则量出来的抓获率说不清是谁的功劳。//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = LLONG_MAX / 4; // 方向反了,哨兵也得跟着反(不然弹不空)
long long ans = 0; vector<int> st; for (int i = 1; i <= n + 1; i++) { while (!st.empty() && h[st.back()] <= h[i]) { // ✗ 方向反了 int t = st.back(); st.pop_back(); long long left = st.empty() ? 0 : st.back(); ans = max(ans, h[t] * (i - left - 1)); } st.push_back(i); } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
图 A 上它给 48,离谱到一眼就能看出来(300 轮全被抓住)。
★ 方向由你要问的问题决定,不是背的: 这道题问「左右第一个更矮的」,所以栈里必须「越往上越高」; 要是问「第一个更高的」(比如「几天之后会有更暖和的一天」),方向就要整个反过来。
// ✗ 错误版本五:面积用 int 存 —— 而这一份是**对拍永远抓不到**的那种错//// 这份代码为什么存在:它是第 3 节那条老规矩的现场演示 ——// ★ **对拍查不出溢出,这只能靠脑子。**//// 算法一个字都没错,只有 `long long` 全换成了 `int`。// 而对拍用的数据是小数据(n ≤ 12、h ≤ 30),面积撑死几百 —— 它 300 轮全对,**0 / 300**。//// 可题目写的是 `0 ≤ h[i] ≤ 10⁹`、`n ≤ 2×10⁵`,面积最大 2×10¹⁴,// int 在 2 147 483 647 就绕回去了。用 `./genBig 200000 2` 造一组「全是 10⁹」的数据:// 正解给 200 000 000 000 000,这一份给 **2 147 459 072** ——// ⚠ 注意它**不是负数**,而是一个「看起来挺正常」的数(绕回去之后取 max,// 最后留下的往往正好卡在 int 上限附近)。**溢出最可怕的地方就是它长得不像出事了。**//// ⚠ 这不是「对拍没用」,而是**对拍的适用范围**:// 它验的是「两份代码想的是不是同一件事」,验不了「这个类型装不装得下」。// 同一件事的另外两张脸:**两份程序错得一模一样**(第 20 章)、// **随机数据碰不到最坏情况**(第 33 章)。//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<int> h(n + 2); // ✗ 高度本身其实还装得下,问题出在乘法 for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1;
int ans = 0; // ✗ 面积用 int vector<int> st; for (int i = 1; i <= n + 1; i++) { while (!st.empty() && h[st.back()] >= h[i]) { int t = st.back(); st.pop_back(); int left = st.empty() ? 0 : st.back(); ans = max(ans, h[t] * (i - left - 1)); // ✗ int × int 就在这里绕回去了 } st.push_back(i); } cout << ans << "\n"; return 0;}点「运行 ▶」看结果
图 A 上它给 15,和正解一模一样。300 轮对拍:0 / 300。
因为对拍用的是小数据(n ≤ 12、h ≤ 9),面积撑死几百。
可题面写的是 h ≤ 10⁹、n ≤ 2×10⁵:
./genBig 200000 2 > big.txt # 20 万根,全都高 10⁹
./fast < big.txt # 200000000000000
./wrongInt < big.txt # 2147459072 ← ⚠ 不是负数,是一个「看着挺正常」的数★ 对拍查不出溢出,这只能靠脑子。(第 3 节那条老规矩的现场。) 它验的是「两份代码想的是不是同一件事」,验不了「这个类型装不装得下」。 ⚠ 而且溢出最可怕的地方是它长得不像出事了 —— 绕回去之后取 max, 留下的那个数往往正好卡在 int 上限附近。
随机对拍的三个盲区,这是第一个的现场(另两个:第 20 章「只能证伪」、 第 31 章「验证器证明不了没漏报」、第 33 章「随机数据碰不到最坏情况」)。
// 柱状图 —— `>=` 还是 `>`?两种写法并排跑//// 这份代码为什么存在:单调栈那句弹出条件,等号写哪边是初学者最纠结的地方://// while (h[st.back()] >= h[i]) ... // 遇到「一样高」的也弹// while (h[st.back()] > h[i]) ... // 遇到「一样高」的不弹,留着//// ★ 结论有点反直觉:**两种写法的最终答案永远一样**(300 组随机数据一组不差,钉在 check:viz 里),// 可它们**中间结算出来的「高 × 宽」并不一样** —— 这份程序把那些不同的行标出来。//// 为什么答案还是对的:一串**等高**的柱子,`>=` 版让每一根各自结算一次,// 靠前的那些算出来的宽偏小(右边界被同伴截断了);// 而**最后那一根**(也就是这串等高柱子里最右边的那根)算出来的宽是完整的。// `>` 版则相反:它们挤在栈里一直不弹,最后由**最左边**那根统一结算出完整的宽。// ★ 两条路都保证「那个完整的宽至少被算到一次」,而我们只要**最大值**。//// ⚠ 请把它和第 22 章那件事对照着看:那一章 `lower_bound` 写成 `upper_bound`// 是**真 bug**(300 组里 158 组答案不同),因为那道题问的是「严格上升还是非降」,// 等号本身就是题目的一部分;这一章只要最大值,等号写哪边只影响**过程**。// > ★ 同一个「一字之差」,一处致命一处无害 —— 差别在于**你要的是什么**。// (第 33 章「同一句代码危不危险取决于数据的取值范围」、第 34 章「取决于题目问什么」的第三张脸。)//// ⚠ 这份程序 cout / printf 混用,不能关 sync(第 26 章那条)。//// 输入:同 brute.cpp// 输出:两种写法各自的结算清单 + 答案
#include <bits/stdc++.h>using namespace std;
struct Item { int t; long long hh, w; };
/** eq = true 表示用 `>=`(遇到等高也弹) */static vector<Item> run(const vector<long long>& h, int n, bool eq, long long& ans) { vector<Item> out; vector<int> st; ans = 0; for (int i = 1; i <= n + 1; i++) { while (!st.empty() && (eq ? h[st.back()] >= h[i] : h[st.back()] > h[i])) { int t = st.back(); st.pop_back(); long long left = st.empty() ? 0 : st.back(); long long w = i - left - 1; out.push_back({t, h[t], w}); ans = max(ans, h[t] * w); } st.push_back(i); } return out;}
int main() { int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1;
long long ansGe = 0, ansGt = 0; vector<Item> a = run(h, n, true, ansGe); vector<Item> b = run(h, n, false, ansGt);
printf("%-6s %-22s %-22s %s\n", "第几次", ">= 版(等高也弹)", "> 版(等高留着)", "一样吗"); printf("%s\n", string(70, '-').c_str()); size_t m = max(a.size(), b.size()), diff = 0; for (size_t i = 0; i < m; i++) { string sa = i < a.size() ? "柱 " + to_string(a[i].t) + ":高 " + to_string(a[i].hh) + " × 宽 " + to_string(a[i].w) : "—"; string sb = i < b.size() ? "柱 " + to_string(b[i].t) + ":高 " + to_string(b[i].hh) + " × 宽 " + to_string(b[i].w) : "—"; bool same = (sa == sb); if (!same) diff++; printf("%-8zu %-26s %-26s %s\n", i + 1, sa.c_str(), sb.c_str(), same ? "" : "★ 不"); } printf("%s\n", string(70, '-').c_str()); printf(">= 版:结算 %zu 次,答案 %lld\n", a.size(), ansGe); printf("> 版:结算 %zu 次,答案 %lld\n", b.size(), ansGt); printf("两版结算出的「高 × 宽」有 %zu 处不同\n", diff); cout << (ansGe == ansGt ? "★ 但最大值一模一样 —— 这道题只要最大值,所以等号写哪边都对。\n" : "✗ 答案不一样(那就说明我上面那段话错了)\n"); return 0;}点「运行 ▶」看结果
while (h[st.back()] >= h[i]) // 遇到一样高的也弹
while (h[st.back()] > h[i]) // 遇到一样高的不弹,留着图 A 上两版结算出来的「高 × 宽」有 3 处不同(表里那三行 ★ 不),
可最大值都是 15。300 组随机数据一组不差(钉在 check:viz 里)。
道理:一串等高的柱子里,>= 版让最右边那根算出完整的宽,
> 版让最左边那根算出完整的宽 —— 两条路都保证那个完整的宽至少被算到一次,
而我们只要最大值。
⚠ 请和第 22 章对照:那一章
lower_bound写成upper_bound是真 bug (300 组里 158 组答案不同),因为那道题问的是「严格上升还是非降」,等号就是题目本身。 ★ 同一个「一字之差」,一处致命一处无害 —— 差别在于你要的到底是什么。 (第 33 章「取决于数据的取值范围」、第 34 章「取决于题目问什么」之后的第三张脸。)
9第二道题:滑动窗口最值 —— 单调队列只多管了一头
给 n 个整数
a[1..n](可能是负数)和一个窗口宽度 k。 窗口从最左边一格一格滑到最右边,每个位置输出窗口里的最小值和最大值。第一行 n−k+1 个数(每个窗口的最小值),第二行 n−k+1 个数(最大值)。
// 滑动窗口最值 —— 标准答案:每个窗口老老实实扫一遍 O(nk)//// 这份代码为什么存在:它是对拍的**标准答案**,也是「题目到底在问什么」的定义本身。//// 窗口从左往右滑,每滑一格就把窗口里的 k 个数**从头扫一遍**,取最小、取最大。// 一共 n−k+1 个窗口,每个窗口 k 个数 —— O(nk)。//// ⚠ 它和 fast 那份的关系,和柱状图那道题正好是一对:// 那边的暴力(expand.cpp)带 break,会假装自己不慢;// 这一份**一次 break 都没有**,k 有多大就扫多少下,n 和 k 都够大时它是真的慢。// (所以第 13 步那张耗时表的旋钮是 **k**,不是 n —— 这也是「要随机的是算法依赖的那个量」。)//// 输入:第一行 n 和 k(1 ≤ k ≤ n);第二行 n 个整数 a[1..n](可能是负数)// 输出:第一行 n−k+1 个数 —— 每个窗口的**最小值**// 第二行 n−k+1 个数 —— 每个窗口的**最大值**
#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<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
for (int wantMin = 1; wantMin >= 0; wantMin--) { for (int i = k; i <= n; i++) { // 窗口是 [i-k+1, i] long long best = a[i - k + 1]; for (int j = i - k + 2; j <= i; j++) best = wantMin ? min(best, a[j]) : max(best, a[j]); cout << best << " \n"[i == n]; } } return 0;}点「运行 ▶」看结果
默认那组 1 3 -1 -3 5 3 6 7,k = 3:
最小值:-1 -3 -3 -3 3 3
最大值: 3 3 5 5 6 7
// ① 队尾:新来一个 a[i],把队尾所有「不比它小」的弹掉
while (!q.empty() && a[q.back()] >= a[i]) q.pop_back();
q.push_back(i);
// ② 队头:如果队头那个下标已经滑出窗口,弹掉
if (q.front() <= i - k) q.pop_front();
// ③ 队头就是当前窗口的最小值
if (i >= k) cout << a[q.front()];- 队尾那一句和单调栈一模一样:那些家伙又老又大, 只要 a[i] 还在窗口里,它们永远轮不到当最小值 —— 当场扔掉。
- 队头那一句是新的,它管的正是「窗口只有 k 宽」这件事。
★ 单调栈只从一头进出,单调队列两头都要动。多出来的那一头,就是「窗口」这两个字的全部代价。
★ 而这一头也顺便解释了为什么队列里必须存下标: 值用来比大小,下标用来判出没出窗口 —— 两件事,缺一不可。 (单调栈那道题存下标是为了算宽度,也是「值答不了的那半」。)
// 滑动窗口最值 —— 正解:单调队列 O(n)//// 这份代码为什么存在:它是本章后半章的主角,也是**均摊分析的第二次登场**。//// ★ 关键一步和单调栈是同一句话,只是多了一头:// 队列里存**下标**,对应的值单调(求最小值时递增)。// · **队尾**:新来一个 a[i],把队尾所有「不比它小」的都弹掉 ——// 理由是那些家伙**又老又大**,只要 a[i] 还在窗口里,它们永远轮不到当最小值。// · **队头**:如果队头那个下标已经滑出窗口(≤ i−k),弹掉。// ★ **这就是为什么队列里必须存下标**:光存值,你根本不知道它是什么时候进来的// (winWrongVal.cpp 就死在这里)。// 于是队头永远是当前窗口的最小值,O(1) 取到。//// ★★ 还是均摊:每个下标**只入队一次、只出队一次**(要么被队尾挤掉、要么从队头滑走),// 所以两个 while 加起来转不过 2n 圈。和单调栈、和第 7 章双指针,是同一件事的三张脸。//// ⚠ 单调栈和单调队列的区别只有一句:**单调栈只从一头进出,单调队列两头都要动** ——// 多出来的那一头,管的正是「窗口有多宽」这件事。//// 输入输出同 winBrute.cpp。
#include <bits/stdc++.h>using namespace std;
/** wantMin = true 求每个窗口的最小值,false 求最大值 —— 两者只差一个不等号方向 */static void solve(const vector<long long>& a, int n, int k, bool wantMin) { deque<int> q; // ★ 存下标,不存值 for (int i = 1; i <= n; i++) { // ① 队尾:把「又老又不如新来的」全弹掉 while (!q.empty() && (wantMin ? a[q.back()] >= a[i] : a[q.back()] <= a[i])) q.pop_back(); q.push_back(i); // ② 队头:滑出窗口的弹掉(一次最多滑出一个,所以 if 就够) if (q.front() <= i - k) q.pop_front(); // ③ 从第 k 个数开始,每一格都有一个完整的窗口 if (i >= k) cout << a[q.front()] << " \n"[i == n]; }}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
solve(a, n, k, true); solve(a, n, k, false); return 0;}点「运行 ▶」看结果
// 滑动窗口 —— 把工作量数出来(★ 均摊分析的第二份证据),外加一个「只读入」的开关//// 这份代码为什么存在,有两个理由://// ① ★ **入队次数和 k 一点关系都没有。** 暴力是 O(nk),k 从 10 拧到 100000 慢了两百多倍;// 而单调队列这边,不管 k 是多少,**入队次数恒等于 n**,队尾 + 队头的出队次数加起来也不超过 n。// 这就是「两个 while 却是 O(n)」的实物证据。//// ② ⚠ **量之前先确认「你量的就是它」**(第 29、32、34 章那条,这是第四次)。// n = 200000 时正解跑完只要 0.03 秒 —— 可这 0.03 秒里**几乎全是读入**。// `./winCount io` 只读入、什么都不算,把那一截量出来减掉,才知道算法本身花了多少。//// 用法:// ./winCount 读入 + 数一遍工作量,打印一张表// ./winCount io ★ 只读入,立刻退出(用来量「读入本身要多久」)//// 输入:同 winBrute.cpp
#include <bits/stdc++.h>using namespace std;
static string padDisp(const string& s, int width) { int disp = 0; for (size_t i = 0; i < s.size();) { unsigned char c = s[i]; if (c < 0x80) { disp += 1; i += 1; } else if ((c >> 5) == 6) { disp += 1; i += 2; } else if ((c >> 4) == 14) { disp += 2; i += 3; } else { disp += 2; i += 4; } } return s + string(max(0, width - disp), ' ');}
int main(int argc, char** argv) { bool ioOnly = (argc > 1 && string(argv[1]) == "io");
// ⚠ 读入方式必须和 winFast.cpp / winBrute.cpp **一模一样**(同样是 cin + 关掉 sync), // 否则量出来的「读入耗时」根本不是它们的读入耗时 —— // 第 32 章那条「两份代码的 I/O 设置要一致」正是这么栽的。 ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i]; if (ioOnly) { printf("只读入:n = %d, k = %d\n", n, k); return 0; }
long long pushes = 0, backPops = 0, frontPops = 0, tests = 0; deque<int> q; for (int i = 1; i <= n; i++) { while (true) { tests++; if (q.empty() || a[q.back()] < a[i]) break; q.pop_back(); backPops++; } q.push_back(i); pushes++; if (q.front() <= i - k) { q.pop_front(); frontPops++; } }
printf("n = %d,k = %d\n\n", n, k); printf("%s%s\n", padDisp("写法", 34).c_str(), "碰数据的次数"); printf("%s\n", string(52, '-').c_str()); printf("%s%lld\n", padDisp("★ 单调队列:入队", 34).c_str(), pushes); printf("%s%lld\n", padDisp("★ 单调队列:队尾出队", 34).c_str(), backPops); printf("%s%lld\n", padDisp("★ 单调队列:队头出队(滑出窗口)", 34).c_str(), frontPops); printf("%s%lld\n", padDisp(" 单调队列:while 判断", 34).c_str(), tests); printf("%s%lld\n", padDisp(" 每个窗口扫一遍(winBrute)", 34).c_str(), (long long)(n - k + 1) * k); printf("\n★ 入队恒等于 n = %d,出队 %lld 次(队尾 + 队头)≤ n —— 和 k 一点关系都没有。\n", n, backPops + frontPops); printf(" 而暴力那一行是 (n−k+1)·k,k 一拧就炸。这就是 O(n) 和 O(nk) 的全部差别。\n"); return 0;}点「运行 ▶」看结果
./winGenBig <k> 造的是 n = 200 000 的数据,只有 k 变(本机实测):
| k | 入队 | 队尾出队 | 队头出队 | 暴力要扫 (n−k+1)·k 次 |
|---|---|---|---|---|
| 10 | 200 000 | 181 892 | 18 104 | 1 999 910 |
| 1 000 | 200 000 | 199 789 | 199 | 199 001 000 |
| 100 000 | 200 000 | 199 983 | 2 | 10 000 100 000 |
★ 左边三列纹丝不动,右边那列涨了五千倍。这就是 O(n) 和 O(nk) 的全部差别。
10★ 动画二 + 四种把它写错的方式
上面是数组(绿框 = 当前窗口,蓝底 = 还在队列里),下面是每个窗口的答案,右边是队列和三个计数器。
播一遍就会发现,绿框有 3 格宽,可队列里常常只有 1~2 个 —— 没戏的早在进来的时候就被赶走了。
这正是「为什么它是 O(n) 而不是 O(nk)」的直观版: 我们从来没有把窗口里的 k 个数都留着。
// ✗ 错误版本六:单调队列里存**值**,不存下标//// 这份代码为什么存在:★ 它是本章后半章的核心反面教材,// 而且它错的地方**恰好就是这道题和柱状图那道题唯一的区别**。//// 单调栈那道题只关心「左右第一个更矮的在哪」,队列(栈)里存值还是存下标,// 影响的只是能不能算宽度;这道题多了一条「窗口只有 k 宽」,// 于是每一步都要回答一个新问题:**队头那个家伙,还在窗口里吗?**//// 光存值答不了这个问题。这一份只好拿**队列的长度**冒充窗口宽度://// if ((int)q.size() > k) q.pop_front(); // ✗ 队列长度 ≠ 窗口宽度//// ⚠ 可队列里装的从来就不是「窗口里的所有数」,而是「还有希望当最值的那些」——// 它通常比 k 短得多。于是该滑走的没滑走,最小值会**一直赖在队头不走**。//// ★ 一句话:**队列里存的是下标还是值,取决于你还要不要问「它是什么时候进来的」。**//// 输入输出同 winBrute.cpp。
#include <bits/stdc++.h>using namespace std;
static void solve(const vector<long long>& a, int n, int k, bool wantMin) { deque<long long> q; // ✗ 存的是值 for (int i = 1; i <= n; i++) { while (!q.empty() && (wantMin ? q.back() >= a[i] : q.back() <= a[i])) q.pop_back(); q.push_back(a[i]); if ((int)q.size() > k) q.pop_front(); // ✗ 拿长度冒充「出没出窗口」 if (i >= k) cout << q.front() << " \n"[i == n]; }}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
solve(a, n, k, true); solve(a, n, k, false); return 0;}点「运行 ▶」看结果
它给 -1 -3 -3 -3 -3 3(正解 -1 -3 -3 -3 3 3)。
光存值,「队头那个还在窗口里吗」这个问题就再也答不上来了, 只好拿队列长度冒充窗口宽度 —— 可队列里装的从来不是「窗口里的所有数」, 它通常比 k 短得多。于是该滑走的没滑走。
★ 要不要存下标,取决于你还要不要问「它是什么时候进来的」。
// ✗ 错误版本七:忘了从队头弹掉「已经滑出窗口」的那个//// 这份代码为什么存在:它只少了一行,而且少的那一行正是「窗口」这两个字的全部含义。//// ✓ if (q.front() <= i - k) q.pop_front();// ✗ (整行没有)//// 少了它之后,队列就变成了「从第 1 个数到现在为止的最小值」—— 也就是**前缀最值**,// 而不是窗口最值。★ 它精确地解了另一道题(**前缀最小值 / 前缀最大值**),// 这是本教材第十一条这样的恒等式(前十条在第 23–28、34 章)。//// ⚠ 它对生成器提了一个要求:**最值必须真的会滑出去** ——// 如果数据是单调不增的,最小值永远在窗口右端,滑不滑走都一样,这个 bug 就隐身。// (又是「顺手让数据单调」那条。)//// 输入输出同 winBrute.cpp。
#include <bits/stdc++.h>using namespace std;
static void solve(const vector<long long>& a, int n, int k, bool wantMin) { deque<int> q; for (int i = 1; i <= n; i++) { while (!q.empty() && (wantMin ? a[q.back()] >= a[i] : a[q.back()] <= a[i])) q.pop_back(); q.push_back(i); // ✗ 这里少了一行:if (q.front() <= i - k) q.pop_front(); if (i >= k) cout << a[q.front()] << " \n"[i == n]; }}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
solve(a, n, k, true); solve(a, n, k, false); return 0;}点「运行 ▶」看结果
它给 -1 -3 -3 -3 -3 -3。而它不是随机地错:
// 前缀最小值 / 前缀最大值 —— 用来验那条恒等式的「另一种思路」//// 这份代码为什么存在:★ 第十一条恒等式的**另一半**。//// winWrongPop.cpp(忘了从队头弹掉出窗口的那个)不是随机地错 ——// 它精确地解了另一道题:**前缀最值**(从第 1 个数一直到现在,最小 / 最大是多少)。// 道理一句话:队头之所以不走,就是因为没人赶它走,// 而队尾那句 while 保证了「比它更优的都进不来」—— 于是队头永远是**从头到现在**的最优值。//// ⚠ 验证这条恒等式,两份代码的**思路必须不同**(第 9、15、34 章那条):// 这一份根本没有队列,就是一路 `best = min(best, a[i])`。// 同一个思路写两遍只能验出打字错误。//// 输入输出同 winBrute.cpp(输出的是「从第 k 个数开始」的前缀最值,好和它逐字节比)。
#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<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
for (int wantMin = 1; wantMin >= 0; wantMin--) { long long best = a[1]; for (int i = 1; i <= n; i++) { best = wantMin ? min(best, a[i]) : max(best, a[i]); if (i >= k) cout << best << " \n"[i == n]; } } return 0;}点「运行 ▶」看结果
一模一样。300 组随机数据一组不差(钉在 check:viz 里)。
★ 忘了弹队头 ≡ 前缀最值。 这是本教材第十一条这样的恒等式 (前十条在第 23、24、25、26、27、28、34 章)。 道理一句话:队头没人赶它走,而队尾那句 while 保证「比它更优的都进不来」—— 于是队头永远是从头到现在的最优值。 ⚠ 验法照旧:两份程序思路必须不同(一份用队列、一份一路
min过去)。
// ✗ 错误版本八:判「滑出窗口」的那个不等号差了一格//// 这份代码为什么存在:滑动窗口这类题,一半的 bug 都长在**下标的边界**上。//// ✓ if (q.front() <= i - k) q.pop_front(); // 窗口是 [i−k+1, i],所以 ≤ i−k 的都出界了// ✗ if (q.front() < i - k) q.pop_front(); // 慢一拍:让出界的那个多赖一轮//// 窗口 [i−k+1, i] 里下标最小的是 `i−k+1`,所以「出界」的判据是 `≤ i−k`。// 写成 `< i−k` 的话,正好卡在边上的那个下标(`i−k`)会**多留一轮**,// 于是那一轮的答案可能来自一个刚刚滑走的数。//// ★ 这个 bug 的抓获率非常挑数据:只有当「刚滑走的那个正好是最值」时才现形,// 窗口越窄越容易撞上(k = 2 时几乎每一步都在换最值)。// 正文第 12 步那张表里,把 k 拉到两端(1 和 n)的那一档就是为它加的。//// ⚠ 顺带记一件事:k = 1 时窗口里只有一个数,答案恒等于原数组;k = n 时只有一个窗口,// 答案就是全局最值。**两端都是「另一道更简单的题」** —— 第 24 章那条// 「这个量取到极小 / 极大时会退化成哪道题?那一端也必须造」在这一章的落法。//// 输入输出同 winBrute.cpp。
#include <bits/stdc++.h>using namespace std;
static void solve(const vector<long long>& a, int n, int k, bool wantMin) { deque<int> q; for (int i = 1; i <= n; i++) { while (!q.empty() && (wantMin ? a[q.back()] >= a[i] : a[q.back()] <= a[i])) q.pop_back(); q.push_back(i); if (q.front() < i - k) q.pop_front(); // ✗ 差一格 if (i >= k) cout << a[q.front()] << " \n"[i == n]; }}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
solve(a, n, k, true); solve(a, n, k, false); return 0;}点「运行 ▶」看结果
它给 -1 -3 -3 -3 -3 3 —— ⚠ 和上面那个「存值不存下标」的输出一模一样。
★ 第 28 章那条「两个不同的 bug,症状可以一模一样」的第二次。 而这一章还量出了更狠的:300 组数据里, 「忘了弹队头」和「出界差一格」同时对、同时错,一次例外都没有(各 182 / 300, 其中 58 组连输出都相同)。 一个方向能证明(差一格错了 ⇒ 队头是陈旧的 ⇒ 前缀最值也错), 反过来只是实测没碰到反例 —— 这两句话的分量差得很远。 ⚠ 对拍只能告诉你「错了」,不能告诉你「错在哪」。 定位得靠 trace。
// ✗ 错误版本九:复制粘贴求最大值的那一半,忘了把不等号方向改过来//// 这份代码为什么存在:它不是「想错了」,是「手快了」——// 而这种 bug 在竞赛里出现的频率比任何一个算法错误都高。//// 求最小值的队列要求「队里的值递增」,所以队尾弹掉的是 `>= a[i]` 的;// 求最大值要反过来(队里递减、弹掉 `<= a[i]` 的)。// 这一份把最小值那段整个复制过来,只改了变量名,**不等号忘了改**。//// ★ 于是它两行输出里**第一行永远是对的、第二行永远是错的** ——// 这正好演示了一件事:**对拍逐字节比,比「只比一个数」值钱**。// 如果这道题只让你输出「所有窗口最小值之和」这么一个数,// 第二行那个 bug 根本没有出场的机会。// (第 27、28 章那句「一份方案能自证清白,一个数字不能」的又一张脸。)//// 输入输出同 winBrute.cpp。
#include <bits/stdc++.h>using namespace std;
static void solve(const vector<long long>& a, int n, int k, bool wantMin) { deque<int> q; for (int i = 1; i <= n; i++) { while (!q.empty() && a[q.back()] >= a[i]) // ✗ 求最大值时这里也没改方向 q.pop_back(); q.push_back(i); if (q.front() <= i - k) q.pop_front(); if (i >= k) cout << a[q.front()] << " \n"[i == n]; } (void)wantMin;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
solve(a, n, k, true); solve(a, n, k, false); return 0;}点「运行 ▶」看结果
它两行输出一模一样 —— 第一行(最小值)永远是对的,第二行永远是错的。
★ 这正好说明一件事:题面让你输出两整行,比让你输出一个数值钱得多。 如果这道题只要「所有窗口最小值之和」这么一个数,第二行那个 bug 根本没有出场机会。 (第 27、28 章「一份方案能自证清白,一个数字不能」的又一张脸。) ⚠ 第 12 步会量出这句话到底值多少 —— 那是这一章最刺眼的一张表。
// 滑动窗口 —— 把单调队列**每一步**的队列内容打出来(动画照着这张表画)//// 这份代码为什么存在:和 trace.cpp 一样,check:viz 拿它和网页动画**逐行**对 ——// 只比最终答案的话,「答案蒙对、过程画错」根本发现不了。//// 每一步打四样:// · 队尾弹了几个(因为「又老又不如新来的」)// · 队头弹了几个(因为**滑出窗口**了)—— ★ 这一列是单调队列比单调栈多出来的那一头// · 队列里剩哪些**下标**// · 这一格的窗口最小值(i < k 时还没有完整窗口,打「—」)//// ⚠ cout / printf 混用,不能关 sync(第 26 章那条)。//// 输入:同 winBrute.cpp(只演示最小值那一半)
#include <bits/stdc++.h>using namespace std;
static string padDisp(const string& s, int width) { int disp = 0; for (size_t i = 0; i < s.size();) { unsigned char c = s[i]; if (c < 0x80) { disp += 1; i += 1; } else if ((c >> 5) == 6) { disp += 1; i += 2; } else if ((c >> 4) == 14) { disp += 2; i += 3; } else { disp += 2; i += 4; } } return s + string(max(0, width - disp), ' ');}
int main() { int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
printf("%s%s%s%s%s%s\n", padDisp("步", 6).c_str(), padDisp("新来", 9).c_str(), padDisp("队尾弹", 9).c_str(), padDisp("队头弹", 9).c_str(), padDisp("队列里的下标", 20).c_str(), "窗口最小值"); printf("%s\n", string(70, '-').c_str());
deque<int> q; long long pushes = 0, backPops = 0, frontPops = 0; for (int i = 1; i <= n; i++) { int b = 0, f = 0; while (!q.empty() && a[q.back()] >= a[i]) { q.pop_back(); b++; backPops++; } q.push_back(i); pushes++; if (q.front() <= i - k) { q.pop_front(); f++; frontPops++; }
string body; for (int x : q) { if (!body.empty()) body += " "; body += to_string(x); } string ansCol = (i >= k) ? to_string(a[q.front()]) : "—"; printf("%s%s%s%s%s%s\n", padDisp("步 " + to_string(i), 6).c_str(), padDisp("a=" + to_string(a[i]), 9).c_str(), padDisp(to_string(b) + " 个", 9).c_str(), padDisp(to_string(f) + " 个", 9).c_str(), padDisp("[" + body + "]", 20).c_str(), ansCol.c_str()); }
printf("%s\n", string(70, '-').c_str()); printf("★ 入队 %lld 次;出队 %lld 次(队尾 %lld + 队头 %lld),还剩 %zu 个在队里没人赶 ——\n" " 每个下标**至多**进出各一次,这就是全部。\n", pushes, backPops + frontPops, backPops, frontPops, q.size()); cout << "★ 两个 while 加起来转不过 2n 圈,所以它是 O(n) —— 和单调栈是同一句话," "只是多管了一头。\n"; return 0;}点「运行 ▶」看结果
11★ 兑现预告②:多重背包的 O(nW)
第 24 章讲完二进制拆分之后欠了一句话:「还能做到 O(nW),等第 35 章讲完单调队列再回来收尾」。
多重背包的转移是(第 i 种物品,价值 v、重量 w、至多 k 件):
f[j] = max over 0 ≤ t ≤ k of f[j − t·w] + t·v★ 右边只用到 j, j−w, j−2w, … —— 下标模 w 同余的那一串,彼此谁也够不着谁。
于是把容量按 j mod w 分组。设 j = r + s·w,g[s] = f[r + s·w]:
g[s] = max over 0 ≤ t ≤ k of g[s − t] + t·v
= max over s−k ≤ s' ≤ s of ( g[s'] − s'·v ) + s·v ← 换元 s' = s − t括号里那一坨只和 s’ 有关,s·v 提得出去 ——
于是它就是「在 g[s'] − s'·v 这个序列上求宽度 k+1 的滑动窗口最大值」,
正是这一章前半章那道题。每个容量只被处理一次 → O(nW)。
★ 那个「提出去的 s·v」还顺带把第 9 步那句话又说了一遍: 队列里比的是
g[s'] − s'·v(值),窗口边界是拿 s(下标)判的。
// 多重背包 O(nW):单调队列优化 —— ★ 还第 24 章欠下的那笔账//// 这份代码为什么存在:第 24 章讲完二进制拆分(O(W × Σlog k))之后,// 章末白纸黑字写着「还能做到 O(nW),等第 35 章讲完单调队列再回来收尾」。这就是收尾。//// ★ 关键一步只有一句话:**把那个 max 写出来,它就是一个滑动窗口最大值。**//// 多重背包的转移是(第 i 种物品,价值 v、重量 w、至多 k 件)://// f[j] = max_{0 ≤ t ≤ k} f[j − t·w] + t·v//// 右边只用到 `j, j−w, j−2w, …` —— **下标模 w 同余的那一串**,彼此谁也够不着谁。// 于是把容量按 `j mod w` 分成 w 组,每组单独看。设 `j = r + s·w`,令 `g[s] = f[r + s·w]`://// g[s] = max_{0 ≤ t ≤ k} g[s − t] + t·v// = max_{s−k ≤ s' ≤ s} ( g[s'] − s'·v ) + s·v ← 把 t 换成 s' = s − t//// ★ 括号里那一坨**只和 s' 有关**,s·v 提得出去 ——// 于是它就是「在 `g[s'] − s'·v` 这个序列上,求一个宽度 k+1 的滑动窗口最大值」,// 正是这一章前半章那道题。每个容量只被处理一次 → **O(nW)**。//// ⚠ 这里有一个非写不可的细节:队列里必须存**算好的那个值**,不能存下标之后回头去读 `f`——// 因为 `f[r + s·w]` 在这一行就被覆盖掉了。**读要在写之前**(代码里 val 那一行)。// ★ 这和第 23 章「一维倒序」是同一个毛病的两种解法:那边靠倒序避开「读到本轮写过的」,// 这边靠「先读进队列」避开。**转移的读写次序,从来都是 DP 的一部分。**//// ⚠ 那个「提出去的 s·v」也解释了为什么单调队列要存下标:// 队列里比的是 `g[s'] − s'·v`(去掉 s·v 之后的**可比量**),// 而窗口边界 `s − k` 是拿**下标**判的。★ 值用来比大小,下标用来判出没出窗口 —— 两件事。//// 对拍:和第 24 章的 multi.cpp(二进制拆分)、multiBrute.cpp(DFS 枚举每种拿几件)// 用**同一个生成器** `code/24-knapsack-multi/multiGen.cpp` 逐字节比。// ★ 这是本教材第二次**跨章节交叉验证**(第一次是第 30 章把第 13 章那张网格图转成图跑)。//// 输入输出同 code/24-knapsack-multi/multiBrute.cpp:// 第一行 n W;接下来 n 行每行 v w k
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; if (!(cin >> n >> W)) return 0;
vector<long long> f(W + 1, 0); for (int i = 0; i < n; i++) { long long v, w, k; cin >> v >> w >> k; if (w <= 0) continue; // 重量为 0 的物品没有「窗口」可言 if (k > W / w) k = W / w; // 装不完那么多,截断(不截也对,只是白转)
for (long long r = 0; r < w && r <= W; r++) { // 这一组的容量是 r, r+w, r+2w, …;s 是它在组里的编号 deque<pair<long long, long long>> q; // (s, f_旧[r+s·w] − s·v) for (long long s = 0; r + s * w <= W; s++) { long long val = f[r + s * w] - s * v; // ★ 读要在写之前 while (!q.empty() && q.back().second <= val) q.pop_back(); q.push_back({s, val}); if (q.front().first < s - k) q.pop_front(); // 出了宽度 k+1 的窗口 f[r + s * w] = q.front().second + s * v; } } }
cout << f[W] << "\n"; return 0;}点「运行 ▶」看结果
第 24 章那组数据(n = 3、W = 12)上它给 19 —— 和那一章的二进制拆分一个字不差。
long long val = f[r + s * w] - s * v; // ★ 此刻 f 还是「上一件物品处理完」的值
…
f[r + s * w] = q.front().second + s * v; // 这一行才覆盖掉它队列里必须存算好的那个值,不能只存下标、回头再去读 f —— 因为 f 已经被覆盖了。
★ 这和第 23 章「一维倒序」是同一个毛病的两种解法: 那边靠倒序避开「读到本轮写过的」,这边靠先读进队列避开。 转移的读写次序,从来都是 DP 的一部分。
cd code/24-knapsack-multi && g++ -O2 -std=c++17 -o multiGen multiGen.cpp
for s in $(seq 1 300); do ./multiGen $s > t.txt
diff <(../35-monotonic/multiQueue < t.txt) <(./multi < t.txt) || echo "seed $s 不一致"
done300 组,一组不差(枚举每种拿几件的 multiBrute.cpp 也一起对了)。
这是本教材第二次跨章节交叉验证(第一次是第 30 章把第 13 章那张网格图转成图跑)。
./genBig <kmax> 500 100000(第 24 章那个生成器,n = 500、W = 100000):
| 每种物品最多 k 件 | 二进制拆分 O(W × Σlog k) |
单调队列 O(nW) |
|---|---|---|
| 10 | 0.06 秒 | 0.18 秒 |
| 100 | 0.14 秒 | 0.16 秒 |
| 1 000 | 0.21 秒 | 0.14 秒 |
| 10 000 | 0.29 秒 | 0.13 秒 |
★ 交叉点在 k ≈ 100。 k 小的时候 log k 才三四,
而单调队列的常数明显更重(要按余数分组、要维护 deque、访问 f 是跳着走的)。
★ 第 29、32、33、34 章那条「口诀要拿实测复核」的第五次。 结论要说准:不是「单调队列更快」,是「k 大的时候单调队列更快」。 ⚠ 而竞赛里绝大多数多重背包的 k 都不大 —— 所以二进制拆分至今仍是首选, 它还短得多、也不容易写错。
12★ 对拍与生成器:两个生成器,两次被实测打脸
// 柱状图里最大的矩形 —— 正解:单调栈 O(n)//// 这份代码为什么存在:它是本章的主角。//// ★ 换一个问法就全通了:**以第 t 根柱子的高度为高**的最大矩形,宽能有多少?// 往左能扩到「左边第一根**比它矮**的柱子」的右边,往右扩到「右边第一根比它矮的」的左边。// 于是问题变成:**对每一根柱子,求出它左右两侧第一个更矮的柱子在哪。**// (★ 请注意:问的是**位置**,不是值 —— 所以栈里存的必须是**下标**。)//// 单调栈就是干这个的:栈里的下标,对应的高度**从栈底到栈顶严格递增**。// 新来一根 cur,把栈顶所有「不比 cur 矮」的都弹掉 ——// 对每个被弹出的 t,此刻的两件事同时揭晓:// · 右边第一个比它矮的,就是**当前这个 i**;// · 左边第一个比它矮的,就是**它被弹掉之后的新栈顶**(因为栈里是递增的)。// 所以宽 = i − 新栈顶 − 1(★ 那个 −1 是「不含两端那两根更矮的」,wrongWidth.cpp 就错在这里)。//// ★★ 关键一步是**均摊分析**:这里明明有两层循环(for 里套 while),但它是 O(n) ——// 因为每根柱子**一辈子只入栈一次、只出栈一次**,那句 while 转的总次数不会超过 n。// 这和第 7 章双指针「两个指针都只往前走,所以是 O(n)」是同一件事。// count.cpp 把这两个次数数出来给你看。//// ⚠ 末尾那根 −1 的哨兵:扫完之后栈里通常还剩一串(越往栈顶越高),// 它们的右边界还没揭晓。放一根「比所有柱子都矮」的哨兵,就能把它们全逼出来,// 主循环一个字都不用改。忘了这件事就是 wrongTail.cpp。// ⚠ 为什么写 −1 而不是 0:题目允许 h = 0,写 −1 才**严格**比所有柱子矮,一句话就说得清。// (这道题写 0 其实也对 —— 高 0 的柱子面积也是 0,漏算了也不影响最大值 ——// 但那要多想一步。★ 哨兵的意义就是「不用多想一步」,别给自己留这种账。)//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; h[n + 1] = -1; // 哨兵:比任何柱子都矮,把栈里剩下的全逼出来
long long ans = 0; vector<int> st; // ★ 存的是**下标**,对应高度严格递增 st.reserve(n + 1);
for (int i = 1; i <= n + 1; i++) { while (!st.empty() && h[st.back()] >= h[i]) { int t = st.back(); st.pop_back(); long long left = st.empty() ? 0 : st.back(); // 左边第一个比 h[t] 矮的下标(没有就当 0) long long w = i - left - 1; ans = max(ans, h[t] * w); } st.push_back(i); }
cout << ans << "\n"; return 0;}300 轮实测,五个错误版本:
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
| 弹出方向写反 | 300 / 300 | 第 1 轮 |
| while 写成 if | 246 / 300 | 第 1 轮 |
| 宽度只从被弹出那根算起 | 206 / 300 | 第 3 轮 |
| 忘了哨兵 | 199 / 300 | 第 1 轮 |
| 面积用 int(溢出) | ★ 0 / 300 | — |
gen.cpp 带了八个档位(./gen 种子 档位),种子固定 1..300:
| 档位 | 改了什么 | 宽度差一 | 忘了哨兵 | 只弹一根 | 方向反 |
|---|---|---|---|---|---|
| 0(最初) | 纯随机,值域 [0, 999] | 217 | 110 | 235 | 300 |
| 1 | 值域压到 [0, 9] | 227 | 93 | 196 | 300 |
| 2 | 造「台阶」(一定概率抄上一根) | 256 | 122 | 164 | 297 |
| 3(在用) | 结尾接一段递增 | 206 | 199 | 246 | 300 |
| 4 | 再多造些 0 高柱 | 184 | 185 | 219 | 300 |
★ 档位 3 那一行是这一章的定盘星:结尾递增这一处改动, 把「忘了哨兵」从 122 抬到 199,「只弹一根」从 164 抬到 246 —— 因为这个 bug 的现场只有一种:答案落在结尾那段没人结算的柱子上。
★ 档位 4 是一次撤回。 题面允许 h = 0,多造点 0 高柱看起来只会让覆盖更全, 实测却是三个 bug 一起掉(206→184、199→185、246→219)。 原因很实在:0 高柱把柱状图切成了几段短的,每段能攒的栈都变浅了, 而这一章的 bug 全都要靠「栈里攒着好几根」才现形。 ⚠ 而且 0 本来就有 —— 值域是 [0, 9],档位 3 已经有 114 / 300 组带 0 了。
★ 「多加一点」和「数据变好了」是两件事(第 33 章那条的第二次; 第 34 章「调生成器要允许撤回」的第二次)。
| 档位 | 和「在用」的差别 | 宽度差一 | 忘了哨兵 | 只弹一根 | 方向反 |
|---|---|---|---|---|---|
| 3(在用) | —— | 206 | 199 | 246 | 300 |
| 5(对照) | 撤回「台阶」 | 176 | 186 | 249 | 300 |
| 6(对照) | 撤回「结尾递增」 | 256 | 122 | 164 | 297 |
| 7(对照) | 撤回「值域压小」 | 227 | 199 | 249 | 300 |
- 台阶(档位 5):撤了之后「宽度差一」掉 30 —— 有用,留。
- 结尾递增(档位 6):撤了之后「忘了哨兵」掉到 122 —— 最有用的一处,留。
- ⚠ 值域压小(档位 7):撤了之后几乎一个数都没变(甚至略好一点)。
★ 为什么值域压小没用了?因为「台阶」那处改动把它想干的事包办了 —— 并列的高度是靠「抄上一根」造出来的,和值域宽窄没关系。
★ 第 32 章「调优不可加:一处改动值不值得留,取决于其它旋钮此刻在哪」的第三次。 两处改动想做同一件事时,后来的那处会把前面那处吃掉。
它最后还是留下了,但理由不是抓获率:值域 [0, 999] 时 300 组里只有 2 组带 0 高柱,
而 h = 0 是题面明确允许的边界(第 24 章那条:退化的那一端也必须造)。
这笔账明写在这里,不粉饰成「改了就是更好」(第 27 章档位 3 的同款)。
// 滑动窗口最值 —— 正解:单调队列 O(n)//// 这份代码为什么存在:它是本章后半章的主角,也是**均摊分析的第二次登场**。//// ★ 关键一步和单调栈是同一句话,只是多了一头:// 队列里存**下标**,对应的值单调(求最小值时递增)。// · **队尾**:新来一个 a[i],把队尾所有「不比它小」的都弹掉 ——// 理由是那些家伙**又老又大**,只要 a[i] 还在窗口里,它们永远轮不到当最小值。// · **队头**:如果队头那个下标已经滑出窗口(≤ i−k),弹掉。// ★ **这就是为什么队列里必须存下标**:光存值,你根本不知道它是什么时候进来的// (winWrongVal.cpp 就死在这里)。// 于是队头永远是当前窗口的最小值,O(1) 取到。//// ★★ 还是均摊:每个下标**只入队一次、只出队一次**(要么被队尾挤掉、要么从队头滑走),// 所以两个 while 加起来转不过 2n 圈。和单调栈、和第 7 章双指针,是同一件事的三张脸。//// ⚠ 单调栈和单调队列的区别只有一句:**单调栈只从一头进出,单调队列两头都要动** ——// 多出来的那一头,管的正是「窗口有多宽」这件事。//// 输入输出同 winBrute.cpp。
#include <bits/stdc++.h>using namespace std;
/** wantMin = true 求每个窗口的最小值,false 求最大值 —— 两者只差一个不等号方向 */static void solve(const vector<long long>& a, int n, int k, bool wantMin) { deque<int> q; // ★ 存下标,不存值 for (int i = 1; i <= n; i++) { // ① 队尾:把「又老又不如新来的」全弹掉 while (!q.empty() && (wantMin ? a[q.back()] >= a[i] : a[q.back()] <= a[i])) q.pop_back(); q.push_back(i); // ② 队头:滑出窗口的弹掉(一次最多滑出一个,所以 if 就够) if (q.front() <= i - k) q.pop_front(); // ③ 从第 k 个数开始,每一格都有一个完整的窗口 if (i >= k) cout << a[q.front()] << " \n"[i == n]; }}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, k; if (!(cin >> n >> k)) return 0; vector<long long> a(n + 1); for (int i = 1; i <= n; i++) cin >> a[i];
solve(a, n, k, true); solve(a, n, k, false); return 0;}300 轮实测,四个错误版本:
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
| 求最大值那半忘了改方向 | 269 / 300 | 第 1 轮 |
| 忘了弹出界的队头 | 182 / 300 | 第 1 轮 |
| 出界判断差一格 | 182 / 300 | 第 1 轮 |
| 队列存值不存下标 | 144 / 300 | 第 1 轮 |
(这 300 轮里:k = 1 的有 31 组、k = n 的有 37 组、有并列的 148 组、含负数的 292 组;
「忘了弹队头 ≡ 前缀最值」300 / 300 成立。这些数都钉在 check:viz 里。)
| 档位 | 改了什么 | 存值 | 忘弹队头 | 出界差一 | 方向没改 |
|---|---|---|---|---|---|
| 0(最初) | k 固定 3,值域 [0, 999] | 247 | 262 | 262 | 300 |
| 1 | k 取遍 1..n,两端占大头 | 79 | 157 | 157 | 227 |
| 2 | 把两端的比例降下来(各 1/8) | 147 | 189 | 189 | 269 |
| 3(在用) | 值域压到 [−4, 4] | 144 | 182 | 182 | 269 |
| 4(对照) | 数据排成单调不增 | 174 | 239 | 239 | 269 |
| 4(只看最小值那一行) | 同上 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
★ 档位 1 是「某一支占得太多」的第五次:k 取到 1 和 n 是两个退化端 (k = 1 时答案恒等于原数组,k = n 时只有一个窗口)—— 加上它们本身是对的(第 24 章那条), 可一口气占到 180 / 300,四个 bug 全线腰斩。降到各八分之一之后回涨。 ⚠ 但要老实说:回涨之后(147/189/189/269)仍然不如什么都不加的档位 0(247/262/262/300)。 这两个退化端是拿抓获率换来的覆盖,我留下了它们,账写在这里。
★★ 档位 4 才是这一章最值得记的一格。 我本来断定「数据一单调,四个 bug 一起隐身」—— 因为窗口最小值永远待在最右边,滑不滑走都一样。只对了一半:
- 只看最小值那一行:四个 bug 全部 0 / 300,一个都测不出来 —— 直觉是对的;
- 可题面同时要最大值那一行,在那一行上它们全现形了(174 / 239 / 239 / 269)。
★ 同一个顺手写法,在同一道题的两问里效果正好相反。 这也解释了这道题为什么要一次问两个方向:多问一问,对拍就多一条腿。 (第 34 章那条「顺手写法危不危险,取决于题目在问什么」的加强版 —— 现在连「同一道题的两个问法」都能差出 0 和 239。)
13实测:暴力有多慢(两种形状都要给)
本机实测 · 柱状图(./genBig n 0 随机 / ./genBig n 1 单调不降):
| n | 枚举所有段 O(n²) |
往左右扩(随机) | 往左右扩(单调不降) | 单调栈 |
|---|---|---|---|---|
| 20 000 | 0.17 秒 | 0.00 秒 | 0.07 秒 | 0.00 秒 |
| 50 000 | 1.12 秒 | 0.00 秒 | 0.46 秒 | 0.00 秒 |
| 100 000 | 4.53 秒 | 0.01 秒 | 1.96 秒 | 0.00 秒 |
| 200 000 | 17.45 秒 | 0.02 秒 | 7.10 秒 | 0.01 秒 |
g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o expand expand.cpp
./genBig 200000 1 > up.txt && time ./expand < up.txt # 7.10 秒
./genBig 200000 0 > rd.txt && time ./expand < rd.txt # 0.02 秒
★ 中间那两列是同一份代码。只有数据的形状变了。 (第一列和形状无关:同样 n = 200 000,随机上 17.45 秒、单调上 16.8 秒 —— 它没有任何提前退出。)
本机实测 · 滑动窗口(./winGenBig <k>,n 固定 200 000):
| k | 每个窗口扫一遍 O(nk) |
单调队列 O(n) |
|---|---|---|
| 10 | 0.03 秒 | 0.03 秒 |
| 100 | 0.06 秒 | 0.03 秒 |
| 1 000 | 0.20 秒 | 0.03 秒 |
| 10 000 | 1.50 秒 | 0.03 秒 |
| 100 000 | 8.14 秒 | 0.02 秒 |
./winCount io(只读入、什么都不算)在同一份数据上是 0.01 秒,
而输出 40 万个数又要一截 —— 单调队列本身几乎量不出来。
★ 第 29、32、34 章那条「量之前先确认「你量的就是它」」的第四次。 ⚠ 而且这次还顺带踩了它的另一张脸:
winCount.cpp一开始用的是scanf, 而winFast.cpp用的是cin(关了 sync)—— 两份代码的 I/O 设置不一致, 量出来的「读入耗时」根本不是它的读入耗时(第 32 章那条)。现在两边都用 cin。
⚠ 也正因为如此,这张表的旋钮必须是 k 而不是 n: 它们的差距完全由 k 决定,和 n 只是同比例放大 (第 30 章「指数的底数藏在密度里,不在规模里」的直系亲戚)。
14这一章可以带走的五样东西
【1】★★ 均摊分析:不是「内层转得少」,而是「每个元素一辈子只能出去一次」。
for 里套 while 照样可以是 O(n) —— 哪怕某一步一口气弹掉 n−1 个,
因为那 n−1 个再也不会回来了。
把这个理由记反了,就会把 while 写成 if(那才是真 bug)。
接的是第 7 章双指针那句「两个指针都只往前走」。
【2】★ 单调栈解的是「左右第一个更小 / 更大的在哪」,而不是「最值是多少」。 被弹出的那一刻,两个边界同时揭晓:右边界是当前这个 i,左边界是弹掉它之后的新栈顶。 ★ 所以栈里存的必须是下标 —— 宽度要靠下标相减。 方向(递增还是递减)由你要问的问题决定,不是背的。
【3】★ 单调队列 = 单调栈 + 多管一头。 队尾那句和单调栈一模一样;队头那句管的是「窗口只有 k 宽」。
★ 值用来比大小,下标用来判出没出窗口 —— 两件事,缺一不可。 忘了队头那一行,它就精确地变成了前缀最值(第十一条恒等式,300 组一组不差)。
【4】★ 多重背包的 O(nW):把那个 max 写出来,它就是滑动窗口最大值。
按 j mod w 分组,换元把 s·v 提出去,剩下的正是本章前半章那道题。
⚠ 但实测的交叉点在 k ≈ 100:k 小的时候二进制拆分更快(log k 才三四,
而单调队列常数重)。口诀要拿实测复核,这是第五次。
【5】★ 生成器的两次打脸,都在同一个地方:我以为的「关键的量」不是那个量。
- 「值域压小」在最终环境里几乎没用 —— 因为「造台阶」把它想干的事包办了 (调优不可加,后来的改动会吃掉前面的);
- 「多造 0 高柱」看着更全面,实测三个 bug 一起掉(0 把柱状图切碎了,栈攒不起来)—— 撤回;
- 而「数据排成单调不增」只让最小值那一行全灭(0 / 300),最大值那一行照样抓 239。
★ 同一个顺手写法,在同一道题的两问里效果可以正好相反。
第 36 章:并查集。
★ 第 34 章已经把它的基本操作讲完了(find / unite + 路径压缩),
所以下一章不重复讲用法,专讲为什么它快到几乎是 O(1) ——
路径压缩 + 按秩合并的复杂度,以及实测:
不压缩 / 只压缩 / 压缩加按秩,三条曲线到底差多少。
⚠ 顺带把这一章的均摊分析再推一步:并查集那个 O(α(n)) 也是均摊出来的,
而且它比「每个元素进出各一次」难得多 —— 这一章是那一章的热身。
15自测
- 洛谷 P5788 【模板】单调栈解析 → —— 最裸的那一问:每个数右边第一个更大的在哪。本章第 5 步那段代码去掉算面积就是它
- 洛谷 P1886 滑动窗口 / 单调队列解析 → —— 本章后半章的原题,两行输出一个字不差
- 洛谷 SP1805 HISTOGRA - Largest Rectangle in a Histogram解析 → —— 本章前半章的原题。⚠ 记得开 long long
- 洛谷 P2866 [USACO06NOV] Bad Hair Day S解析 → —— ★ 换个方向问「右边第一个更高的」—— 正好检验你有没有把方向背成死的
- 洛谷 P1440 求m区间内的最小值解析 → —— 单调队列,但窗口是「前 m 个」—— 边界比模板题还容易写错,正好练第 10 步那个差一
- 洛谷 P1776 宝物筛选解析 → —— ★ 多重背包,n·W 到了 10⁷ 级别 —— 本章第 11 步那份 O(nW) 的用武之地
- 洛谷 P4147 玉蟾宫解析 → —— 进阶:把柱状图那道题在「每一行」上做一遍,就是最大子矩形。这一步跨得比看起来小