【算法】动态规划第五篇:区间 DP——从“看什么都像背包“到“最后戳谁“

【算法】动态规划第五篇:区间 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 背包、金额在内物品在外"------把刚毕业的背包模型整套搬来。两个根本不匹配:

  1. 没有"金额" :背包的第二维是标量约束(容量/目标),这题的第二个自由度是位置(子序列从哪选到哪)
  2. "前 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]=1i>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 跑到 ns[n] 越界 panic(index out of range [5] with length 5)。根因:手推表的列头是 0..n-1,代码却是 1..n------两套下标系统错位一格 。还有个被 panic 挡住的隐藏 bug:j 从 1 起跑会碰到 i > j 的空区间,乱算出垃圾值再顺着相邻配对扩散。

区间 DP 标准姿势:ji+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 更根本:

  1. 312 没有"选不选" ------背包的前提是"从物品里选子集",这题所有气球最终都要被戳光,没有子集自由度,只有顺序
  2. 顺序的自由度是 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 正序 第一篇"计算顺序保证被依赖先算"的伏笔兑现
边界 对角线 / 空区间,零值常天然正确 ji+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 见。股票系列一天一个决策,等着我。

参考资料


版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

相关推荐
whitelbwwww1 小时前
c++ 多线程
开发语言·c++·算法
额鹅恶饿呃1 小时前
随着CentOS官方停服的时间越来越久,大量仍在使用CentOS7的企业和运维从业者
java·python·算法·c#·ruby
白狐_7981 小时前
408 数据结构|KMP算法核心:为什么不用主串回退
数据结构·算法
wuyk5551 小时前
13.堆排序:基于完全二叉树的高效排序算法一、什么是堆排序?
开发语言·算法·排序算法
qinzechen2 小时前
本周科技行业热点汇总·2026第36周(2026年8月31日-9月6日)
c++·科技·算法
白狐_7982 小时前
408 数据结构|红黑树 vs AVL:高频考点、易错判断与可能出题方式
数据结构·算法
zuozong_2 小时前
C++类与对象
开发语言·c++·算法
hansang_IR2 小时前
【代码】分层最短路模板
c++·算法·最短路
啊阿狸不会拉杆2 小时前
《计算机网络-自顶向下方法》5.2 路由选择算法 读书笔记
计算机网络·算法·路由