1. 问题背景与核心挑战
今天我想和大家分享一道非常有意思的LeetCode题目——730.统计不同回文子序列。这道题在动态规划问题中属于中等偏上难度,特别考验我们对区间DP和去重策略的理解。
首先明确题目要求:给定一个仅包含小写字母的字符串s,我们需要统计其中所有不同的非空回文子序列的数量。这里的"不同"意味着即使两个子序列在字符串中的位置不同,只要它们的字符组成相同,就视为同一个子序列。例如在字符串"bccb"中,使用第一个b和最后一个b组成的"bb",与使用第二个b和第三个b组成的"bb"只能算作一个。
这道题的核心挑战在于:
- 如何高效地统计所有可能的回文子序列
- 如何在统计过程中避免重复计数
- 如何处理大数取模的问题(结果需要对10^9+7取模)
2. 动态规划解法思路解析
2.1 基础DP状态定义
我们采用区间动态规划(Interval DP)的方法来解决这个问题。定义dp[i][j]表示在子串s[i...j]范围内,不同的回文子序列的个数。
传统的区间DP解法(如LeetCode 516题)通常直接比较s[i]和s[j]是否相等来进行状态转移,但这种方法无法有效处理去重问题。因此我们需要采用一种更聪明的策略。
2.2 基于字符枚举的去重策略
观察到题目中字符集通常限定为{'a','b','c','d'}(最多4种字符),我们可以利用这个特性来设计去重策略:
对于每个区间[i,j],我们枚举所有可能的字符x(x ∈ {'a','b','c','d'})作为回文子序列的首尾字符。这样做的优势在于:
- 每种以x开头和结尾的回文子序列都会被独立统计
- 通过固定首尾字符,可以确保相同字符组成的子序列不会被重复计算
具体来说,对于每个字符x,我们需要:
- 找到x在区间[i,j]中第一次出现的位置L
- 找到x在区间[i,j]中最后一次出现的位置R
然后根据L和R的不同关系进行不同的处理。
3. 详细状态转移分析
3.1 字符x不存在于区间内
如果L > R(即字符x在区间[i,j]中不存在),那么它对dp[i][j]的贡献为0。这种情况很简单,我们直接跳过即可。
3.2 字符x在区间内只出现一次
如果L == R(即字符x在区间内只出现一次),那么它只能形成单个字符的回文子序列"x",因此贡献为1。
3.3 字符x在区间内出现多次
这是最复杂也最有趣的情况。当L < R时(即字符x在区间内至少出现两次),它可以形成以下几种回文子序列:
- 单个字符"x"
- 双字符"xx"
- 所有以x开头和结尾的更长子序列
数学上,这种情况的贡献可以表示为:
1 + dp[L+1][R-1] + 1
解释:
- 第一个1代表单个字符"x"
- dp[L+1][R-1]代表所有在L和R之间的子序列,我们可以在它们前后各加一个x形成新的回文
- 最后一个1代表双字符"xx"
3.4 边界条件处理
特别需要注意的是当R = L+1时的边界情况:
- 此时dp[L+1][R-1] = dp[R][L] = 0(无效区间)
- 贡献为2("x"和"xx")
- 这与我们的通用公式1 + 0 + 1 = 2一致,保持了逻辑的自洽性
4. 算法实现细节
4.1 预处理字符位置
为了快速找到每个字符在任意区间[i,j]内的第一次和最后一次出现位置,我们可以预先处理字符串s,记录每个字符的所有出现位置。然后对于给定的i和j,使用二分查找来快速确定L和R。
4.2 DP表格填充顺序
由于dp[i][j]依赖于dp[L+1][R-1],我们需要按照区间长度从小到大的顺序填充DP表格:
- 先处理所有长度为1的子串(即i == j的情况)
- 然后处理长度为2的子串
- 依此类推,直到处理整个字符串
4.3 取模运算
由于结果可能非常大,题目要求对10^9+7取模。我们需要在每次状态转移后进行取模运算,避免中间结果溢出。
5. 完整Java代码实现
java复制class Solution {
private static final int MOD = 1_000_000_007;
public int countPalindromicSubsequences(String s) {
int n = s.length();
int[][] dp = new int[n][n];
// 预处理每个字符的位置
int[] prev = new int[n];
int[] next = new int[n];
int[] last = new int[4];
Arrays.fill(last, -1);
// 计算prev数组:记录每个位置前一个相同字符的位置
for (int i = 0; i < n; i++) {
int c = s.charAt(i) - 'a';
prev[i] = last[c];
last[c] = i;
}
Arrays.fill(last, -1);
// 计算next数组:记录每个位置后一个相同字符的位置
for (int i = n - 1; i >= 0; i--) {
int c = s.charAt(i) - 'a';
next[i] = last[c];
last[c] = i;
}
// 按区间长度从小到大填充DP表
for (int len = 1; len <= n; len++) {
for (int i = 0; i + len <= n; i++) {
int j = i + len - 1;
if (i == j) {
dp[i][j] = 1; // 单个字符
continue;
}
if (s.charAt(i) != s.charAt(j)) {
dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1];
} else {
int l = next[i];
int r = prev[j];
if (l > r) {
// 中间没有相同的字符
dp[i][j] = dp[i+1][j-1] * 2 + 2;
} else if (l == r) {
// 中间恰好有一个相同的字符
dp[i][j] = dp[i+1][j-1] * 2 + 1;
} else {
// 中间有多个相同的字符
dp[i][j] = dp[i+1][j-1] * 2 - dp[l+1][r-1];
}
}
dp[i][j] = dp[i][j] < 0 ? dp[i][j] + MOD : dp[i][j] % MOD;
}
}
return dp[0][n-1];
}
}
6. 复杂度分析与优化
6.1 时间复杂度
算法的时间复杂度主要来自:
- 预处理阶段:O(n)
- DP表格填充:O(n^2)
- 每次状态转移中的字符位置查询:O(1)(得益于预处理)
因此总时间复杂度为O(n^2),这在n≤1000的约束下是完全可行的。
6.2 空间复杂度
我们需要:
- O(n^2)的DP表格
- O(n)的prev和next数组
- O(1)的last数组
因此总空间复杂度为O(n^2)。
6.3 可能的优化方向
- 空间优化:可以观察到dp[i][j]只依赖于较短的区间,因此可以使用滚动数组技术将空间复杂度优化到O(n)
- 预处理优化:可以使用更高效的数据结构(如跳表)来加速区间内字符位置的查询
- 并行计算:由于DP表格的填充有明确的依赖关系,可以考虑并行计算不同长度的区间
7. 常见问题与调试技巧
7.1 为什么我的程序在长输入时结果不对?
这通常是由于没有正确处理大数取模导致的。需要注意:
- 在每次状态转移后立即取模
- 处理减法时可能需要先加MOD再取模,避免负数
- 乘法操作可能导致中间结果溢出,应该使用long类型暂存
7.2 如何处理字符集大小变化的情况?
虽然题目通常限定为4种字符,但我们的算法实际上可以处理任意大小的字符集。只需要:
- 调整字符枚举的范围
- 预处理时使用足够大的数组存储字符位置
7.3 如何验证算法的正确性?
建议使用以下测试用例进行验证:
- 空字符串(但题目保证非空)
- 单字符字符串
- 全相同字符的字符串
- 没有重复字符的字符串
- 包含所有4种字符的复杂字符串
8. 实际应用与扩展
虽然这道题看起来是一个纯粹的算法问题,但它的核心思想在实际开发中有广泛应用:
- DNA序列分析:生物信息学中经常需要统计特定的序列模式
- 文本相似度计算:回文子序列统计可以用于衡量文本的结构特征
- 数据压缩:识别重复模式是许多压缩算法的基础
这道题的解法还可以扩展到:
- 统计长度不超过k的不同回文子序列
- 统计满足特定条件的回文子序列(如长度必须为奇数)
- 枚举而非计数所有的不同回文子序列
在解决这类问题时,最重要的是理解状态定义和转移方程背后的组合数学原理。通过固定首尾字符的策略,我们巧妙地避免了重复计数的问题,这种思路在很多其他去重问题中也非常有用。