好的,在回顾了你已学习过的海量题目后,我将为你讲解一个未被提及的线性动态规划经典问题。
线性动态规划:最长回文子序列(基础版——求解长度)
题目描述:
给你一个字符串 s,请你找出并返回这个字符串中最长的 回文子序列 的长度。
子序列 定义为:在不改变字符相对顺序的情况下,通过删除某些字符(也可以不删除)后组成的新字符串。
回文 定义为:一个字符串正着读和反着读是一样的。
示例 1:
输入:s = "bbbab"
输出:4
解释:一个可能的最长回文子序列是 "bbbb"。
示例 2:
输入:s = "cbbd"
输出:2
解释:一个可能的最长回文子序列是 "bb"。
解题过程循序渐进讲解:
我们的目标是,在一个序列(字符串)中,找出一个符合特定条件(回文)的最长子序列。由于“子序列”不要求连续,这提示我们不能简单地使用处理子数组(连续)的思路。动态规划是解决这类问题的有力工具。
步骤一:定义状态
我们定义一个二维数组 dp,其中 dp[i][j] 表示字符串 s 在区间 [i, j](即从下标 i 到下标 j 包含两端)内的子串中,最长回文子序列的长度。
i和j分别代表子串的起始和结束下标。- 我们的最终答案就是
dp[0][n-1],其中n是字符串s的长度。
步骤二:寻找状态转移方程
我们需要思考,如何从更小的子问题推导出 dp[i][j]。这取决于子串两端字符 s[i] 和 s[j] 的关系。
-
情况一:
s[i] == s[j]
如果两端的字符相等,那么它们可以作为我们正在寻找的回文子序列的头和尾。在这种情况下,最优的回文子序列长度,就等于去掉头尾后,中间部分(子串s[i+1...j-1])的最长回文子序列长度,再加上这两个相等的字符(长度+2)。- 转移方程:
dp[i][j] = dp[i+1][j-1] + 2 - 注意:当
i == j时(即子串只有一个字符),i+1会大于j-1,我们定义这种情况的基础值是 0。
- 转移方程:
-
情况二:
s[i] != s[j]
如果两端的字符不相等,那么它们不可能同时作为最终回文子序列的头和尾。因此,我们必须从两个方向“舍弃”一个字符,看哪个能得到更长的回文子序列。- 选项A:舍弃
s[i],考虑子串s[i+1...j]的最长回文子序列长度,即dp[i+1][j]。 - 选项B:舍弃
s[j],考虑子串s[i...j-1]的最长回文子序列长度,即dp[i][j-1]。 - 我们选择长度更长的那个。
- 转移方程:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
- 选项A:舍弃
步骤三:确定初始条件和边界情况
- 单个字符:任何单个字符本身就是一个长度为 1 的回文串。所以对于所有
i,有dp[i][i] = 1。 - 空串:当
i > j时,表示我们考虑的子串是空的,其最长回文子序列长度自然为 0。这在我们的计算中,特别是处理s[i] == s[j]且i+1 > j-1的情况(即i和j相邻,j = i+1)时,需要作为基础。
步骤四:确定计算顺序
观察状态转移方程 dp[i][j] 依赖于 dp[i+1][j-1]、dp[i+1][j] 和 dp[i][j-1]。它依赖于更短的子串(i 更大或 j 更小)的结果。
因此,我们不能简单地按 i 从 0 到 n-1,j 从 0 到 n-1 的顺序计算。一个合理的顺序是:
- 按子串的长度
L从短到长进行计算。因为长度更长的子串结果依赖于长度更短的子串结果。 - 外层循环
L从2遍历到n(长度为1的已经在初始化中完成)。 - 内层循环
i从0遍历到n - L,那么j就固定为i + L - 1。
步骤五:示例推导(以 s = “bbbab” 为例)
初始化一个 5x5 的 dp 表,主对角线 dp[i][i] = 1。
初始状态:
b b b a b (j)
b 1 0 0 0 0
b 0 1 0 0 0
b 0 0 1 0 0
a 0 0 0 1 0
b 0 0 0 0 1
(i)
长度 L=2:
i=0, j=1: s[0]=b, s[1]=b,相等 -> dp[0][1] = dp[1][0] + 2。因为 i>j,dp[1][0]=0,所以 dp[0][1]=2。
i=1, j=2: s[1]=b, s[2]=b,相等 -> dp[1][2] = dp[2][1] + 2 = 0+2 = 2。
i=2, j=3: s[2]=b, s[3]=a,不等 -> dp[2][3] = max(dp[3][3], dp[2][2]) = max(1,1)=1。
i=3, j=4: s[3]=a, s[4]=b,不等 -> dp[3][4] = max(dp[4][4], dp[3][3]) = max(1,1)=1。
更新后:
b b b a b (j)
b 1 2 0 0 0
b 0 1 2 0 0
b 0 0 1 1 0
a 0 0 0 1 1
b 0 0 0 0 1
(i)
长度 L=3:
i=0, j=2: s[0]=b, s[2]=b,相等 -> dp[0][2] = dp[1][1] + 2 = 1+2=3。
i=1, j=3: s[1]=b, s[3]=a,不等 -> dp[1][3] = max(dp[2][3], dp[1][2]) = max(1,2)=2。
i=2, j=4: s[2]=b, s[4]=b,相等 -> dp[2][4] = dp[3][3] + 2 = 1+2=3。
更新后:
b b b a b (j)
b 1 2 3 0 0
b 0 1 2 2 0
b 0 0 1 1 3
a 0 0 0 1 1
b 0 0 0 0 1
(i)
长度 L=4:
i=0, j=3: s[0]=b, s[3]=a,不等 -> dp[0][3] = max(dp[1][3], dp[0][2]) = max(2,3)=3。
i=1, j=4: s[1]=b, s[4]=b,相等 -> dp[1][4] = dp[2][3] + 2 = 1+2=3。
更新后:
b b b a b (j)
b 1 2 3 3 0
b 0 1 2 2 3
b 0 0 1 1 3
a 0 0 0 1 1
b 0 0 0 0 1
(i)
长度 L=5:
i=0, j=4: s[0]=b, s[4]=b,相等 -> dp[0][4] = dp[1][3] + 2 = 2+2=4。
最终结果:dp[0][4] = 4。
步骤六:代码实现要点(Python)
def longestPalindromeSubseq(s: str) -> int:
n = len(s)
# 初始化一个 n x n 的二维数组,所有值为0
dp = [[0] * n for _ in range(n)]
# 初始化:单个字符是长度为1的回文
for i in range(n):
dp[i][i] = 1
# 按长度从2到n进行递推
for length in range(2, n + 1): # length表示子串长度
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
# 如果子串长度为2,即i+1 > j-1的情况,中间部分长度为0
if length == 2:
dp[i][j] = 2
else:
dp[i][j] = dp[i+1][j-1] + 2
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
return dp[0][n-1]
总结:
这个问题的核心在于巧妙地定义了 dp[i][j] 来表示子串 s[i...j] 的最优解。通过分析首尾字符的关系,我们得到了简洁而强大的状态转移方程。计算顺序(按子串长度递增)确保了在计算 dp[i][j] 时,它所依赖的子问题都已经被计算出来。这是解决“序列类”区间动态规划问题的经典范式。理解了这个问题,就为后续更复杂的回文子序列变种(如统计个数、构造序列等)打下了坚实的基础。