LeetCode 730. 统计不同回文子序列
题目描述

题意分析
统计字符串中不同的非空回文子序列数量,对
10^9+7取模。子序列不要求连续,但必须保持原顺序;不同下标组合得到相同字符串时只计一次。
解法:区间动态规划 + 同字符位置
核心思路
[!blue]
定义
dp[i][j]为闭区间[i, j]中不同非空回文子序列的数量。单字符只有一种,故dp[i][i] = 1;空区间贡献 0。按区间长度从小到大计算,就能由已经求好的内部区间得到当前答案。若
s[i] != s[j],回文不能同时使用这两个字符作为首尾,所以它一定属于[i+1, j]或[i, j-1]。两者共同包含的回文正是中间[i+1, j-1]的回文,合并后减去一次重复,得到dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]。若两端都为字符
c,设中间区间的答案为S。候选来自四部分:中间原有的S种回文、给每种中间回文外包两个c得到的S种新形式,以及单独的c和cc。外包不同回文会得到不同字符串,但可能与中间已经存在的字符串重复,需要按内部c的数量分类。
- 内部没有
c:四部分互不重复,答案为2*S+2。- 内部只有一个
c:单独的c已被S计入,但内部无法组成cc或更长的首尾为c的回文,所以只额外增加cc,答案为2*S+1。- 内部至少有两个
c:c和cc都已计入S。设内部最左、最右的c位于l、r,重复的是那些内部已经能组成的c + 回文 + c。这些中间回文恰好可以从[l+1, r-1]取得,因此要减去dp[l+1][r-1],答案为2*S-dp[l+1][r-1]。最后一种情况必须取内部最左、最右的
c:任何内部首尾为c的回文,都可以改用这两个最外侧位置作为首尾而保持内容不变;反过来,它们中间的每个非空回文也都能外包成一个重复项。因此减去的集合既完整,也不会多减。字符只有
a到d,预处理nextPos[p][c]表示从p开始向右第一个c的位置,prevPos[p][c]表示从p向左第一个c的位置。查询nextPos[i+1][c]和prevPos[j-1][c]得到l、r,不存在或l > r表示内部没有c,l == r表示只有一个。每次转移取模后若为负,再加一次模数修正。
解题步骤
- 预处理相同字符的下一位置和上一位置。
- 将单字符区间初始化为一。
- 按区间长度递增,分别处理两端不同和相同的情况。
- 减法后修正到非负模数范围,返回完整区间。
代码实现
class Solution {
public int countPalindromicSubsequences(String s) {
int n = s.length();
int mod = 1_000_000_007;
char[] a = s.toCharArray();
// 位置表包含当前下标,转移查询从 i+1 到 j-1 的内部同字符。
int[][] nextPos = new int[n][4];
int[][] prevPos = new int[n][4];
int[] last = new int[4];
Arrays.fill(last, -1);
for (int i = 0; i < n; i++) {
int c = a[i] - 'a';
last[c] = i;
for (int t = 0; t < 4; t++) {
prevPos[i][t] = last[t];
}
}
Arrays.fill(last, -1);
for (int i = n - 1; i >= 0; i--) {
int c = a[i] - 'a';
last[c] = i;
for (int t = 0; t < 4; t++) {
nextPos[i][t] = last[t];
}
}
long[][] dp = new long[n][n];
for (int i = 0; i < n; i++) {
// 单字符只有一种回文;下三角未写位置保持零,表示空区间。
dp[i][i] = 1;
}
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (a[i] != a[j]) {
dp[i][j] = dp[i + 1][j] + dp[i][j - 1] - dp[i + 1][j - 1];
} else {
int ch = a[i] - 'a';
// 定位两端之间最左和最右的同字符,区分重复数量。
int l = nextPos[i + 1][ch];
int r = prevPos[j - 1][ch];
if (l == -1 || 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] %= mod;
// 包含减法的模运算需要将负余数修正到非负范围。
if (dp[i][j] < 0) {
dp[i][j] += mod;
}
}
}
return (int) dp[0][n - 1];
}
}
func countPalindromicSubsequences(s string) int {
const mod int64 = 1_000_000_007
n := len(s)
a := []byte(s)
// 位置表包含当前下标,转移查询从 i+1 到 j-1 的内部同字符。
nextPos := make([][4]int, n)
prevPos := make([][4]int, n)
last := [4]int{
-1,
-1,
-1,
-1,
}
for i := 0; i < n; i++ {
last[a[i]-'a'] = i
for t := 0; t < 4; t++ {
prevPos[i][t] = last[t]
}
}
last = [4]int{
-1,
-1,
-1,
-1,
}
for i := n - 1; i >= 0; i-- {
last[a[i]-'a'] = i
for t := 0; t < 4; t++ {
nextPos[i][t] = last[t]
}
}
dp := make([][]int64, n)
for i := range dp {
dp[i] = make([]int64, n)
// 单字符只有一种回文;下三角未写位置保持零,表示空区间。
dp[i][i] = 1
}
for length := 2; length <= n; length++ {
for i := 0; i+length-1 < n; i++ {
j := i + length - 1
if a[i] != a[j] {
dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]
} else {
ch := a[i] - 'a'
// 定位两端之间最左和最右的同字符,区分重复数量。
l := nextPos[i+1][ch]
r := prevPos[j-1][ch]
if l == -1 || 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] %= mod
// 包含减法的模运算需要将负余数修正到非负范围。
if dp[i][j] < 0 {
dp[i][j] += mod
}
}
}
return int(dp[0][n-1])
}
复杂度分析
- 时间复杂度:$O(n^2)$,
n为字符串长度,同字符位置预处理为 $O(n)$,每个区间进行常数时间转移。- 空间复杂度:$O(n^2)$,区间状态表;同字符位置表另占 $O(n)$。
关键点总结
[!green]
- 去重对象是结果字符串,不是下标组合。
- 内部边界使用最左和最右的同字符位置。
- 所有状态保存的是取模后的数量,转移减法需要统一处理负值。
易错点总结
[!yellow]
- 直接套用普通回文子序列计数:同一个字符串会重复计算。
- 两端相同总是加二:内部已有同字符时产生重复。
- 选取内部最靠近彼此的一对字符:减去的区间不符合去重关系。
- 长度从大到小计算:依赖的小区间尚未得到结果。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 516. 最长回文子序列 | 中等 | 同样使用回文区间结构,原题只求最长长度,本题计所有不同内容,必须处理重复贡献。 |
| 1930. 长度为 3 的不同回文子序列 | 中等 | 原题只数长度3的不同回文,本题允许任意长度,需要更完整的区间去重递推。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!