516. 最长回文子序列
问题描述
给定一个字符串 s
,找到其中最长的回文子序列的长度。子序列不要求连续。
示例:
输入: "bbbab"
输出: 4
解释: 最长回文子序列是 "bbbb"
输入: "cbbd"
输出: 2
解释: 最长回文子序列是 "bb"
算法思路
动态规划(DP 数组):
- 状态定义:
dp[i][j]
表示子串s[i..j]
的最长回文子序列长度。
- 状态转移:
- 当
s[i] == s[j]
时:
dp[i][j] = dp[i+1][j-1] + 2
- 当
s[i] != s[j]
时:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
- 当
- 初始化:
- 单个字符是长度为 1 的回文:
dp[i][i] = 1
。 - 相邻字符相同则为 2:
dp[i][i+1] = 2 if s[i]==s[i+1]
。
- 单个字符是长度为 1 的回文:
- 遍历顺序:
- 从下往上(
i
从n-1
到0
),从左往右(j
从i+1
到n-1
)。
- 从下往上(
空间优化(一维 DP):
- 由于
dp[i][j]
仅依赖左侧(dp[i][j-1]
)、下方(dp[i+1][j]
)和左下角(dp[i+1][j-1]
),可用一维数组代替二维数组。 - 用
pre
保存左下角值,避免覆盖。
代码实现
方法一:动态规划(DP 数组)
class Solution {
public int longestPalindromeSubseq(String s) {
if (s == null || s.isEmpty()) return 0;
int n = s.length();
int[][] dp = new int[n][n]; // dp[i][j] 表示 s[i..j] 的最长回文子序列长度
// 初始化:单个字符的回文长度为 1
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
}
// 从下往上遍历
for (int i = n - 1; i >= 0; i--) {
// 从左往右遍历(j 从 i+1 开始)
for (int j = i + 1; j < n; j++) {
// 首尾字符相同
if (s.charAt(i) == s.charAt(j)) {
// 当 j=i+1 时,dp[i+1][j-1] 未定义,视为 0
int inner = (j - i > 1) ? dp[i + 1][j - 1] : 0;
dp[i][j] = inner + 2;
} else {
// 取左侧或下方的最大值
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
}
方法二:动态规划(空间优化)
class Solution {
public int longestPalindromeSubseq(String s) {
if (s == null || s.isEmpty()) return 0;
int n = s.length();
int[] dp = new int[n]; // dp[j] 表示当前行以 j 结尾的子串的最长回文子序列长度
Arrays.fill(dp, 1); // 初始化:每个字符自身长度为 1
for (int i = n - 1; i >= 0; i--) {
int pre = 0; // 保存 dp[i+1][j-1] 的值
for (int j = i + 1; j < n; j++) {
int temp = dp[j]; // 保存旧值(即 dp[i+1][j])
if (s.charAt(i) == s.charAt(j)) {
// 状态转移:dp[i][j] = pre + 2
dp[j] = pre + 2;
} else {
// 状态转移:dp[i][j] = max(dp[i+1][j], dp[i][j-1])
dp[j] = Math.max(dp[j], dp[j - 1]);
}
pre = temp; // 更新 pre 为 dp[i+1][j](用于下一轮作为 dp[i+1][j-1])
}
}
return dp[n - 1];
}
}
算法分析
- 时间复杂度:O(n²)
两层循环遍历所有子串。 - 空间复杂度:
- 二维 DP:O(n²)
- 一维 DP:O(n)
算法过程
输入:s = "bbbab"
- 二维 DP 初始化:
- 对角线
dp[i][i] = 1
- 对角线
- 状态转移:
i=3, j=4
:'a' != 'b'
→dp[3][4]=max(dp[4][4], dp[3][3])=max(1,1)=1
i=2, j=3
:'b' != 'a'
→dp[2][3]=max(dp[3][3], dp[2][2])=1
i=2, j=4
:'b' == 'b'
→dp[2][4]=dp[3][3]+2=3
i=1, j=2
:'b' == 'b'
→dp[1][2]=2
i=1, j=3
:'b' != 'a'
→dp[1][3]=max(dp[2][3], dp[1][2])=max(1,2)=2
i=1, j=4
:'b' == 'b'
→dp[1][4]=dp[2][3]+2=1+2=3
i=0, j=1
:'b' == 'b'
→dp[0][1]=2
i=0, j=2
:'b' == 'b'
→dp[0][2]=dp[1][1]+2=3
i=0, j=3
:'b' != 'a'
→dp[0][3]=max(dp[1][3], dp[0][2])=max(2,3)=3
i=0, j=4
:'b' == 'b'
→dp[0][4]=dp[1][3]+2=2+2=4
- 结果:
dp[0][4]=4
测试用例
public static void main(String[] args) {
Solution solution = new Solution();
// 测试用例1: 标准示例
String s1 = "bbbab";
System.out.println("Test 1: " + solution.longestPalindromeSubseq(s1)); // 4
// 测试用例2: 连续相同字符
String s2 = "cbbd";
System.out.println("Test 2: " + solution.longestPalindromeSubseq(s2)); // 2
// 测试用例3: 全相同字符
String s3 = "aaaa";
System.out.println("Test 3: " + solution.longestPalindromeSubseq(s3)); // 4
// 测试用例4: 单字符
String s4 = "a";
System.out.println("Test 4: " + solution.longestPalindromeSubseq(s4)); // 1
// 测试用例5: 无回文子序列(最长1)
String s5 = "abc";
System.out.println("Test 5: " + solution.longestPalindromeSubseq(s5)); // 1
// 测试用例6: 长字符串
String s6 = "abcdefghijklmnopqrstuvwxyz";
System.out.println("Test 6: " + solution.longestPalindromeSubseq(s6)); // 1
}
关键点
- 状态转移逻辑:
- 首尾相同:长度 = 内部子串 + 2。
- 首尾不同:长度 = 左侧或下方子串的最大值。
- 遍历顺序:
- 从下往上确保
dp[i+1][*]
已计算。 - 从左往右确保
dp[i][j-1]
已计算。
- 从下往上确保
- 空间优化核心:
pre
保存dp[i+1][j-1]
。temp
保存dp[i+1][j]
避免覆盖。
常见问题
- 为什么
dp[i][j]
依赖dp[i+1][j-1]
?
当首尾相同时,需加上内部子串的最长回文子序列长度。 - 如何处理相邻字符?
当j=i+1
时,dp[i+1][j-1]
越界视为 0,此时dp[i][j]=0+2=2
。 - 一维 DP 中
pre
的作用?
保存dp[i+1][j-1]
的值,避免被覆盖。