阶段 7 · 数据结构 · 第 35 章提高组 S

单调栈与单调队列

两道题,一个道理:把「已经没戏的」当场扔掉,剩下的自然是单调的。★ 关键一步是均摊分析 ——「for 里套 while」为什么还是 O(n):每个元素一辈子只能出去一次。

需要先学:第 7 章 双指针与滑动窗口、第 24 章 完全背包与多重背包例题:柱状图里最大的矩形 + 滑动窗口最值建议用时:135 分钟
这一章要还两笔账

第 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手算一遍:默认那张图

★ 图 A:一根 0 高柱把它切成两段,结尾是一段递增
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标准答案:把定义直接翻译成代码

brute.cpp标准答案:枚举所有连续段 O(n²)
// 柱状图里最大的矩形 —— 标准答案:枚举所有区间 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案要用「枚举段」而不是「每根柱子往外扩」

正解想的是「对每根柱子,左右第一个更矮的在哪」。 要是标准答案也这么想,两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 9、15、34 章那条)。

所以这一份换个思路:每一段连续的柱子都试一遍,高取这段里最矮的。 mn 边扫边更新,所以是 O(n²) 而不是 O(n³)。

★ 更要紧的是:它两层循环里一次 break 都没有 —— n² 就老老实实是 n²。 下一步你会看到,这件事一点都不多余。

4★ 另一份「更聪明」的暴力,以及它怎么假装自己不慢

expand.cpp✗ 陷阱:以每根柱子为高,往左右扩
// 柱状图 —— 另一份「看起来更聪明」的 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这份代码的思路和正解一模一样,只是老老实实地一根一根往外挪:

while (l - 1 >= 1 && h[l - 1] >= h[i]) l--;      // ★ 一碰到更矮的就停
while (r + 1 <= n && h[r + 1] >= h[i]) r++;

最坏情况当然是 O(n²)。可随机数据上它快得像 O(n):

⚠ 本机实测:同一份代码、同样 n = 200000,只换数据的形状
数据形状 两个 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 自己弹掉了)。

★ 「栈里存的是下标不是高度」这件事,理由就在这一行:宽度要靠下标相减。

fast.cpp正解:单调栈 O(n)
// 柱状图里最大的矩形 —— 正解:单调栈 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 那根哨兵是干什么的

扫完之后栈里通常还剩一串(越往栈顶越高)—— 它们的右边界从来没揭晓过, 因为再没有更矮的柱子来把它们弹出去了。

在末尾放一根「比谁都矮」的哨兵(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)」,说的是同一件事。

count.cpp不讲道理,直接数:三种写法各碰了多少次数据
// 柱状图 —— 把三种写法的**工作量**数出来(★ 均摊分析的证据)
//
// 这份代码为什么存在:「单调栈是 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

默认那张图(n = 8)上:

写法 碰数据的次数
★ 单调栈:入栈 9(= n+1,含哨兵)
★ 单调栈:出栈 8(= n,哨兵只进不出,没人来弹它)
单调栈:while 判断 17
枚举所有区间(brute) 36
往左右扩(expand) 32
★ 那两个数和数据长什么样毫无关系 —— 这就是全部的证据

把柱状图换成单调递增、单调递减、全都一样高……入栈永远是 n+1、出栈永远是 n。 而 expand 那一行会从几十跳到几百亿(第 4 步那张表)。

★ 一个是「和数据无关的常数级工作量」,一个是「随数据形状剧烈变化」—— 这就是 O(n) 和 O(n²) 在计数器上的样子。

7★ 动画一:柱子和栈同屏,看那两个计数器

一根一根扫过去:被挤出去的那一刻,它的两个边界同时揭晓
答案 15
第 1 / 19 步
2
1
1
2
5
3
0
4
3
5
5
6
5
7
6
8
蓝 = 还在栈里 红 = 这一步刚被挤出去 绿 = 当前这一根 虚线框 = 刚结算的矩形
★ 累计入栈
0
★ 累计出栈
0
★ 已结算矩形
0
当前最大面积 0
栈(栈底 → 栈顶)
(空)
★ 存的是下标,不是高度 —— 宽度要靠下标相减才算得出来
开始:栈是空的。★ 栈里存的是「下标」,对应的高度从栈底到栈顶递增。

左边是柱状图,右边是栈。每弹出一根,就把它对应的矩形当场画出来(虚线框)—— 因为「被弹出」的那一刻,正是它两个边界同时揭晓的那一刻。

★ 请盯着右边那三个计数器看,并把「柱状图」那个下拉框逐个切一遍
柱状图 入栈 出栈 结算的矩形数
默认那张 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 里。)

⚠ 但请注意它们过程完全不同:单调不降时前面一根都弹不掉、全靠哨兵一次弹光; 单调不增时每来一根就弹一根。总量一样,节奏完全不一样 —— 均摊说的正是这件事。

trace.cpp逐步打印栈的内容(动画就是照着这张表画的)
// 柱状图 —— 把单调栈**每一步**的栈内容打出来(动画就是照着这张表画的)
//
// 这份代码为什么存在: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

check:viz 拿这张表和动画逐行对(第 23 章那条)—— 不只比最终答案, 每一步「弹出了几根、栈里剩哪些下标、当前 ans」都要一致。 否则「答案蒙对、过程画错」根本发现不了。

8四种把它写错的方式,外加一种「怎么写都对」

✗ 一、宽度只从被弹出那根算起(忘了往左还能扩)
wrongWidth.cpp✗ w = i − t
// ✗ 错误版本一:宽度只从被弹出的那根算起 —— 忘了它往左还能扩
//
// 这份代码为什么存在:它是单调栈**最常见**的手滑,而且症状极温和 ——
// 答案只是偏小一点,不崩溃、不越界。
//
// ✓ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 12(正解 15)。

w = i − t 相当于说「这根柱子只能从它自己站的地方往右铺」, 可它明明还能往左铺 —— 那些被它更早弹掉的、比它高的柱子,站的地方它当然也能站。

⚠ 它挑数据:如果最大矩形正好就是某一根柱子自己(宽 1),两种写法算出来一样。

✗ 二、忘了哨兵(栈里剩的一概不管)
wrongTail.cpp✗ 扫到 n 就走
// ✗ 错误版本二:忘了那根哨兵 —— 扫完就走,栈里剩下的一概不管
//
// 这份代码为什么存在:它错的不是循环体,是**收尾**。
//
// 扫到最后,栈里通常还剩一串柱子(从栈底到栈顶越来越高),
// 它们的**右边界一直没揭晓** —— 因为再也没有比它们矮的柱子来把它们弹出去了。
// 正解的做法是在末尾放一根 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 5 —— 正好是左半段的答案。

★ 因为图 A 结尾那一段(3 5 5 6)是递增的,它们全留在栈里没人结算, 而答案 15 恰恰就在那一段里。 ⚠ 所以这个 bug 只在「答案落在结尾那段递增上」时才现形 —— 生成器要是顺手让数据整体递减,它就永远 0 / 300。

✗ 三、while 写成 if(一次只弹一根)—— ★ 均摊分析的反面教材
wrongOnce.cpp✗ if 代替 while
// ✗ 错误版本三: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 6(正解 15)。

★ 写成 if 的人,心里那个理由通常是「一次弹一个才平衡,这样才是 O(n)」—— 这个理由整个是反的(第 6 步)。而代价是:该弹的没弹干净, 栈从此不再单调,后面每一次「左边第一个更矮的」都可能读到一个比它更高的下标。

★ 把动画切到这一档,你会肉眼看见栈不再递增 —— 这个 bug 是能看出来的。

✗ 四、弹出方向写反(维护成了递减栈)
wrongDir.cpp✗ >= 写成 <=
// ✗ 错误版本四:弹出条件的方向写反了(维护成了单调递**减**栈)
//
// 这份代码为什么存在:单调栈只有一个参数需要想清楚 —— **栈里到底该单调递增还是递减**。
// 想反了,代码照样跑得欢,答案照样是个正数。
//
// ✓ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 48,离谱到一眼就能看出来(300 轮全被抓住)。

★ 方向由你要问的问题决定,不是背的: 这道题问「左右第一个更矮的」,所以栈里必须「越往上越高」; 要是问「第一个更高的」(比如「几天之后会有更暖和的一天」),方向就要整个反过来。

✗ 五、面积用 int —— ★ 而这一份对拍「永远」抓不到
wrongInt.cpp✗ long long 全换成 int
// ✗ 错误版本五:面积用 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 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 章「随机数据碰不到最坏情况」)。

★ 那个「怎么写都对」的:等号写哪边?
eq.cpp`>=` 和 `>` 并排跑
// 柱状图 —— `>=` 还是 `>`?两种写法并排跑
//
// 这份代码为什么存在:单调栈那句弹出条件,等号写哪边是初学者最纠结的地方:
//
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
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 个数(最大值)。

winBrute.cpp标准答案:每个窗口扫一遍 O(nk)
// 滑动窗口最值 —— 标准答案:每个窗口老老实实扫一遍 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

默认那组 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 宽」这件事。

★ 单调栈只从一头进出,单调队列两头都要动。多出来的那一头,就是「窗口」这两个字的全部代价。

★ 而这一头也顺便解释了为什么队列里必须存下标: 值用来比大小,下标用来判出没出窗口 —— 两件事,缺一不可。 (单调栈那道题存下标是为了算宽度,也是「值答不了的那半」。)

winFast.cpp正解:单调队列 O(n)
// 滑动窗口最值 —— 正解:单调队列 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
winCount.cpp还是数一遍(附「只读入」开关)
// 滑动窗口 —— 把工作量数出来(★ 均摊分析的第二份证据),外加一个「只读入」的开关
//
// 这份代码为什么存在,有两个理由:
//
// ① ★ **入队次数和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 均摊的第二份证据:入队次数和 k 一点关系都没有

./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★ 动画二 + 四种把它写错的方式

两头都要动:队尾赶走「没戏的」,队头赶走「滑出去的」
第 1 / 20 步
1
1
3
2
-1
3
-3
4
5
5
3
6
6
7
7
8
绿框 = 当前窗口(宽 3) 蓝底 = 还在队列里 ⚠ 队列里通常**比窗口少得多**: 没戏的早被赶走了
·
·
·
·
·
·
·
·
每个窗口的最小值(前 2 格还凑不满一个窗口)
★ 入队
0
队尾出队
0
队头出队
0
队列(队头 → 队尾)
(空)
★ 存下标:值用来比大小,下标用来判出没出窗口
开始:队列是空的。★ 队列里存的是「下标」,这样才知道谁该滑出窗口。

上面是数组(绿框 = 当前窗口,蓝底 = 还在队列里),下面是每个窗口的答案,右边是队列和三个计数器。

★ 先看一件事:队列里的元素,通常比窗口里少得多

播一遍就会发现,绿框有 3 格宽,可队列里常常只有 1~2 个 —— 没戏的早在进来的时候就被赶走了。

这正是「为什么它是 O(n) 而不是 O(nk)」的直观版: 我们从来没有把窗口里的 k 个数都留着。

✗ 一、队列存值不存下标(本章后半章的核心反面教材)
winWrongVal.cpp✗ deque 里存的是值
// ✗ 错误版本六:单调队列里存**值**,不存下标
//
// 这份代码为什么存在:★ 它是本章后半章的核心反面教材,
// 而且它错的地方**恰好就是这道题和柱状图那道题唯一的区别**。
//
// 单调栈那道题只关心「左右第一个更矮的在哪」,队列(栈)里存值还是存下标,
// 影响的只是能不能算宽度;这道题多了一条「窗口只有 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它给 -1 -3 -3 -3 -3 3(正解 -1 -3 -3 -3 3 3)。

光存值,「队头那个还在窗口里吗」这个问题就再也答不上来了, 只好拿队列长度冒充窗口宽度 —— 可队列里装的从来不是「窗口里的所有数」, 它通常比 k 短得多。于是该滑走的没滑走。

★ 要不要存下标,取决于你还要不要问「它是什么时候进来的」。

✗ 二、忘了弹出界的队头 —— ★ 第十一条恒等式
winWrongPop.cpp✗ 少了「弹队头」那一行
// ✗ 错误版本七:忘了从队头弹掉「已经滑出窗口」的那个
//
// 这份代码为什么存在:它只少了一行,而且少的那一行正是「窗口」这两个字的全部含义。
//
// ✓ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它给 -1 -3 -3 -3 -3 -3。而它不是随机地错:

winPrefix.cpp前缀最值(根本没有队列,一路 min 过去)
// 前缀最小值 / 前缀最大值 —— 用来验那条恒等式的「另一种思路」
//
// 这份代码为什么存在:★ 第十一条恒等式的**另一半**。
//
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

一模一样。300 组随机数据一组不差(钉在 check:viz 里)。

★ 忘了弹队头 ≡ 前缀最值。 这是本教材第十一条这样的恒等式 (前十条在第 23、24、25、26、27、28、34 章)。 道理一句话:队头没人赶它走,而队尾那句 while 保证「比它更优的都进不来」—— 于是队头永远是从头到现在的最优值。 ⚠ 验法照旧:两份程序思路必须不同(一份用队列、一份一路 min 过去)。

✗ 三、出界判断差一格
winWrongEdge.cpp✗ `<= i-k` 写成 `< i-k`
// ✗ 错误版本八:判「滑出窗口」的那个不等号差了一格
//
// 这份代码为什么存在:滑动窗口这类题,一半的 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它给 -1 -3 -3 -3 -3 3 —— ⚠ 和上面那个「存值不存下标」的输出一模一样。

★ 第 28 章那条「两个不同的 bug,症状可以一模一样」的第二次。 而这一章还量出了更狠的:300 组数据里, 「忘了弹队头」和「出界差一格」同时对、同时错,一次例外都没有(各 182 / 300, 其中 58 组连输出都相同)。 一个方向能证明(差一格错了 ⇒ 队头是陈旧的 ⇒ 前缀最值也错), 反过来只是实测没碰到反例 —— 这两句话的分量差得很远。 ⚠ 对拍只能告诉你「错了」,不能告诉你「错在哪」。 定位得靠 trace。

✗ 四、复制粘贴求最大值那一半,忘了改方向
winWrongMax.cpp✗ 求最大值时不等号没改
// ✗ 错误版本九:复制粘贴求最大值的那一半,忘了把不等号方向改过来
//
// 这份代码为什么存在:它不是「想错了」,是「手快了」——
// 而这种 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它两行输出一模一样 —— 第一行(最小值)永远是对的,第二行永远是错的。

★ 这正好说明一件事:题面让你输出两整行,比让你输出一个数值钱得多。 如果这道题只要「所有窗口最小值之和」这么一个数,第二行那个 bug 根本没有出场机会。 (第 27、28 章「一份方案能自证清白,一个数字不能」的又一张脸。) ⚠ 第 12 步会量出这句话到底值多少 —— 那是这一章最刺眼的一张表。

winTrace.cpp逐步打印队列的内容(动画照着它画)
// 滑动窗口 —— 把单调队列**每一步**的队列内容打出来(动画照着这张表画)
//
// 这份代码为什么存在:和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

11★ 兑现预告②:多重背包的 O(nW)

第 24 章讲完二进制拆分之后欠了一句话:「还能做到 O(nW),等第 35 章讲完单调队列再回来收尾」。

★★ 把那个 max 写出来,它就是一个滑动窗口最大值

多重背包的转移是(第 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(下标)判的。

multiQueue.cpp多重背包 O(nW):按余数分组 + 单调队列
// 多重背包 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

第 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 的一部分。

★ 跨章节对拍:和第 24 章的两份代码逐字节比
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 不一致"
done

300 组,一组不差(枚举每种拿几件的 multiBrute.cpp 也一起对了)。 这是本教材第二次跨章节交叉验证(第一次是第 30 章把第 13 章那张网格图转成图跑)。

⚠ 但「O(nW) 更快」是句需要复核的口诀 —— 本机实测

./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★ 对拍与生成器:两个生成器,两次被实测打脸

对拍器
★ 这个生成器调了七次,其中「有一次是撤回」:题面允许 h = 0,我特意多造了些 0 高柱 —— 实测三个 bug 一起掉,因为 0 把柱状图切碎了,栈根本攒不起来。
// 柱状图里最大的矩形 —— 正解:单调栈 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 的同款)。

gen.cpp(八个档位)七次改动全部可重跑,包括那次撤回
对拍器
★ 后半章那个生成器只调了四次,可它撞上了这一章最刺眼的一张表:把数据排成单调不增之后,只看最小值那一行,四个 bug 全部 0 / 300。
// 滑动窗口最值 —— 正解:单调队列 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 秒 —— 它没有任何提前退出。)

同题对比:往左右扩的 O(n²)(喂它单调不降的数据) vs 单调栈 O(n)
12000 → 约 0.03 秒;15000 → 约 0.05 秒。⚠ 网页运行服务的输出上限是 64 KB,n 最多一万五左右(再大生成器的输出就传不过去了);要跑上面那张表只能在终端里
往左右扩的 O(n²)(喂它单调不降的数据)
单调栈 O(n)

本机实测 · 滑动窗口(./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 秒
⚠ 那一列 0.03 秒里,绝大部分不是算法

./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自测

自测清单0 / 12
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)