第 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 手算一遍:默认那张图
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 标准答案:把定义直接翻译成代码
点「运行 ▶」看结果
正解想的是「对每根柱子,左右第一个更矮的在哪」。 要是标准答案也这么想,两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 9、15、34 章那条)。
所以这一份换个思路:每一段连续的柱子都试一遍,高取这段里最矮的。
mn 边扫边更新,所以是 O(n²) 而不是 O(n³)。
★ 更要紧的是:它两层循环里一次 break 都没有 —— n² 就老老实实是 n²。 下一步你会看到,这件事一点都不多余。
4 ★ 另一份「更聪明」的暴力,以及它怎么假装自己不慢
点「运行 ▶」看结果
这份代码的思路和正解一模一样,只是老老实实地一根一根往外挪:
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 自己弹掉了)。
★ 「栈里存的是下标不是高度」这件事,理由就在这一行:宽度要靠下标相减。
点「运行 ▶」看结果
扫完之后栈里通常还剩一串(越往栈顶越高)—— 它们的右边界从来没揭晓过, 因为再没有更矮的柱子来把它们弹出去了。
在末尾放一根「比谁都矮」的哨兵(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)」,说的是同一件事。
点「运行 ▶」看结果
默认那张图(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 章那条)—— 不只比最终答案,
每一步「弹出了几根、栈里剩哪些下标、当前 ans」都要一致。
否则「答案蒙对、过程画错」根本发现不了。
8 四种把它写错的方式,外加一种「怎么写都对」
点「运行 ▶」看结果
图 A 上它给 12(正解 15)。
w = i − t 相当于说「这根柱子只能从它自己站的地方往右铺」,
可它明明还能往左铺 —— 那些被它更早弹掉的、比它高的柱子,站的地方它当然也能站。
⚠ 它挑数据:如果最大矩形正好就是某一根柱子自己(宽 1),两种写法算出来一样。
点「运行 ▶」看结果
图 A 上它给 5 —— 正好是左半段的答案。
★ 因为图 A 结尾那一段(3 5 5 6)是递增的,它们全留在栈里没人结算, 而答案 15 恰恰就在那一段里。 ⚠ 所以这个 bug 只在「答案落在结尾那段递增上」时才现形 —— 生成器要是顺手让数据整体递减,它就永远 0 / 300。
点「运行 ▶」看结果
图 A 上它给 6(正解 15)。
★ 写成 if 的人,心里那个理由通常是「一次弹一个才平衡,这样才是 O(n)」——
这个理由整个是反的(第 6 步)。而代价是:该弹的没弹干净,
栈从此不再单调,后面每一次「左边第一个更矮的」都可能读到一个比它更高的下标。
★ 把动画切到这一档,你会肉眼看见栈不再递增 —— 这个 bug 是能看出来的。
点「运行 ▶」看结果
图 A 上它给 48,离谱到一眼就能看出来(300 轮全被抓住)。
★ 方向由你要问的问题决定,不是背的: 这道题问「左右第一个更矮的」,所以栈里必须「越往上越高」; 要是问「第一个更高的」(比如「几天之后会有更暖和的一天」),方向就要整个反过来。
点「运行 ▶」看结果
图 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]) // 遇到一样高的不弹,留着图 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 个数(最大值)。
点「运行 ▶」看结果
默认那组 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 宽」这件事。
★ 单调栈只从一头进出,单调队列两头都要动。多出来的那一头,就是「窗口」这两个字的全部代价。
★ 而这一头也顺便解释了为什么队列里必须存下标: 值用来比大小,下标用来判出没出窗口 —— 两件事,缺一不可。 (单调栈那道题存下标是为了算宽度,也是「值答不了的那半」。)
点「运行 ▶」看结果
点「运行 ▶」看结果
./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 个数都留着。
点「运行 ▶」看结果
它给 -1 -3 -3 -3 -3 3(正解 -1 -3 -3 -3 3 3)。
光存值,「队头那个还在窗口里吗」这个问题就再也答不上来了, 只好拿队列长度冒充窗口宽度 —— 可队列里装的从来不是「窗口里的所有数」, 它通常比 k 短得多。于是该滑走的没滑走。
★ 要不要存下标,取决于你还要不要问「它是什么时候进来的」。
点「运行 ▶」看结果
它给 -1 -3 -3 -3 -3 -3。而它不是随机地错:
点「运行 ▶」看结果
一模一样。300 组随机数据一组不差(钉在 check:viz 里)。
★ 忘了弹队头 ≡ 前缀最值。 这是本教材第十一条这样的恒等式 (前十条在第 23、24、25、26、27、28、34 章)。 道理一句话:队头没人赶它走,而队尾那句 while 保证「比它更优的都进不来」—— 于是队头永远是从头到现在的最优值。 ⚠ 验法照旧:两份程序思路必须不同(一份用队列、一份一路
min过去)。
点「运行 ▶」看结果
它给 -1 -3 -3 -3 -3 3 —— ⚠ 和上面那个「存值不存下标」的输出一模一样。
★ 第 28 章那条「两个不同的 bug,症状可以一模一样」的第二次。 而这一章还量出了更狠的:300 组数据里, 「忘了弹队头」和「出界差一格」同时对、同时错,一次例外都没有(各 182 / 300, 其中 58 组连输出都相同)。 一个方向能证明(差一格错了 ⇒ 队头是陈旧的 ⇒ 前缀最值也错), 反过来只是实测没碰到反例 —— 这两句话的分量差得很远。 ⚠ 对拍只能告诉你「错了」,不能告诉你「错在哪」。 定位得靠 trace。
点「运行 ▶」看结果
它两行输出一模一样 —— 第一行(最小值)永远是对的,第二行永远是错的。
★ 这正好说明一件事:题面让你输出两整行,比让你输出一个数值钱得多。 如果这道题只要「所有窗口最小值之和」这么一个数,第二行那个 bug 根本没有出场机会。 (第 27、28 章「一份方案能自证清白,一个数字不能」的又一张脸。) ⚠ 第 12 步会量出这句话到底值多少 —— 那是这一章最刺眼的一张表。
点「运行 ▶」看结果
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(下标)判的。
点「运行 ▶」看结果
第 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 ★ 对拍与生成器:两个生成器,两次被实测打脸
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 的同款)。
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 玉蟾宫 —— 进阶:把柱状图那道题在**每一行**上做一遍,就是最大子矩形。这一步跨得比看起来小