第28篇-区间DP与序列DP-从最长回文子序列讲起

概述

前面我们已经讲过线性 DP 和背包 DP。

线性 DP 通常是沿着一个方向推进:

text 复制代码
dp[i] 由前面的状态推出来

背包 DP 通常是在容量限制下做选择:

text 复制代码
dp[j] 表示容量为 j 时的最优结果

到了区间 DP,问题会变成:

text 复制代码
一个区间 [i, j] 的答案,如何由更小的区间推出来

这也是很多初学者第一次明显感觉动态规划变难的地方。

因为它不再只是从左到右扫一遍,而是要处理二维状态和区间长度。

本篇会重点讲:

  1. 什么是区间 DP
  2. 区间 DP 和序列 DP 有什么区别
  3. 最长回文子序列怎么定义状态
  4. 为什么区间 DP 的遍历顺序很关键
  5. 区间 DP 的常见模板和坑点

学完这篇,你应该能看懂 dp[i][j] 这种区间状态,并能独立写出最长回文子序列的动态规划解法。

核心概念:什么是区间 DP

区间 DP 处理的是一段连续区间上的最优问题。

常见状态长这样:

text 复制代码
dp[i][j] 表示区间 [i, j] 上的某种最优解

这里的 ij 分别表示区间的左右边界。

区间 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 个字符的最长公共子序列长度

这里的 ij 表示两个序列的前缀长度。

它不是同一个字符串里的左右边界。

序列 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] 已经算好
  • ji + 1 往后:保证区间逐渐变长

边界补充

如果题目允许空字符串,可以加一个判断:

java 复制代码
if (s == null || s.length() == 0) {
    return 0;
}

很多在线题库会保证 s.length() >= 1,这时可以不写。

这段代码的核心不是 if else,而是遍历顺序保证所有依赖状态都已经算过。

用表格理解 dp[i][j]

假设:

text 复制代码
s = "bbbab"

dp[i][j] 表示从 ij 的最长回文子序列长度。

最终答案在:

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,再看一个经典例子:最长公共子序列。

题目描述:

给定两个字符串 text1text2,求它们的最长公共子序列长度。

状态定义

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 + 1j - 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 都可能是二维数组,但状态含义不同