阶段 5 · 动态规划 · 第 26 章

区间 DP:石子合并

状态从「前 i 个」换成「一段区间」。填表顺序既不是从左到右也不是从右到左 —— 但这一章真正想让你记住的是:顺序的判据从来不是某个固定写法,而是「依赖谁,就先填谁」。

例题:石子合并(相邻两堆) 建议用时:110 分钟
第 21 章那句话,第四次登场

背包三章的状态都长一个样: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)! 条路径

brute.cpp(n-1)! 枚举合并顺序
输入(stdin)
输出
点「运行 ▶」看结果

它手里拿着当前这一排堆,挑一对相邻的合掉,然后接着挑。 第一步有 n-1 对可选,合完剩 n-1 堆于是又有 n-2 对…… 一共 (n-1)! 条路径。

跑出来 33,和手算一致。

为什么这份暴力值得留着当标准答案

因为它是完全不同的思路(第 20 章那条规矩)。 这份代码里没有区间、没有 f 表、没有断点,它就是老老实实在合石子。 而正解那边是「枚举最后一次合并的断点」—— 两边连看问题的角度都不一样。

同一个思路写两遍只能验出打字错误,不同思路才能验出想法错误

⚠ 另外它故意一句剪枝都不写,连「已经比当前最优差了就别往下走」都没有。 第 25 章那张耗时表差点做废,就是因为暴力里有一句免费的剪枝,把整棵树剪没了、 暴力假装自己不慢。要拿它证明「暴力有多慢」,就得让它老老实实走完 (n-1)! 条路径。

4 实测:它慢得非常有节奏

同题对比:(n-1)! 枚举合并顺序 vs 区间 DP O(n³)
先跑 11,再改成 12、13。⚠ 变的是堆数 —— 暴力是 (n-1)!,每加一堆就乘以当前堆数。别超过 13。
(n-1)! 枚举合并顺序
区间 DP O(n³)

本机实测(./genBig n,固定种子):

堆数 n(n-1)! 暴力区间 DP暴力比上一行慢了几倍
100.027 秒0.002 秒
110.173 秒0.001 秒6.4
121.877 秒0.002 秒10.8
1322.538 秒0.002 秒12.0

最后一列就是阶乘的样子:每加一堆,暴力乘以当前的堆数。 而 DP 那一列压根没动 —— 它是 ,从 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 先写记忆化搜索 —— 它根本不用你操心顺序

memo.cpp记忆化搜索(接第 17、21 章)
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 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²)

fast.cpp区间 DP 正解:长度 → 左端点 → 断点
输入(stdin)
输出
点「运行 ▶」看结果

还是 33。想看它一层一层长起来的样子,就跑这份:

trace.cpp把整张 f 表按长度分层打出来
输入(stdin)
输出
点「运行 ▶」看结果
长度 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 —— 它是对的:

lrOrder.cpp左端点倒序、右端点正序(它是对的)
输入(stdin)
输出
点「运行 ▶」看结果

跑出来还是 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 是同一件事)。

反过来,把左端点写成正序

wrongOrder.cpp✗ 左端点正序(最经典的错法)
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 15 —— 正是这排石子的总数

★ 第 23、24、25 章那个现象,第六次出现

它不是随机地错,它精确地解了另一道题。两行就能推出来:

  • 这个顺序下 f[k+1][r](左端点更大)一个都还没算,读到的全是 0;
  • k = lf[l][l] 本来就是 0 —— 所以那个 min 恒取到 0

于是 f[l][r] = s[r] - s[l-1],答案就是石子总数。 换句话说,它解的是「允许一次把任意多个连续的堆合成一堆」的那道题 —— 那道题当然是一口气全合掉最便宜。

check:viz 用 300 组数据钉死了这条恒等式:输出恒等于石子总数,一组不差。

连上前三章,DP 这几章一共钉死了六条这样的恒等式:

写错的地方它其实解了哪道题
2301 背包写成正序完全背包
24完全背包写成倒序01 背包
25分组背包组内枚举提到容量外无视分组的 01 背包
25分组背包容量写成正序无视分组的完全背包
25二维费用外层正序二维费用的完全背包
26区间 DP 左端点正序允许一次合并任意多个连续堆

它们都在说同一件事:顺序不是格式,顺序就是题目本身。

把三种顺序摆在一起,顺便把「错在哪」数出来

order.cpp三种填表顺序并排跑,附带脏读计数
输入(stdin)
输出
点「运行 ▶」看结果
  填表顺序                      答案   读到还没算好的格子   和正解一样
  ----------------------------  ----   ------------------   ----------
  按区间长度从小到大              33                    0   是
  左端点倒序、右端点正序          33                    0   是
  左端点正序、右端点正序          15                   10   否

这份代码给每一格挂了一个「算好了没有」的标记,转移时只要读到没算好的就计一次数。 于是「顺序错了」不再是一句抽象的话,它是一个数字

⚠ 那为什么正文还是推荐按长度写

因为「短的先算」是一句不用每次都重新推的理由。 而 lrOrder.cpp 的正确性,每次都得把上面那张依赖表重新验一遍。

能不动脑子的地方就别动脑子 —— 但你得知道自己省的是哪一步脑子。 考场上遇到没见过的区间型转移(比如依赖的不是「更短的区间」而是别的东西), 按长度枚举可能就不管用了,那时候能救你的只有「依赖谁,就先填谁」。

9 动画:三角形的表,一层一层往上长

★ 区间 DP:长的要读短的,所以短的必须先填
答案 33
第 1 / 12 步
r=1
r=2
r=3
r=4
r=5
l=1
0
·
·
·
·
l=2
0
·
·
·
l=3
0
·
·
l=4
0
·
l=5
0
读到「还没算好」的格子
0
这个顺序对不对
✓ 对的
当前区间长度
答案 f[1][5]
每一行是左端点 l,每一列是右端点 r,只有右上半张表有意义。 浅绿的对角线是长度 1 的区间(代价 0),那是地基。蓝色 = 正在填的格子, 它的两个来源 f[l][k] 和 f[k+1][r] 会被标成绿色(已经算好)红色(还没算好)。 红色一出现,读到的就是初值 0,这一格的答案立刻变成假的。 换成第三档「左端点正序」看一遍,再回来看前两档。
f[l][r] = 把第 l 堆到第 r 堆合成一堆的最小代价。对角线(长度 1)全是 0,那是整张表的地基。现在按「区间长度从小到大」的次序往下填 —— 请盯住每一格的两个来源是不是都已经算好了。

每一行是左端点 l,每一列是右端点 r,只有右上半张表有意义 —— 所以它是个三角形。 浅绿的对角线是长度 1 的区间(代价 0),那是地基。

蓝色 = 正在填的格子,它的两个来源会被标成 绿色(已经算好)红色(还没算好)

盯住左下角那个计数器「读到还没算好的格子」:

填表顺序计数器答案
按区间长度从小到大033
左端点倒序、右端点正序033
左端点正序、右端点正序1015

红色一出现,读到的就是初值 0,这一格的答案立刻变成假的 —— 而程序不会有任何反应。这个计数器也参与交叉验证(防止「答案蒙对、过程画错」)。

10 ★ 打一次假:那个「每次合最小的相邻两堆」的贪心

这是这道题最经典的错误直觉,而且它错得很有来头(第 1 步那个 ⚠)。

greedy.cpp✗ 贪心:每次合并相邻两堆里和最小的一对
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 34,比正解多 1。多的这 1 是怎么丢的?看动画:

正解的合并顺序 vs「每次合最小的相邻两堆」
正解 33 · 贪心 34
第 1 / 6 步
正解
4
1
2
3
5
✗ 贪心
4
1
2
3
5
正解累计代价
0
贪心累计代价
0
此刻谁便宜
打平
第几次合并
0 / 4
两排石子从同一个起点出发,合并的次数也完全一样,差别只在「先合谁」。 默认数据上请特别注意第 2 步之后那一眼:贪心的累计代价是 9、正解是 10 ——贪心此时是领先的,第 3 步才被反超。 一个每一步都更便宜、最后却更贵的算法,就是这么骗过人的。
同一排石子,上面按正解的方案合,下面按「每次合并相邻两堆里和最小的那一对」合。两边都要合 4 次,最后都会变成一堆 —— 差别只在「先合谁」,以及右边那个累计代价。
★ 请务必看完第 2 步之后那一眼

两排石子从同一个起点出发,合并次数完全一样,差别只在先合谁。默认数据上:

第几次合并正解累计贪心累计谁便宜
1(两边都合 1+2=333打平
2109贪心领先
31819正解反超
43334正解赢

贪心的第一步是对的,第二步开始便宜,直到第三步才输。

这就是它这么难被说服的原因:它每一步都挑当时最便宜的那一对, 代价是把两个大堆留到了最后,而最后那几次合并是最贵的。

(这张表里的每一个数字都在 check:viz 里钉成了断言 —— 换了默认数据它就不成立了,那时脚本会立刻变红提醒我把这段重写。)

那这个贪心到底有多错?错得罕见还是错得普遍?别猜,枚举一遍:

greedyFind.cpp枚举小数据,找贪心的最小反例
输入(stdin)
输出
点「运行 ▶」看结果
  堆数   枚举组数   反例组数     占比   最小反例(字典序最小)   贪心 / 正解
  ----   --------   --------   ------   ----------------------   -----------
     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 的同款做法)一口气回答了三件事:

  1. 最小反例是 2 2 1 2(贪心 15、正解 14)—— 动画里有个按钮可以直接切过去看。
  2. 反例占比就是「随手造一组数据能抓住它」的概率。4 堆时只有 8.1%, 所以样例过了什么都证明不了。
  3. 3 堆时反例是 0 —— 那时候这个贪心是真的对的。
★ 第 3 条不是巧合,两句话能证明

3 堆时只有两种合并顺序:先合 (1,2),或者先合 (2,3)。 不管先合哪一对,最后那一次都是把整排并成一堆,代价恒等于总和。 所以 总代价 = 总和 + 先合的那一对之和 —— 挑和最小的那一对当然最优, 而那正好就是贪心干的事。

这条性质马上会变成一个大坑,第 12 步见。

11 顺便把第 23 章欠的账还了:输出合并方案

第 23 章末尾说过一句话:「要方案就得开二维表」。一维滚动数组只留得下答案, 留不下「这个答案是怎么来的」。

区间 DP 的 f 本来就是二维的,所以这笔账还起来特别便宜 —— 只要再开一张同样大的 from[l][r] 记下最优断点

path.cpp记 from[l][r],回溯输出合并方案
输入(stdin)
输出
点「运行 ▶」看结果
最小总代价 = 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
★ 回溯的顺序有讲究 —— 又是第 1 章那个「归」

要输出一个真的能照着做的合并序列,必须输出两个子区间内部的合并、 最后才输出这一次(后序遍历)。

反过来先输出自己,得到的序列是没法执行的 —— 那两堆当时还没并起来呢。

check:viz 对这份输出做的是硬验证,不是比字符串: 它维护当前这一排堆,逐行检查「这两段确实是当前相邻的两堆」、代价确实等于两堆之和, 最后确认只剩一堆、累计代价正好是 33。

12 ★ 对拍:以及一次「我的两个直觉都错了」的现场记录

对拍器
★ 这个生成器的灵魂是「堆数」,不是石子数。堆数一少,这道题会退化成一道贪心题(3 堆时那个错误贪心是真的对的),最经典的错误直觉就一轮都抓不到了。

300 轮实测,五个版本:

故意写错的地方被抓第几轮它其实解了哪道题
左端点正序(填表顺序)300 / 300第 1 轮允许一次合并任意多个连续堆
前缀和差一s[r]-s[l]300 / 300第 1 轮—(每次少加一堆)
断点范围差一kl+1 起)184 / 300第 2 轮—(凭空多了「左半段至少两堆」的限制)
贪心(每次合最小的相邻两堆)114 / 300第 4 轮哈夫曼树(不要求相邻的那道题)
左端点倒序、右端点正序0 / 300它就是正解(第 8 步那个 ★)
wrongSum.cpp(前缀和差一)✗ s[r] - s[l]
wrongK.cpp(断点范围差一)✗ k 从 l+1 开始
⚠ 偏小的错误和偏大的错误,不一样危险

前两个错误版本都是偏小的(读到 0、少加一堆),最后一个是偏大的。

偏小的错误你还能靠「答案怎么比暴力小」认出来; 偏大的不行 —— 一个偏大的答案和一个「数据比较难」的正确答案长得一模一样只能靠标准答案,不能靠眼力。

★ 生成器改了四次,而我动手前的两个直觉都是错的

gen.cpp 带了四个档位,你可以把当初那四次修改一次一次重跑一遍 (./gen 种子 档位)。种子固定 1..300:

档位石子数堆数抓住错误贪心抓住断点差一
0(最初)1 ~ 34 ~ 974 / 30066 / 300
11 ~ 94 ~ 979 / 300144 / 300
21 ~ 1004 ~ 9101 / 300168 / 300
3(在用)1 ~ 1006 ~ 9114 / 300184 / 300
4(废案)大小交错6 ~ 942 / 300173 / 300

① 「值域小才是灵魂」在这道题上是错的(档位 0 → 2,石子数放大反而更狠)。 第 22 章(LIS)里值域小确实是灵魂,因为那道题的 bug(lower/upper_bound 写反) 依赖的是相等。这道题的错误贪心依赖的是「相邻两对的和谁大谁小」—— 它要的是对比度。石子数全挤在 1~3 里,每一对都差不多,贪心反而不容易露馅。

规矩本身没变(要随机的是算法依赖的那个量),变的是「那个量」是谁。 这一条得每道题重新问一遍,不能背。

② 「大小交错」这种看着很刁钻的花样,实测是最差的一档(42 / 300)。 交错排开之后「哪一对最小」几乎总是那几对固定的小的,局面反而变单调了。 数据里的花样和打得准是两回事。

真正起作用的旋钮是堆数:49 → 69,两个 bug 的抓获率一起涨。

gen.cpp(带四个档位的生成器)四次改动都能重跑
★ 而只要把堆数造成 3,那个贪心就彻底隐身了

我另写了一份 genSmall.cpp,和最终档比只改了一处:堆数固定成 3。同样跑 300 轮:

故意写错的地方正常数据(6~9 堆)只有 3 堆的数据
贪心(每次合最小的相邻两堆)114 / 3000 / 300
断点范围差一184 / 300136 / 300
左端点正序300 / 300300 / 300
前缀和差一300 / 300300 / 300

一个 bug 完全隐身,其它照旧。 而且这次不用猜原因 —— 第 10 步已经证明过了:3 堆时那个贪心是真的对的greedyFind.cpp 枚举全部 216 组三堆数据,反例数正好是 0。

genSmall.cpp(故意造得很温柔的生成器)演示用:反面教材

这是第 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 自测

自测清单0 / 10
配套练习
  • 洛谷 P1775 石子合并(弱化版) —— 本章原题,直线版。写完直接交,一遍就该过
  • 洛谷 P1880 [NOI1995] 石子合并 —— ★ 环形版,而且要同时求最小和最大。关键技巧是「破环成链」:把序列复制一遍接在后面,跑长度为 n 的所有区间。求最大值只需要把 min 换成 max —— 但那个错误贪心对最大值同样是错的
  • 洛谷 P1063 [NOIP2006 提高组] 能量项链 —— 环形区间 DP 的另一张皮。合并的代价换了个公式,框架一个字不用改 —— 正好确认自己抓到的是框架而不是那道题
  • 洛谷 P1040 [NOIP2003 提高组] 加分二叉树 —— ★ 区间 DP + 输出方案,本章第 11 步那套 from[l][r] 回溯原样能用。而且它的「根」就是本章的「断点」
  • 洛谷 P4170 [CQOI2007] 涂色 —— 区间 DP 经典。转移里多了一个「两端颜色相同」的特判,想清楚那个特判为什么成立
  • 洛谷 P1220 关路灯 —— 进阶:状态在区间之外还要多记一维「人现在站在左端还是右端」。适合确认自己是真的会了「状态该怎么定」
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)