阶段 7 · 数据结构 · 第 35 章

单调栈与单调队列

两道题,一个道理:把「已经没戏的」当场扔掉,剩下的自然是单调的。★ 关键一步是均摊分析 —— 那个「for 里套 while」的东西为什么是 O(n),理由不是「内层转得少」,而是「每个元素一辈子只能出去一次」。顺带还清第 24 章欠下的那笔账:多重背包的 O(nW)。

例题:柱状图里最大的矩形 + 滑动窗口最值 建议用时:135 分钟
这一章要还两笔账

第 34 章结尾白纸黑字写了两件事:

★ 关键一步是均摊分析:每个元素进出各一次,所以那个「看起来有两层循环」的东西其实是 O(n) —— 接的是第 7 章双指针那一段。 ⚠ 还有一笔欠了很久的账:第 24 章说过「多重背包还能做到 O(nW)(单调队列优化), 等第 35 章讲完单调队列再回来收尾」。

第 6 步还①(而且第 7 步把它做成了画面),第 11 步还②。

⚠ 这一章有两道题:柱状图最大矩形(单调栈)、滑动窗口最值(单调队列)。 它们看着毫无关系,其实是同一句话的两个说法 —— 第 9 步会把这句话挑明。

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 专门死在这儿

一根一根问「以我为高,能铺多宽」:

柱子往左能到往右能到面积
121112
211333
353315
401880
5358412
6568315
7568315
868816

答案 15(第 6~8 根,高 5 宽 3)。

★ 请留意第 4 行那根 0 高柱:它能铺满整整 8 格,可高是 0,面积还是 0 —— 它唯一的作用是把左右两段隔开(左边最好的是 5,右边最好的是 15)。 ⚠ 顺带解释一件事:正解末尾要放一根「比谁都矮」的哨兵,写的是 −1 而不是 0 —— 因为题面允许 h = 0,写 −1 才严格比所有柱子矮,不用多想一步。

3 标准答案:把定义直接翻译成代码

brute.cpp标准答案:枚举所有连续段 O(n²)
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案要用「枚举段」而不是「每根柱子往外扩」

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

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

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

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

expand.cpp✗ 陷阱:以每根柱子为高,往左右扩
输入(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 040 970 6410.02 秒
单调不降(./genBig 200000 120 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)
输入(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不讲道理,直接数:三种写法各碰了多少次数据
输入(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 6988
单调不降 1 2 3 4 5 6766
单调不增 6 5 4 3 2 1766
全都一样高 4 4 4 4 4655

四种形状差别巨大,三个计数器却只跟着 n 走。 这就是上一步那句话的画面版。 (这三个数都钉在 check:viz 里。)

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

trace.cpp逐步打印栈的内容(动画就是照着这张表画的)
输入(stdin)
输出
点「运行 ▶」看结果

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

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

✗ 一、宽度只从被弹出那根算起(忘了往左还能扩)
wrongWidth.cpp✗ w = i − t
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

✗ 二、忘了哨兵(栈里剩的一概不管)
wrongTail.cpp✗ 扫到 n 就走
输入(stdin)
输出
点「运行 ▶」看结果

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

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

✗ 三、while 写成 if(一次只弹一根)—— ★ 均摊分析的反面教材
wrongOnce.cpp✗ if 代替 while
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

✗ 四、弹出方向写反(维护成了递减栈)
wrongDir.cpp✗ >= 写成 <=
输入(stdin)
输出
点「运行 ▶」看结果

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

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

✗ 五、面积用 int —— ★ 而这一份对拍**永远**抓不到
wrongInt.cpp✗ long long 全换成 int
输入(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`>=` 和 `>` 并排跑
输入(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)
输入(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)
输入(stdin)
输出
点「运行 ▶」看结果
winCount.cpp还是数一遍(附「只读入」开关)
输入(stdin)
输出
点「运行 ▶」看结果
★ 均摊的第二份证据:入队次数和 k 一点关系都没有

./winGenBig <k> 造的是 n = 200 000 的数据,只有 k 变(本机实测):

k入队队尾出队队头出队暴力要扫 (n−k+1)·k 次
10200 000181 89218 1041 999 910
1 000200 000199 789199199 001 000
100 000200 000199 983210 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 里存的是值
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

✗ 二、忘了弹出界的队头 —— ★ 第十一条恒等式
winWrongPop.cpp✗ 少了「弹队头」那一行
输入(stdin)
输出
点「运行 ▶」看结果

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

winPrefix.cpp前缀最值(根本没有队列,一路 min 过去)
输入(stdin)
输出
点「运行 ▶」看结果

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

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

✗ 三、出界判断差一格
winWrongEdge.cpp✗ `<= i-k` 写成 `< i-k`
输入(stdin)
输出
点「运行 ▶」看结果

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

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

✗ 四、复制粘贴求最大值那一半,忘了改方向
winWrongMax.cpp✗ 求最大值时不等号没改
输入(stdin)
输出
点「运行 ▶」看结果

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

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

winTrace.cpp逐步打印队列的内容(动画照着它画)
输入(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·wg[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):按余数分组 + 单调队列
输入(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)
100.06 秒0.18 秒
1000.14 秒0.16 秒
1 0000.21 秒0.14 秒
10 0000.29 秒0.13 秒

交叉点在 k ≈ 100。 k 小的时候 log k 才三四, 而单调队列的常数明显更重(要按余数分组、要维护 deque、访问 f 是跳着走的)。

★ 第 29、32、33、34 章那条「口诀要拿实测复核」的第五次。 结论要说准:不是「单调队列更快」,是「k 大的时候单调队列更快」。 ⚠ 而竞赛里绝大多数多重背包的 k 都不大 —— 所以二进制拆分至今仍是首选, 它还短得多、也不容易写错。

12 ★ 对拍与生成器:两个生成器,两次被实测打脸

对拍器
★ 这个生成器调了七次,其中**有一次是撤回**:题面允许 h = 0,我特意多造了些 0 高柱 —— 实测三个 bug 一起掉,因为 0 把柱状图切碎了,栈根本攒不起来。

300 轮实测,五个错误版本:

故意写错的地方被抓第几轮
弹出方向写反300 / 300第 1 轮
while 写成 if246 / 300第 1 轮
宽度只从被弹出那根算起206 / 300第 3 轮
忘了哨兵199 / 300第 1 轮
面积用 int(溢出)0 / 300
★ 第一张表:柱状图那个生成器,一次只改一处

gen.cpp 带了八个档位(./gen 种子 档位),种子固定 1..300:

档位改了什么宽度差一忘了哨兵只弹一根方向反
0(最初)纯随机,值域 [0, 999]217110235300
1值域压到 [0, 9]22793196300
2造「台阶」(一定概率抄上一根)256122164297
3(在用)结尾接一段递增206199246300
4再多造些 0 高柱184185219300

档位 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(在用)——206199246300
5(对照)撤回「台阶」176186249300
6(对照)撤回「结尾递增」256122164297
7(对照)撤回「值域压小」227199249300
  • 台阶(档位 5):撤了之后「宽度差一」掉 30 —— 有用,留。
  • 结尾递增(档位 6):撤了之后「忘了哨兵」掉到 122 —— 最有用的一处,留。
  • 值域压小(档位 7):撤了之后几乎一个数都没变(甚至略好一点)。

★ 为什么值域压小没用了?因为**「台阶」那处改动把它想干的事包办了** —— 并列的高度是靠「抄上一根」造出来的,和值域宽窄没关系。

★ 第 32 章「调优不可加:一处改动值不值得留,取决于其它旋钮此刻在哪」的第三次。 两处改动想做同一件事时,后来的那处会把前面那处吃掉

它最后还是留下了,但理由不是抓获率:值域 [0, 999] 时 300 组里只有 2 组带 0 高柱, 而 h = 0题面明确允许的边界(第 24 章那条:退化的那一端也必须造)。 这笔账明写在这里,不粉饰成「改了就是更好」(第 27 章档位 3 的同款)。

gen.cpp(八个档位)七次改动全部可重跑,包括那次撤回
对拍器
★ 后半章那个生成器只调了四次,可它撞上了这一章最刺眼的一张表:把数据排成单调不增之后,只看最小值那一行,四个 bug 全部 0 / 300。

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]247262262300
1k 取遍 1..n,两端占大头79157157227
2把两端的比例降下来(各 1/8)147189189269
3(在用)值域压到 [−4, 4]144182182269
4(对照)数据排成单调不增174239239269
4(只看最小值那一行)同上0000

档位 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 0000.17 秒0.00 秒0.07 秒0.00 秒
50 0001.12 秒0.00 秒0.46 秒0.00 秒
100 0004.53 秒0.01 秒1.96 秒0.00 秒
200 00017.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)
100.03 秒0.03 秒
1000.06 秒0.03 秒
1 0000.20 秒0.03 秒
10 0001.50 秒0.03 秒
100 0008.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
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)