LeetCode 730题"统计不同回文子序列"是一个经典的动态规划问题,要求计算字符串中所有不同的非空回文子序列的数量。这个问题在技术面试中经常出现,因为它很好地考察了候选人对动态规划的理解和应用能力。
给定一个字符串s,我们需要统计其中所有不同的非空回文子序列的数量。回文子序列是指正读反读都相同的子序列,且不同的定义是指内容不同而非位置不同。例如,字符串"bccb"的回文子序列包括:
这道题的标准解法是使用区间动态规划(Interval DP)。我们定义一个二维数组dp[i][j],表示字符串s从i到j的子串中不同回文子序列的数量。关键思路是:
状态转移的核心逻辑可以表示为:
code复制dp[i][j] = sum_{k=0}^3 (处理字符k的情况)
对于每个字符k:
java复制private static final int MOD = 1_000_000_007;
public int countPalindromicSubsequences(String s) {
int n = s.length();
int[][] dp = new int[n][n];
// 将字符映射为0-3,减少内存使用
int[] nums = new int[n];
for (int i = 0; i < n; i++) {
nums[i] = s.charAt(i) - 'a';
}
这里我们首先定义模数MOD,用于防止整数溢出。然后将字符串中的字符映射为0-3的数字,这样可以减少内存使用和提高访问速度。
java复制// 区间DP:按长度从小到大
for (int len = 1; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (len == 1) {
dp[i][j] = 1; // 单个字符本身就是回文
continue;
}
动态规划按照子串长度从小到大进行计算,这是区间DP的典型处理方式。对于长度为1的子串,显然只有一个回文子序列(字符本身)。
java复制long count = 0;
// 枚举4种字符'a','b','c','d'
for (int k = 0; k < 4; k++) {
// 在[i,j]范围内找字符k第一次出现的位置L
int L = -1, R = -1;
// 寻找L
for (int p = i; p <= j; p++) {
if (nums[p] == k) {
L = p;
break;
}
}
// 如果没找到,跳过
if (L == -1) continue;
// 寻找R
for (int p = j; p >= i; p--) {
if (nums[p] == k) {
R = p;
break;
}
}
对于每个可能的字符k,我们首先在区间[i,j]内查找它的第一次出现位置L和最后一次出现位置R。如果找不到该字符,则跳过。
java复制if (L == R) {
// 只出现一次,只能组成"k"
count += 1;
} else {
// 出现至少两次,组成"k", "kk", "k...k"
// 数量为2 + dp[L+1][R-1]
int inner = (L + 1 <= R - 1) ? dp[L + 1][R - 1] : 0;
count += 2 + inner;
}
count %= MOD;
如果字符k在区间内只出现一次,它只能贡献一个回文子序列(单字符)。如果出现多次,则可以贡献:
java复制dp[i][j] = (int) count;
}
}
return dp[0][n - 1];
}
最后,我们将计算结果存入dp数组,并返回整个字符串的回文子序列数量。
原始解法中每次都要线性扫描查找字符的位置,这导致时间复杂度较高。我们可以预处理每个字符在每个位置的前后出现位置,将时间复杂度从O(n^3)优化到O(n^2)。
java复制// 预处理每个位置每个字符的next和prev数组
int[][] next = new int[n][4];
int[][] prev = new int[n][4];
for (int k = 0; k < 4; k++) {
int last = -1;
for (int i = 0; i < n; i++) {
prev[i][k] = last;
if (nums[i] == k) last = i;
}
last = -1;
for (int i = n-1; i >= 0; i--) {
next[i][k] = last;
if (nums[i] == k) last = i;
}
}
使用预处理后的数组可以快速找到字符的位置:
java复制int L = -1, R = -1;
// 使用预处理数组快速查找
for (int p = i; p <= j; p++) {
if (nums[p] == k) {
L = p;
break;
}
}
if (L == -1) continue;
for (int p = j; p >= i; p--) {
if (nums[p] == k) {
R = p;
break;
}
}
当前算法使用了O(n^2)的空间,可以考虑使用滚动数组将空间复杂度优化到O(n),但这会增加代码复杂度,在实际面试中通常不需要。
由于结果可能非常大,必须使用模运算。常见错误是忘记在中间步骤取模:
java复制count += 2 + inner;
count %= MOD; // 必须在这里取模
特别注意子串长度为1和2的情况:
设计测试用例时应考虑:
例如:
这道题在面试中经常作为动态规划的难题出现。面试官可能会:
回答时应先说明暴力解法,然后逐步优化到DP解法。
掌握这个问题后,可以解决许多类似问题:
在实际工程中,如果字符串很长:
在实现这道题的过程中,有几个关键点需要特别注意:
我在第一次实现时忽略了取模的位置,导致大测试用例出错。后来通过打印中间结果发现了这个问题。另一个容易出错的地方是当L和R相邻时的处理,这时内部区间L+1 > R-1,应该视为0而不是错误。