【算法】动态规划第五篇:区间 DP------从"看什么都像背包"到"最后戳谁"
摘要
DP 系列第五篇,第 4 级区间 DP 两课全记录。LC516 最长回文子序列 :开局把区间题误判成 0-1 背包(刚考完背包的范式惯性),自己爬出来后四要素(区间定义 / 两端转移 / 计算顺序 / 右上角答案)全部自推成功,卡在一个 j 的下标语义错位(1..n vs 0..n-1)上 panic 现场被抓------手推表和代码必须严格同构 的教训落地。LC312 戳气球 (hard,区间 DP 天花板):排列/组合的背包语言整体失效(所有气球都要戳光,没有子集自由度);钥匙是盯"最后戳破的气球 k"而不是"最先戳谁" ------"最后"才能保证左右两半独立;从"猜格子"到"逐候选列算式"的质变,12 个转移分支手推全对后代码一次过。本篇还集齐了"最后一步分类法"的三种形态:端点版 (516)、分割点版 (312)、加减号版(494)。
前置阅读:动态规划第四篇:背包收官------min哨兵、计数世界与组合排列分水岭。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode
【算法】动态规划第五篇:区间 DP------从"看什么都像背包"到"最后戳谁"
- [【算法】动态规划第五篇:区间 DP------从"看什么都像背包"到"最后戳谁"](#【算法】动态规划第五篇:区间 DP——从"看什么都像背包"到"最后戳谁")
-
- 摘要
- [1. LC516 最长回文子序列:范式惯性与四要素](#1. LC516 最长回文子序列:范式惯性与四要素)
-
- [1.1 开局误判:刚考完背包,看什么都像背包](#1.1 开局误判:刚考完背包,看什么都像背包)
- [1.2 四要素(全部自推成功)](#1.2 四要素(全部自推成功))
- [1.3 卡点:`j` 的语义错位一格](#1.3 卡点:
j的语义错位一格)
- [2. LC312 戳气球:天花板之战](#2. LC312 戳气球:天花板之战)
-
- [2.1 背包语言整体失效](#2.1 背包语言整体失效)
- [2.2 灵魂:盯"最后戳谁",不是"最先戳谁"](#2.2 灵魂:盯"最后戳谁",不是"最先戳谁")
- [2.3 两个工程细节](#2.3 两个工程细节)
- [2.4 从"猜格子"到"逐候选":一轮回合的质变](#2.4 从"猜格子"到"逐候选":一轮回合的质变)
- [2.5 代码:手推的誊写](#2.5 代码:手推的誊写)
- [3. 区间 DP 沉淀总表](#3. 区间 DP 沉淀总表)
- [4. 下一步:状态机 DP](#4. 下一步:状态机 DP)
- 总结
- 参考资料
1. LC516 最长回文子序列:范式惯性与四要素
"bbbab"→ 4("bbbb");"cbbd"→ 2("bb")。
1.1 开局误判:刚考完背包,看什么都像背包
第一版分析写的是"选不选的问题、组合问题、0-1 背包、金额在内物品在外"------把刚毕业的背包模型整套搬来。两个根本不匹配:
- 没有"金额" :背包的第二维是标量约束(容量/目标),这题的第二个自由度是位置(子序列从哪选到哪)
- "前 i 个"不够用 :回文是两端结构 。LIS 的一维
dp[i](以 i 结尾)能活,因为子序列只需单端信息;回文需要知道两端配没配对------一维前缀里没存这个
钥匙还是老方法论------看着答案长出来 :"bbbb" 占了位置 0、1、2、4,一个回文子序列由一个可选范围界定:
dp[i][j] = 在 s[i..j] 范围内,能选出的最长回文子序列长度
区间 DP 登场 :dp[i][j] 的两个下标第一次指向同一条串------不是双序列的"两条串各一个前缀",是一条串的左右端点。
1.2 四要素(全部自推成功)
| 要素 | 内容 |
|---|---|
| 转移 | s[i]==s[j] → dp[i+1][j-1]+2(两端配对,区间收缩);不等 → max(dp[i+1][j], dp[i][j-1])(放弃一端) |
| 边界 | 对角线 dp[i][i]=1;i>j(空区间)零值天然正确 |
| 顺序 | i 倒序、j 正序------大区间依赖小区间 |
| 答案 | dp[0][n-1](整个区间) |
计算顺序是第一篇埋的伏笔正式兑现:dp[i][j] 依赖的三个格子(dp[i+1][j-1] 左下、dp[i+1][j] 正下、dp[i][j-1] 左边)里有两个在下一行 ------按老习惯 i 正序逐行算会读到没算过的格子。计算顺序必须保证被依赖的先算好,i 倒序是天然解。
1.3 卡点:j 的语义错位一格
第一版代码 for j := 1; j < n+1------j 跑到 n,s[n] 越界 panic(index out of range [5] with length 5)。根因:手推表的列头是 0..n-1,代码却是 1..n------两套下标系统错位一格 。还有个被 panic 挡住的隐藏 bug:j 从 1 起跑会碰到 i > j 的空区间,乱算出垃圾值再顺着相邻配对扩散。
区间 DP 标准姿势:j 从 i+1 起 ------只碰上三角,空区间一个不碰。修正后还有个漂亮细节:相邻配对(j = i+1)时转移读的 dp[i+1][j-1] = dp[i+1][i] 落在零值区------零值恰好是空区间的正确答案,连种都不用种。
通用教训:代码的下标系统必须和手推表严格同构------表是 0...n-1,代码就得是 0...n-1,错一格就是 panic 或垃圾值。
2. LC312 戳气球:天花板之战
nums = [3,1,5,8]→ 167:3×1×5 + 3×5×8 + 1×3×8 + 1×8×1。
2.1 背包语言整体失效
第一版分析:"顺序有关,所以是排列"、"金额在外,物品在内"。失效原因比 516 更根本:
- 312 没有"选不选" ------背包的前提是"从物品里选子集",这题所有气球最终都要被戳光,没有子集自由度,只有顺序
- 顺序的自由度是
n!(4 个气球 24 种戳序)------顺序不能枚举,必须被某个分类吸收掉
(不过第一版手推有个意外的宝藏:逐个气球算的 3 / 15 / 40 / 40 四个得分,恰好就是后来 dp 表的第一条对角线------推对了四格而不自知。)
2.2 灵魂:盯"最后戳谁",不是"最先戳谁"
516 盯区间两端 ;312 盯区间内部的分割点 k------区间 (i,j) 里最后被戳破的那个气球。
为什么必须"最后"而不是"最先"(312 最深的一步):
最先戳 k :k 消失后两边的气球拼到一起,剩余的不构成连续区间------子问题说不出"从哪到哪",状态无法用 (i,j) 描述。
最后戳 k :k 一直站着,把区间隔成左右两半
(i,k)和(k,j)------两半各自内部爱怎么戳怎么戳,互不影响 (独立性!);且轮到最后一戳时,k 的邻居必然是 i 和 j (中间的都戳光了)------这一戳得val[i]·val[k]·val[j],与两半内部的戳序无关。
"最后一步分类法"至此集齐三种形态:
| 形态 | 盯什么 | 代表题 |
|---|---|---|
| 端点版 | 区间两端配不配对 | 516 回文、53 最大子数组("以 i 结尾") |
| 分割点版 | 区间内最后处理的元素是谁 | 312 戳气球、(同类:矩阵链乘法) |
| 加减号版 | 每个元素的符号归属 | 494 目标和 |
2.3 两个工程细节
- 补 1 :首尾各垫虚拟气球,
val = [1, ...nums, 1]------边界邻居一次解决 - 开区间约定 :
dp[i][j]= 戳光开区间 (i,j) 内所有气球的最大得分------i、j 本身不戳 ,只当邻居。空区间(j <= i+1)零值即正确
2.4 从"猜格子"到"逐候选":一轮回合的质变
第二版手推表把"戳单个气球的得分"(3/15/40/40)复制粘贴满表------不是在执行转移,是在猜。诊断出三个结构问题(行列头要用下标不是 val 值、只有右上三角有意义、每格必须按转移算),然后逐层带推:
len=3(区间里 1 个气球,k 没得选):dp[i][i+2] = val[i]·val[i+1]·val[j]
dp[0][2]=3 dp[1][3]=15 dp[2][4]=40 dp[3][5]=40 ← 第一版手推的四格归位
len=4 的教学密度之王 dp[0][3]:
k=1(最后戳3): dp[0][1] + dp[1][3] + 1×3×5 = 30 ← 先戳1再戳3
k=2(最后戳1): dp[0][2] + dp[2][3] + 1×1×5 = 8 ← 第一版手推的"3,1=8"就是这条!
max = 30
彩蛋兑现:第一版手推的 3,1 = 8 不是错------它是 k=2 分支的真实得分;但 k=1 分支拿 30。dp 表的 max 替你试了所有戳序------这就是为什么不用枚举 n!。
之后独立推完全表(len=4 剩两格、len=5 两格、len=6 终点),12 个转移分支全对,锚点全部命中(135/48/159/159/167)。终点 dp[0][5]=167 来自 k=4------全局最后被戳的是 8,整棵"倒序戳法树"就藏在表里:dp 表不只给答案,它存着整条最优路径。
2.5 代码:手推的誊写
go
func maxCoins(nums []int) int {
nNums := append(append([]int{1}, nums...), 1)
n := len(nNums)
dp := make([][]int, n)
for i := range dp {
dp[i] = make([]int, n)
}
for l := 3; l <= n; l++ { // 外层:区间长度------手推就是按 len 逐层推的
for i := 0; i+l-1 < n; i++ {
j := i + l - 1
for k := i + 1; k < j; k++ { // 分割点:最后戳谁
dp[i][j] = max(dp[i][j], dp[i][k]+dp[k][j]+nNums[i]*nNums[k]*nNums[j])
}
}
}
return dp[0][n-1]
}
len 外层的循环结构和手推的逐层轨迹完全镜像 ------纸上怎么推的,代码就怎么跑。六用例全绿(含 LeetCode 的 [1,2,3,4,5] → 110),一次过。
3. 区间 DP 沉淀总表
| 要素 | 内容 | 备注 |
|---|---|---|
| 定义 | dp[i][j] = 区间 i,j 上的最优 |
两个下标指向同一条串;i ≤ j 耦合,只有上三角 |
| 与双序列的区别 | 双序列是"两条串的前缀交叉";区间是"一条串的两端点" | 516 开局误判的根源 |
| 转移·端点版 | 盯两端:相等收缩 +2,不等放弃一端 | 516 |
| 转移·分割点版 | 盯区间内最后处理的 k:左半 + 右半 + 跨界一步 | 312 |
| 计算顺序 | 大区间依赖小区间 → len 从小到大,或 i 倒序 j 正序 | 第一篇"计算顺序保证被依赖先算"的伏笔兑现 |
| 边界 | 对角线 / 空区间,零值常天然正确 | j 从 i+1 起,无效区不碰 |
| 答案 | dp[0][n-1](516 闭区间)/ dp[0][n+1](312 补 1 开区间) |
"整个区间" |
| 下标纪律 | 代码与手推表严格同构(0...n-1 就都 0...n-1) | 错一格 = panic 或垃圾值 |
方法论增补:
| 问题 | 判据 | 出处 |
|---|---|---|
| 这是区间 DP 吗 | 答案由"一段连续范围"上的结构决定(回文/合并/戳破),且一维前缀信息不足 | 516 |
| 转移盯什么 | 两端有配对关系 → 端点版;"处理顺序影响得分且全体都要处理" → 分割点版(问"谁最后") | 312 |
| 分割点为什么选"最后" | "最后"保证子区间独立 + 跨界一步的得分与内部顺序无关 | 312 |
| 手推纪律 | 行列头用下标(防值重复撞车);每格列全 k 候选的算式再取 max,不许猜 | 312 |
4. 下一步:状态机 DP
第 5 级是状态机 DP (股票系列开路,LC121/122/188/309/714 一族):dp[i][j] 的 j 变成"今天结束时所处的状态 "(持有/不持有/冷冻期)------每天一个决策,状态之间按规则迁移。它是回溯期"计数器状态"(括号生成的 open/close)的亲戚:约束只看计数/状态、不看历史路径,一维天数 + 一维状态就能装下整个世界。区间 DP 之后,这是 DP 最后一个大类。
总结
两道题,两天,几个感受:
范式惯性是最体面的坑 。516 开局整套搬背包,312 开局"排列组合"语言打架------但两次都是自己爬出来的,而且爬出来的路径高度一致:先撞墙(模型推不出转移),再回方法论(看答案长出来 / 最后一步分类法)。误判不可怕,识别误判的速度才决定学习效率。
312 的难,难在构思,不难在执行 。想不通"最后戳谁"时举步维艰;想通之后,12 个分支的手推是体力活,代码是手推的誊写。而"逐候选列算式"这个动作(从"猜格子"质变而来)就是检验构思是否真通的标准------能算出来的才叫理解,看着像的就叫猜。
手推和代码是同一张图纸的两份拷贝 。516 的下标错位教训(表 0...n-1、代码 1...n → panic)和 312 的正面示范(len 循环镜像逐层手推 → 一次过)是一体两面:图纸画对了,代码只是誊写;图纸和代码不同构,互证当场爆炸。
下一篇:状态机 DP 见。股票系列一天一个决策,等着我。
参考资料
- LeetCode 516. 最长回文子序列
- LeetCode 312. 戳气球
- LeetCode 494. 目标和(加减号版分类)
- 动态规划第一篇:从打家劫舍到状态设计的三堂课(依赖方向 vs 计算顺序的伏笔源头)
- 动态规划第四篇:背包收官------min哨兵、计数世界与组合排列分水岭
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。