背包三章的状态都长一个样:前 i 件物品 + 还剩多少容量。
这一章的状态换了个形状 —— 一段区间 f[l][r]。
新东西只有这一件。至于填表顺序,你已经会了,只是自己还不知道:
依赖谁,就先填谁。(第 21 章)
- 第 21 章数字三角形:下一行要先填 → 所以从下往上;
- 第 23 章 01 背包:要读「上一轮」的
f[j-w]→ 所以倒序; - 第 24 章完全背包:要读「这一轮」的
f[j-w]→ 所以正序; - 第 25 章分组背包:要读「上一组」的
f[j-w]→ 所以容量倒序、组内枚举在最里层。
这一章:f[l][r] 要读比它短的区间 → 所以短的先填。
⚠ 但这一章会比前面几章多走一步。前面每一章的结论都是「记住这个写法」, 这一章要把那层壳敲掉:按区间长度枚举并不是唯一正确的写法, 还有一种看着完全不像的写法也是对的 —— 而判据自始至终只有上面那一句。
1 一句话问题
n堆石子排成一排,每次只能合并相邻的两堆,代价是这两堆石子数之和。 求把所有石子合并成一堆的最小总代价。
把「相邻」去掉,这道题立刻就不难了:任意两堆都能合并的话, 每次挑最小的两堆合起来就是最优解 —— 那是哈夫曼树,有严格证明。
加上「相邻」之后,那个证明就断了:你想把两堆小的凑到一起先合, 可它们中间隔着别的堆,换不过去。
第 20 章那句话在这里第二次兑现:贪心的正确性属于问题,不属于算法。 同一个「先合最小的」,在哈夫曼树上对,在这道题上错 —— 第 10 步会把它按在地上打一次假。
2 先用手算一遍:五个数字,贯穿全章
石子: 4 1 2 3 5 (5 堆,一共 15 颗)
先想清楚一件事:不管怎么合,最后那一次合并的代价恒等于 15(把整排并成一堆)。 所以能省的只有前面几步。
- 正确答案 33:先合
1+2=3,再合4+3=7,另一边合3+5=8,最后7+8=15。 总代价 3 + 7 + 8 + 15 = 33。 - 贪心(每次合最小的相邻两堆)34:它第一步也合
1+2,第二步就分家了 —— 第 10 步细看。 - 后面三个数字是三份写错的代码跑出来的,每一个都对应一类典型错误: 15(填表顺序写错)、20(前缀和差一)、35(断点范围差一)。
33 / 34 / 15 / 20 / 35 —— 这五个数后面每一步都会回来验。
3 暴力:真的一步步合,(n-1)! 条路径
点「运行 ▶」看结果
它手里拿着当前这一排堆,挑一对相邻的合掉,然后接着挑。
第一步有 n-1 对可选,合完剩 n-1 堆于是又有 n-2 对…… 一共 (n-1)! 条路径。
跑出来 33,和手算一致。
因为它是完全不同的思路(第 20 章那条规矩)。 这份代码里没有区间、没有 f 表、没有断点,它就是老老实实在合石子。 而正解那边是「枚举最后一次合并的断点」—— 两边连看问题的角度都不一样。
同一个思路写两遍只能验出打字错误,不同思路才能验出想法错误。
⚠ 另外它故意一句剪枝都不写,连「已经比当前最优差了就别往下走」都没有。
第 25 章那张耗时表差点做废,就是因为暴力里有一句免费的剪枝,把整棵树剪没了、
暴力假装自己不慢。要拿它证明「暴力有多慢」,就得让它老老实实走完 (n-1)! 条路径。
4 实测:它慢得非常有节奏
本机实测(./genBig n,固定种子):
| 堆数 n | (n-1)! 暴力 | 区间 DP | 暴力比上一行慢了几倍 |
|---|---|---|---|
| 10 | 0.027 秒 | 0.002 秒 | — |
| 11 | 0.173 秒 | 0.001 秒 | 6.4 |
| 12 | 1.877 秒 | 0.002 秒 | 10.8 |
| 13 | 22.538 秒 | 0.002 秒 | 12.0 |
最后一列就是阶乘的样子:每加一堆,暴力乘以当前的堆数。
而 DP 那一列压根没动 —— 它是 n³,从 10 堆到 13 堆只从 1000 涨到 2197 次转移,量都量不出来。
5 慢在哪:一样的区间,被重复算了成千上万遍
盯住暴力的搜索树:先合 (1,2) 再合 (4,5),和先合 (4,5) 再合 (1,2) ——
走到这两条路的尽头时,手里的局面一模一样,后面要做的事也一模一样,
可暴力把它们从头到尾各算了一遍。
这正是第 17 章那个「重复子问题」,一个字都没变。于是:
暴力在递归过程中遇到的每一个局面,都可以由一句话描述完:
第 l 堆到第 r 堆,已经并成了一堆。
所以状态就是它:
f[l][r] = 把第 l 堆到第 r 堆合并成一堆,最少要付多少代价转移:枚举最后一次合并的断点 k(左边 [l,k] 已经并成一堆、右边 [k+1,r] 也并成一堆):
f[l][r] = min{ f[l][k] + f[k+1][r] } + (a[l] + … + a[r])
k ∈ [l, r-1]★ 后面那一项和 k 无关:不管怎么分,最后那一次合并总是把整段并成一堆,
代价恒等于这一段石子之和。所以它可以提到 min 外面,用第 6 章的前缀和 O(1) 求出来。
⚠ 而且请把第 6 章那条老约定一起带上:区间类题目一律 1 基下标,
这样这一项才是干干净净的 s[r] - s[l-1]。第 12 步会看到写成 s[r] - s[l] 的下场。
6 先写记忆化搜索 —— 它根本不用你操心顺序
点「运行 ▶」看结果
跑出来 33。而且注意:这份代码里没有任何「顺序」的痕迹。
solve(l, r) 要用 solve(l, k) 和 solve(k+1, r),就直接递归下去要 ——
谁先算谁后算,是递归自己安排的,你一个字都不用想。
把递归替你做的那件事,自己做一遍。
递推没有递归帮忙,你必须亲手安排一个次序,让每一格被填的时候, 它依赖的那些格子都已经填好了。安排错了不会报错、不会崩溃 —— 只会安静地读到一格还没算的 0。
这就是第 21 章「填表顺序由依赖方向决定」的第四次、也是最难的一次应用。
7 ★ 关键一步:按区间长度从小到大
f[l][r] 要读 f[l][k] 和 f[k+1][r],而这两个区间都比 [l,r] 短。
所以只要按长度从小到大填,依赖就永远在手上:
for (int len = 2; len <= n; len++) // ★ ① 先枚举区间长度,从短到长
for (int l = 1; l + len - 1 <= n; l++) { // ② 再枚举左端点
int r = l + len - 1;
long long best = LLONG_MAX;
for (int k = l; k < r; k++) // ③ 最后枚举断点
best = min(best, f[l][k] + f[k + 1][r]);
f[l][r] = best + s[r] - s[l - 1];
}长度 1 的那一层(对角线)全是 0 —— 一堆不用合,代价 0。那是整张表的地基。
时间 O(n³):状态 O(n²) 个,每个枚举 O(n) 个断点。空间 O(n²)。
点「运行 ▶」看结果
还是 33。想看它一层一层长起来的样子,就跑这份:
点「运行 ▶」看结果
长度 1 : f[1][1]=0 f[2][2]=0 f[3][3]=0 f[4][4]=0 f[5][5]=0 <- 地基
长度 2 : f[1][2]=5(k=1) f[2][3]=3(k=2) f[3][4]=5(k=3) f[4][5]=8(k=4)
长度 3 : f[1][3]=10(k=1) f[2][4]=9(k=3) f[3][5]=15(k=4)
长度 4 : f[1][4]=19(k=1) f[2][5]=20(k=4)
长度 5 : f[1][5]=33(k=3)
每一层用到的都只是下面那些层的值。 这就是「按区间长度从小到大」的全部含义。
(check:viz 拿这张表和动画逐格对过,包括每一格选中的断点 k,不只比最终答案。)
8 ★ 关键一步(二):判据是依赖,不是那个写法
几乎所有资料都会告诉你「区间 DP 就是要按长度枚举」。这句话能用,但它说小了。 下面这份代码看着一点都不像区间 DP —— 它是对的:
点「运行 ▶」看结果
跑出来还是 33。为什么?还是拿依赖去对:
| 要读的格子 | 它在哪 | 这个顺序下算过没有 |
|---|---|---|
f[l][k](k < r) | 同一个 l、更小的 r | 内层 r 正序,这一轮前面刚算过 ✓ |
f[k+1][r](k+1 > l) | 更大的 l | 外层 l 倒序,上几轮就算完了 ✓ |
两个依赖都在手上,所以它和按长度枚举一模一样。
check:viz 拿 300 组随机数据钉死了这一条:它的「被抓轮数」是 0 / 300 ——
不是数据不够狠,是它根本没错(第 25 章那份 vol2In.cpp 是同一件事)。
反过来,把左端点写成正序:
点「运行 ▶」看结果
跑出来 15 —— 正是这排石子的总数。
它不是随机地错,它精确地解了另一道题。两行就能推出来:
- 这个顺序下
f[k+1][r](左端点更大)一个都还没算,读到的全是 0; - 而
k = l时f[l][l]本来就是 0 —— 所以那个min恒取到 0。
于是 f[l][r] = s[r] - s[l-1],答案就是石子总数。
换句话说,它解的是「允许一次把任意多个连续的堆合成一堆」的那道题 ——
那道题当然是一口气全合掉最便宜。
check:viz 用 300 组数据钉死了这条恒等式:输出恒等于石子总数,一组不差。
连上前三章,DP 这几章一共钉死了六条这样的恒等式:
| 章 | 写错的地方 | 它其实解了哪道题 |
|---|---|---|
| 23 | 01 背包写成正序 | 完全背包 |
| 24 | 完全背包写成倒序 | 01 背包 |
| 25 | 分组背包组内枚举提到容量外 | 无视分组的 01 背包 |
| 25 | 分组背包容量写成正序 | 无视分组的完全背包 |
| 25 | 二维费用外层正序 | 二维费用的完全背包 |
| 26 | 区间 DP 左端点正序 | 允许一次合并任意多个连续堆 |
它们都在说同一件事:顺序不是格式,顺序就是题目本身。
把三种顺序摆在一起,顺便把「错在哪」数出来:
点「运行 ▶」看结果
填表顺序 答案 读到还没算好的格子 和正解一样
---------------------------- ---- ------------------ ----------
按区间长度从小到大 33 0 是
左端点倒序、右端点正序 33 0 是
左端点正序、右端点正序 15 10 否
这份代码给每一格挂了一个「算好了没有」的标记,转移时只要读到没算好的就计一次数。 于是「顺序错了」不再是一句抽象的话,它是一个数字。
因为「短的先算」是一句不用每次都重新推的理由。
而 lrOrder.cpp 的正确性,每次都得把上面那张依赖表重新验一遍。
能不动脑子的地方就别动脑子 —— 但你得知道自己省的是哪一步脑子。 考场上遇到没见过的区间型转移(比如依赖的不是「更短的区间」而是别的东西), 按长度枚举可能就不管用了,那时候能救你的只有「依赖谁,就先填谁」。
9 动画:三角形的表,一层一层往上长
每一行是左端点 l,每一列是右端点 r,只有右上半张表有意义 —— 所以它是个三角形。
浅绿的对角线是长度 1 的区间(代价 0),那是地基。
蓝色 = 正在填的格子,它的两个来源会被标成 绿色(已经算好) 或 红色(还没算好)。
盯住左下角那个计数器「读到还没算好的格子」:
| 填表顺序 | 计数器 | 答案 |
|---|---|---|
| 按区间长度从小到大 | 0 | 33 |
| 左端点倒序、右端点正序 | 0 | 33 |
| 左端点正序、右端点正序 | 10 | 15 |
红色一出现,读到的就是初值 0,这一格的答案立刻变成假的 —— 而程序不会有任何反应。这个计数器也参与交叉验证(防止「答案蒙对、过程画错」)。
10 ★ 打一次假:那个「每次合最小的相邻两堆」的贪心
这是这道题最经典的错误直觉,而且它错得很有来头(第 1 步那个 ⚠)。
点「运行 ▶」看结果
跑出来 34,比正解多 1。多的这 1 是怎么丢的?看动画:
两排石子从同一个起点出发,合并次数完全一样,差别只在先合谁。默认数据上:
| 第几次合并 | 正解累计 | 贪心累计 | 谁便宜 |
|---|---|---|---|
1(两边都合 1+2=3) | 3 | 3 | 打平 |
| 2 | 10 | 9 | ✗ 贪心领先 |
| 3 | 18 | 19 | 正解反超 |
| 4 | 33 | 34 | 正解赢 |
贪心的第一步是对的,第二步开始便宜,直到第三步才输。
这就是它这么难被说服的原因:它每一步都挑当时最便宜的那一对, 代价是把两个大堆留到了最后,而最后那几次合并是最贵的。
(这张表里的每一个数字都在 check:viz 里钉成了断言 ——
换了默认数据它就不成立了,那时脚本会立刻变红提醒我把这段重写。)
那这个贪心到底有多错?错得罕见还是错得普遍?别猜,枚举一遍:
点「运行 ▶」看结果
堆数 枚举组数 反例组数 占比 最小反例(字典序最小) 贪心 / 正解
---- -------- -------- ------ ---------------------- -----------
3 216 0 0.0% (一个都没有) 贪心永远是对的
4 1296 105 8.1% 2 2 1 2 15 / 14
5 7776 1224 15.7% 1 1 2 1 2 17 / 16
6 46656 10540 22.6% 1 1 1 1 1 2 19 / 18
这张表(第 20 章 coinFind.cpp 的同款做法)一口气回答了三件事:
- 最小反例是
2 2 1 2(贪心 15、正解 14)—— 动画里有个按钮可以直接切过去看。 - 反例占比就是「随手造一组数据能抓住它」的概率。4 堆时只有 8.1%, 所以样例过了什么都证明不了。
- ★ 3 堆时反例是 0 —— 那时候这个贪心是真的对的。
3 堆时只有两种合并顺序:先合 (1,2),或者先合 (2,3)。
不管先合哪一对,最后那一次都是把整排并成一堆,代价恒等于总和。
所以 总代价 = 总和 + 先合的那一对之和 —— 挑和最小的那一对当然最优,
而那正好就是贪心干的事。
这条性质马上会变成一个大坑,第 12 步见。
11 顺便把第 23 章欠的账还了:输出合并方案
第 23 章末尾说过一句话:「要方案就得开二维表」。一维滚动数组只留得下答案, 留不下「这个答案是怎么来的」。
区间 DP 的 f 本来就是二维的,所以这笔账还起来特别便宜 ——
只要再开一张同样大的 from[l][r] 记下最优断点:
点「运行 ▶」看结果
最小总代价 = 33
第几次 合并的两段 代价 合并后这一排
------ --------------- ---- ------------------------
1 [2,2] + [3,3] 3 4 3 3 5
2 [1,1] + [2,3] 7 7 3 5
3 [4,4] + [5,5] 8 7 8
4 [1,3] + [4,5] 15 15
要输出一个真的能照着做的合并序列,必须先输出两个子区间内部的合并、 最后才输出这一次(后序遍历)。
反过来先输出自己,得到的序列是没法执行的 —— 那两堆当时还没并起来呢。
check:viz 对这份输出做的是硬验证,不是比字符串:
它维护当前这一排堆,逐行检查「这两段确实是当前相邻的两堆」、代价确实等于两堆之和,
最后确认只剩一堆、累计代价正好是 33。
12 ★ 对拍:以及一次「我的两个直觉都错了」的现场记录
300 轮实测,五个版本:
| 故意写错的地方 | 被抓 | 第几轮 | 它其实解了哪道题 |
|---|---|---|---|
| 左端点正序(填表顺序) | 300 / 300 | 第 1 轮 | 允许一次合并任意多个连续堆 |
前缀和差一(s[r]-s[l]) | 300 / 300 | 第 1 轮 | —(每次少加一堆) |
断点范围差一(k 从 l+1 起) | 184 / 300 | 第 2 轮 | —(凭空多了「左半段至少两堆」的限制) |
| 贪心(每次合最小的相邻两堆) | 114 / 300 | 第 4 轮 | 哈夫曼树(不要求相邻的那道题) |
| 左端点倒序、右端点正序 | 0 / 300 | — | ← 它就是正解(第 8 步那个 ★) |
前两个错误版本都是偏小的(读到 0、少加一堆),最后一个是偏大的。
偏小的错误你还能靠「答案怎么比暴力小」认出来; 偏大的不行 —— 一个偏大的答案和一个「数据比较难」的正确答案长得一模一样。 只能靠标准答案,不能靠眼力。
gen.cpp 带了四个档位,你可以把当初那四次修改一次一次重跑一遍
(./gen 种子 档位)。种子固定 1..300:
| 档位 | 石子数 | 堆数 | 抓住错误贪心 | 抓住断点差一 |
|---|---|---|---|---|
| 0(最初) | 1 ~ 3 | 4 ~ 9 | 74 / 300 | 66 / 300 |
| 1 | 1 ~ 9 | 4 ~ 9 | 79 / 300 | 144 / 300 |
| 2 | 1 ~ 100 | 4 ~ 9 | 101 / 300 | 168 / 300 |
| 3(在用) | 1 ~ 100 | 6 ~ 9 | 114 / 300 | 184 / 300 |
| 4(废案) | 大小交错 | 6 ~ 9 | 42 / 300 | 173 / 300 |
① 「值域小才是灵魂」在这道题上是错的(档位 0 → 2,石子数放大反而更狠)。
第 22 章(LIS)里值域小确实是灵魂,因为那道题的 bug(lower/upper_bound 写反)
依赖的是相等。这道题的错误贪心依赖的是「相邻两对的和谁大谁小」——
它要的是对比度。石子数全挤在 1~3 里,每一对都差不多,贪心反而不容易露馅。
规矩本身没变(要随机的是算法依赖的那个量),变的是「那个量」是谁。 这一条得每道题重新问一遍,不能背。
② 「大小交错」这种看着很刁钻的花样,实测是最差的一档(42 / 300)。 交错排开之后「哪一对最小」几乎总是那几对固定的小的,局面反而变单调了。 数据里的花样和打得准是两回事。
真正起作用的旋钮是堆数:49 → 69,两个 bug 的抓获率一起涨。
我另写了一份 genSmall.cpp,和最终档比只改了一处:堆数固定成 3。同样跑 300 轮:
| 故意写错的地方 | 正常数据(6~9 堆) | 只有 3 堆的数据 |
|---|---|---|
| 贪心(每次合最小的相邻两堆) | 114 / 300 | 0 / 300 |
| 断点范围差一 | 184 / 300 | 136 / 300 |
| 左端点正序 | 300 / 300 | 300 / 300 |
| 前缀和差一 | 300 / 300 | 300 / 300 |
一个 bug 完全隐身,其它照旧。 而且这次不用猜原因 ——
第 10 步已经证明过了:3 堆时那个贪心是真的对的,
greedyFind.cpp 枚举全部 216 组三堆数据,反例数正好是 0。
这是第 24、25 章那条教训的第三次现形。写生成器之前先问一句:
这个量取到极小 / 极大时,题目会退化成哪道更简单的题?
石子合并退化到 3 堆,就退化成了一道贪心题。 一个只造 3 堆的生成器,跑一万轮也是绿的,交上去就是 WA。
13 这一章可以带走的四样东西
【1】状态换成一段区间,转移枚举「最后一次合并的断点」。
f[l][r] = min{ f[l][k] + f[k+1][r] } + s[r] - s[l-1]。
那个和 k 无关的尾巴要提到 min 外面,用前缀和 O(1) 求 —— 而且一律 1 基下标。
【2】填表顺序的判据是依赖,不是某个写法。 按区间长度从小到大是最省脑子的一种(短的先算), 但左端点倒序、右端点正序同样正确(0 / 300)。 而左端点正序会读到一片还没算的 0,答案恰好塌成石子总数 —— 第六条「写反了就是另一道题」的恒等式。
【3】想不清顺序,就先写记忆化搜索。
它不用你操心任何顺序,而且和递推是同一个复杂度。
递推的价值在于没有递归开销、也不会爆栈(第 21 章 stairsDeep.cpp 那个段错误)——
先用记忆化把转移写对,再翻译成递推,这个次序永远不亏。
【4】写生成器之前,先问「这个量取到极端时会退化成哪道题」。 石子合并退化到 3 堆 = 一道贪心题,于是那个错误贪心 0 / 300。 另外这一章还证明了一件事:「值域小才是灵魂」不是普适规律 —— 第 22 章成立是因为那道题的 bug 依赖相等,这道题的 bug 依赖对比度,结论正好反过来。 每道题都要重新问一遍:这个 bug 依赖的到底是什么?
第 27 章:树形 DP(没有上司的舞会)。
这一章的状态是「一段区间」,下一章换成「一棵子树」—— 而转移发生在 DFS 回溯的时候,因为父节点要用到所有孩子的结果。
「依赖谁,就先填谁」会第五次登场,而且这一次它有了个更好听的名字: 后序遍历。你在第 1 章就写过它了(那时候叫「归」), 第 11 章的归并排序、本章第 11 步的输出方案,用的都是同一件东西。
14 自测
- 洛谷 P1775 石子合并(弱化版) —— 本章原题,直线版。写完直接交,一遍就该过
- 洛谷 P1880 [NOI1995] 石子合并 —— ★ 环形版,而且要同时求最小和最大。关键技巧是「破环成链」:把序列复制一遍接在后面,跑长度为 n 的所有区间。求最大值只需要把 min 换成 max —— 但那个错误贪心对最大值同样是错的
- 洛谷 P1063 [NOIP2006 提高组] 能量项链 —— 环形区间 DP 的另一张皮。合并的代价换了个公式,框架一个字不用改 —— 正好确认自己抓到的是框架而不是那道题
- 洛谷 P1040 [NOIP2003 提高组] 加分二叉树 —— ★ 区间 DP + 输出方案,本章第 11 步那套 from[l][r] 回溯原样能用。而且它的「根」就是本章的「断点」
- 洛谷 P4170 [CQOI2007] 涂色 —— 区间 DP 经典。转移里多了一个「两端颜色相同」的特判,想清楚那个特判为什么成立
- 洛谷 P1220 关路灯 —— 进阶:状态在区间之外还要多记一维「人现在站在左端还是右端」。适合确认自己是真的会了「状态该怎么定」