概述
前面我们已经讲过线性 DP 和背包 DP。
线性 DP 通常是沿着一个方向推进:
text
dp[i] 由前面的状态推出来
背包 DP 通常是在容量限制下做选择:
text
dp[j] 表示容量为 j 时的最优结果
到了区间 DP,问题会变成:
text
一个区间 [i, j] 的答案,如何由更小的区间推出来
这也是很多初学者第一次明显感觉动态规划变难的地方。
因为它不再只是从左到右扫一遍,而是要处理二维状态和区间长度。
本篇会重点讲:
- 什么是区间 DP
- 区间 DP 和序列 DP 有什么区别
- 最长回文子序列怎么定义状态
- 为什么区间 DP 的遍历顺序很关键
- 区间 DP 的常见模板和坑点
学完这篇,你应该能看懂 dp[i][j] 这种区间状态,并能独立写出最长回文子序列的动态规划解法。
核心概念:什么是区间 DP
区间 DP 处理的是一段连续区间上的最优问题。
常见状态长这样:
text
dp[i][j] 表示区间 [i, j] 上的某种最优解
这里的 i 和 j 分别表示区间的左右边界。
区间 DP 的常见题型
- 最长回文子序列
- 最长回文子串
- 戳气球
- 石子合并
- 矩阵链乘法
- 区间内最小代价、最大收益
这些题都有一个共同点:
text
大区间的答案,依赖小区间的答案
和线性 DP 的区别
线性 DP 常见状态是:
text
dp[i]
区间 DP 常见状态是:
text
dp[i][j]
线性 DP 更多关注"走到某个位置"。
区间 DP 更多关注"某一段范围"。
区间 DP 的核心,是用小区间推出大区间。
序列 DP:先理解另一类二维状态
在讲最长回文子序列之前,先区分一个容易混淆的概念:序列 DP。
序列 DP 通常也是二维数组,但含义不一定是区间。
例如最长公共子序列 LCS:
text
dp[i][j] 表示 text1 的前 i 个字符和 text2 的前 j 个字符的最长公共子序列长度
这里的 i、j 表示两个序列的前缀长度。
它不是同一个字符串里的左右边界。
序列 DP 的特点
- 通常处理一个或两个序列
- 状态经常表示前缀
- 遍历顺序通常是从小下标到大下标
- 典型问题是匹配、编辑距离、公共子序列
区间 DP 的特点
- 通常处理一个连续区间
- 状态表示
[i, j] - 遍历顺序经常和区间长度有关
- 典型问题是回文、合并、区间最优
对比表
| 类型 | 状态含义 | 常见遍历 | 典型题 |
|---|---|---|---|
| 序列 DP | 前缀、匹配、位置关系 | 从前往后 | 最长公共子序列、编辑距离 |
| 区间 DP | 区间 [i, j] 的最优解 |
按区间长度或反向枚举左边界 | 最长回文子序列、石子合并 |
序列 DP 的二维状态多表示前缀关系,区间 DP 的二维状态多表示左右边界。
经典问题:最长回文子序列
题目描述:
给定一个字符串
s,找出其中最长回文子序列的长度。
注意这里是"子序列",不是"子串"。
子序列和子串的区别
- 子序列:可以不连续,但相对顺序不能变
- 子串:必须连续
例如:
text
s = "bbbab"
最长回文子序列可以是:
text
"bbbb"
长度为 4。
这里的 "bbbb" 不要求在原字符串中连续出现,只要顺序不变即可。
最长回文子序列要找的是保留相对顺序后的最长回文,不要求连续。
状态定义:为什么要用 dp[i][j]
对于最长回文子序列,最自然的状态是:
text
dp[i][j] 表示字符串 s 在区间 [i, j] 内的最长回文子序列长度
例如:
text
s = "bbbab"
dp[1][3] 表示区间 s[1..3],也就是 "bba" 的最长回文子序列长度
为什么不用一维状态
因为回文天然和左右两端有关。
要判断一个区间是不是能形成更长回文,通常要看:
text
s[i] 和 s[j] 是否相等
这就需要同时知道左边界和右边界。
最长回文子序列的关键,是把问题定义成区间 [i, j] 上的最优解。
状态转移:两端字符相等怎么办
考虑区间 [i, j]。
如果:
text
s[i] == s[j]
说明这两个字符可以作为回文子序列的两端。
那么区间 [i, j] 的答案就可以由中间区间 [i + 1, j - 1] 扩展而来:
text
dp[i][j] = dp[i + 1][j - 1] + 2
举个例子
text
s = "bbab"
区间 [0, 3] 的左右字符都是 'b'
中间区间是:
text
s[1..2] = "ba"
如果中间最长回文子序列长度是 1,那么两边加上 'b' 和 'b',长度就变成 3。
两端字符相等时,可以把中间区间的答案加上这两个端点。
状态转移:两端字符不相等怎么办
如果:
text
s[i] != s[j]
说明这两个字符不能同时作为同一个回文子序列的两端。
这时有两种选择:
- 不要左端点
s[i],看区间[i + 1, j] - 不要右端点
s[j],看区间[i, j - 1]
所以:
text
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
为什么取最大值
因为题目要求最长长度。
既然左右端点不能同时用,那就分别尝试去掉一个端点,然后取更优结果。
两端字符不相等时,只能丢掉一边,取两个较小区间的最大值。
初始化:单个字符一定是回文
任何一个单独字符,本身就是长度为 1 的回文子序列。
所以:
text
dp[i][i] = 1
例如:
text
"a"、"b"、"c"
它们都是回文。
空区间怎么处理
在代码里通常不显式处理空区间。
当 i + 1 > j - 1 时,二维数组默认值 0 就可以自然表示空区间长度。
例如长度为 2 的区间:
text
s[i] == s[j]
dp[i][j] = dp[i + 1][j - 1] + 2
此时中间是空区间,默认 0 + 2 正好得到 2。
区间 DP 的基础情况通常是最短区间,最长回文子序列里就是 dp[i][i] = 1。
遍历顺序:区间 DP 最容易写错的地方
最长回文子序列的转移依赖:
text
dp[i + 1][j - 1]
dp[i + 1][j]
dp[i][j - 1]
也就是说,计算 dp[i][j] 前,下面这些状态必须已经算好:
- 左下方
- 下方
- 左方
常用写法一:左边界倒序,右边界正序
java
for (int i = n - 1; i >= 0; i--) {
dp[i][i] = 1;
for (int j = i + 1; j < n; j++) {
// 状态转移
}
}
为什么 i 要倒序?
因为 dp[i][j] 依赖 dp[i + 1][j]。
所以必须先算更大的 i,再算更小的 i。
常用写法二:按区间长度枚举
java
for (int len = 1; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
// 状态转移
}
}
这种写法的思路是:
text
先算短区间,再算长区间
它适用于很多区间 DP 题。
区间 DP 的遍历顺序必须保证小区间先算出来,大区间才能正确转移。
Java 实现:最长回文子序列
java
class Solution {
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
for (int i = n - 1; i >= 0; i--) {
dp[i][i] = 1;
for (int j = i + 1; j < n; j++) {
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
}
代码为什么这样写
关键点有三个:
dp[i][i] = 1:单个字符是回文i从后往前:保证dp[i + 1][j]已经算好j从i + 1往后:保证区间逐渐变长
边界补充
如果题目允许空字符串,可以加一个判断:
java
if (s == null || s.length() == 0) {
return 0;
}
很多在线题库会保证 s.length() >= 1,这时可以不写。
这段代码的核心不是 if else,而是遍历顺序保证所有依赖状态都已经算过。
用表格理解 dp[i][j]
假设:
text
s = "bbbab"
dp[i][j] 表示从 i 到 j 的最长回文子序列长度。
最终答案在:
text
dp[0][4]
因为它表示整个字符串 [0, 4] 的答案。
状态依赖关系
可以把它想成一个二维表:
text
j ->
0 1 2 3 4
i 0 [1][ ][ ][ ][答案]
1 [1][ ][ ][ ]
2 [1][ ][ ]
3 [1][ ]
4 [1]
只有 i <= j 的上三角区域有意义。
为什么最后看右上角
因为:
text
i = 0
j = n - 1
正好表示整个字符串。
区间 DP 通常只使用二维表的一部分,最终答案常在 dp[0][n - 1]。
子序列和子串:最长回文子序列不要和子串混淆
最长回文子序列和最长回文子串只差一个字,但解法差很多。
最长回文子序列
子序列可以不连续。
状态通常是:
text
dp[i][j] 表示区间 [i, j] 的最长回文子序列长度
最长回文子串
子串必须连续。
常见状态是:
text
dp[i][j] 表示 s[i..j] 是否是回文串
转移通常是:
text
s[i] == s[j] && dp[i + 1][j - 1]
对比表
| 题目 | 是否连续 | dp[i][j] 含义 |
返回内容 |
|---|---|---|---|
| 最长回文子序列 | 不要求连续 | 区间内最长长度 | 长度 |
| 最长回文子串 | 必须连续 | 区间是否为回文 | 字符串或长度 |
子序列看最优长度,子串更关注某个连续区间是否本身就是回文。
序列 DP 示例:最长公共子序列
为了更清楚地区分序列 DP,再看一个经典例子:最长公共子序列。
题目描述:
给定两个字符串
text1和text2,求它们的最长公共子序列长度。
状态定义
text
dp[i][j] 表示 text1 的前 i 个字符和 text2 的前 j 个字符的最长公共子序列长度
注意这里是"前 i 个"和"前 j 个",不是区间 [i, j]。
状态转移
如果两个字符相等:
text
text1[i - 1] == text2[j - 1]
dp[i][j] = dp[i - 1][j - 1] + 1
如果不相等:
text
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
Java 代码
java
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
}
为什么它不是区间 DP
因为 dp[i][j] 不是表示同一个字符串的 [i, j] 区间。
它表示两个字符串的前缀之间的匹配关系。
最长公共子序列是典型序列 DP,二维下标代表两个前缀长度。
区间 DP 的通用模板
区间 DP 常见模板有两种。
模板一:按区间长度枚举
java
for (int len = 1; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (i == j) {
dp[i][j] = base;
} else {
dp[i][j] = transition;
}
}
}
这种写法通用性很强,很多区间合并类问题都能套。
模板二:左边界倒序
java
for (int i = n - 1; i >= 0; i--) {
dp[i][i] = base;
for (int j = i + 1; j < n; j++) {
dp[i][j] = transition;
}
}
最长回文子序列就很适合这个模板。
两种模板怎么选
- 如果转移明显依赖更短区间,用区间长度模板
- 如果转移主要依赖
i + 1和j - 1,左边界倒序更简洁
区间 DP 的模板不难,难点是根据依赖关系选对遍历顺序。
常见变形:最少插入次数变成回文串
最长回文子序列还有一个常见变形:
给定字符串
s,最少插入多少个字符能让它变成回文串?
这题可以直接借助最长回文子序列理解。
核心结论
text
最少插入次数 = n - 最长回文子序列长度
为什么?
因为最长回文子序列代表原字符串中已经能保留下来的回文骨架。
剩下那些不在回文骨架里的字符,都需要通过插入来补齐匹配。
示例
text
s = "mbadm"
最长回文子序列长度是 3,例如 "mam" 或 "mdm"。
字符串长度是 5,所以最少插入次数是:
text
5 - 3 = 2
很多回文构造题,背后都能转化成最长回文子序列。
常见问题点
1. 状态含义没写清楚
dp[i][j] 到底表示什么?
- 是区间
[i, j]的最长长度 - 是区间
[i, j]是否成立 - 是区间
[i, j]的最小代价
含义不同,转移就完全不同。
2. 遍历顺序和依赖关系不匹配
如果 dp[i][j] 依赖 dp[i + 1][j],那 i 就不能简单从小到大遍历。
3. 忘记初始化短区间
区间 DP 通常要先处理:
- 长度为
1的区间 - 长度为
2的区间 - 空区间默认值
不同题处理方式不同。
4. 把子序列写成子串
最长回文子序列不要求连续。
如果把它当成连续子串,会直接写成另一道题。
5. 返回位置写错
区间 DP 的答案通常是:
text
dp[0][n - 1]
但序列 DP 的答案可能是:
text
dp[m][n]
这两类不要混用。
区间 DP 的错误通常不是代码长,而是状态含义、遍历顺序和返回位置没有对齐。
复杂度分析:区间 DP 通常是平方级起步
对于最长回文子序列:
- 状态数量是
n * n - 每个状态的转移是
O(1)
所以时间复杂度是:
text
O(n^2)
空间复杂度是:
text
O(n^2)
能不能优化空间
有些二维 DP 可以优化空间,但区间 DP 的空间优化通常更绕。
对于初学阶段,建议先保证二维写法正确。
如果过早压缩空间,很容易把依赖关系写乱。
区间 DP 往往需要枚举左右边界,所以常见复杂度是 O(n^2) 或更高。
标准思考流程:遇到区间 DP 应该怎么想
可以按下面顺序分析:
第一步:判断是不是区间问题
看题目是否围绕一段连续范围展开:
- 子串
- 区间
- 合并
- 回文
- 两端选择
第二步:定义 dp[i][j]
写清楚:
text
dp[i][j] 表示区间 [i, j] 的什么结果
第三步:分析两端字符或分割点
很多区间 DP 有两种思路:
- 看左右端点
- 枚举中间分割点
最长回文子序列属于看左右端点。
第四步:确定初始化
先处理最短区间。
第五步:确定遍历顺序
保证所有依赖状态都已经算过。
区间 DP 要先确定状态和依赖,再反推出正确遍历顺序。
总结
区间 DP 是动态规划里很重要的一类模型。
它比线性 DP 难,主要难在二维状态和遍历顺序。
你可以重点记住下面几句话:
- 区间 DP 常用
dp[i][j]表示区间[i, j]的答案 - 大区间通常由小区间转移而来
- 最长回文子序列的状态是区间内的最长回文子序列长度
- 两端字符相等时,答案来自中间区间加
2 - 两端字符不相等时,丢掉一边取最大值
- 区间 DP 的遍历顺序必须满足依赖关系
- 序列 DP 和区间 DP 都可能是二维数组,但状态含义不同