统计一个字符串中“回文子序列”的总数(字符集为小写字母,允许重复计数)
题目描述
给定一个只包含小写字母的字符串 s,我们需要统计其所有回文子序列(Palindromic Subsequences)的总数。回文子序列定义为原字符串中通过删除零个或多个字符(不改变剩余字符的相对顺序)得到的一个序列,且该序列从左到右和从右到左读是一样的。注意,即使两个回文子序列由相同的字符组成,但如果它们在原字符串中的位置(下标)不同,则视为不同的子序列。最终结果可能非常大,要求对 \(10^9+7\) 取模。
举例
例如,字符串 "aab":
- 回文子序列有:
""(空序列)、"a"(第一个'a')、"a"(第二个'a')、"b"、"aa"、"aa"(由第一个和第三个字符组成,注意:第一个和第三个字符是'a'和'b',不能组成"aa",这里应为第二个'a'和第三个'b'也不能组成"aa",实际上"aa"只能由前两个'a'组成。更正:"aa"只有一种,由下标 0 和 1 的'a'组成)、"aba"?实际上"aba"不是子序列,因为字符顺序是'a'、'a'、'b',无法得到'b'在中间。正确回文子序列列表:- 长度为 0:
""(1 个) - 长度为 1:
"a"(下标 0)、"a"(下标 1)、"b"(下标 2)共 3 个 - 长度为 2:
"aa"(下标 0 和 1)共 1 个 - 长度为 3:无
总数为 1 + 3 + 1 = 5。
实际上,我们通常包括空序列,但有时题目可能要求非空,这里我们按包括空序列计数。但为了清晰,我们先从非空开始讲解,最后加上空序列。
- 长度为 0:
解题过程
步骤 1:理解问题与定义状态
我们需要统计所有回文子序列的数量。注意“子序列”允许跳过中间字符,且不同位置即使字符相同也算不同子序列。
这是一个经典的区间动态规划问题,我们定义:
dp[i][j] 表示字符串 s 中从下标 i 到 j(包含两端)的子串中,回文子序列的个数(包括空序列吗?先不包括空序列会更清晰,但最终答案可以加 1 表示空序列。不过为了方便转移,我们通常让 dp[i][j] 表示该子串中所有回文子序列的个数,包括空序列。但这样会导致重复计数问题,因为空序列在任意子串中都计数一次。更常见的定义是:dp[i][j] 表示子串 s[i..j] 中回文子序列的个数(不包括空序列),这样最终答案再加 1。但转移方程会复杂一些。另一种更简洁的定义是:dp[i][j] 表示子串 s[i..j] 中回文子序列的个数(包括空序列)。我们采用这种,因为这样转移方程更整齐。最终答案就是 dp[0][n-1],其中 n 是字符串长度。
但要注意,当 i > j 时,我们认为子串为空,此时回文子序列只有空序列,所以 dp[i][j] = 1(当 i > j)。
步骤 2:状态转移方程
考虑子串 s[i..j],我们需要计算所有回文子序列的数量。我们可以根据子序列的首尾字符是否包含 s[i] 和 s[j] 来分类:
- 包含
s[i]但不包含s[j] - 包含
s[j]但不包含s[i] - 同时包含
s[i]和s[j] - 既不包含
s[i]也不包含s[j]
这四类覆盖了所有回文子序列。但注意,如果 s[i] == s[j],那么同时包含 s[i] 和 s[j] 的子序列可以形成新的回文(即以 s[i] 和 s[j] 作为首尾),而且这些子序列的内部可以由 s[i+1..j-1] 中的任意回文子序列构成。如果 s[i] != s[j],那么同时包含 s[i] 和 s[j] 的子序列不可能是回文,因为首尾字符不同。
因此,我们需要分情况:
情况 1:s[i] == s[j]
设 dp[i][j] 表示子串 s[i..j] 中回文子序列的个数(包括空序列)。
那么:
- 不包含
s[i]也不包含s[j]的子序列数 =dp[i+1][j-1](即s[i+1..j-1]中的所有回文子序列)。 - 包含
s[i]但不包含s[j]的子序列数 = 包含s[i]但可能包含或不包含s[j]吗?实际上,我们固定不包含s[j],但可以从s[i+1..j-1]中选任意回文子序列,然后前面加上s[i]。但这些子序列是否回文取决于s[i]和内部子序列的匹配。更简单的方法是利用计数原理:
我们考虑从 s[i..j] 中选子序列,可以按是否包含 s[i] 和 s[j] 分为四类:
(A) 既不包含 i 也不包含 j:dp[i+1][j-1]
(B) 包含 i 但不包含 j
(C) 包含 j 但不包含 i
(D) 包含 i 且包含 j
由于 s[i] == s[j],所以 (B) 和 (C) 是对称的,且 (D) 中的子序列可以这样得到:取 s[i+1..j-1] 中的任意一个回文子序列(包括空序列),然后在首尾加上 s[i] 和 s[j],这样得到的序列一定是回文。所以 (D) 的数量 = dp[i+1][j-1](因为对 s[i+1..j-1] 的每个回文子序列,都可以在首尾添加 s[i] 和 s[j] 得到一个新的回文子序列,包括空序列对应单个 s[i] 和 s[j] 组成的长度为 2 的回文,以及空序列对应空序列加首尾得到长度为 2 的回文,这里注意:空序列加首尾得到的是 "s[i]s[j]" 即两个相同字符)。
但 (B) 和 (C) 的数量并不是 dp[i+1][j-1],因为 (B) 包含 i 但不包含 j,这些子序列不一定回文,我们需要从 s[i+1..j] 中选,但必须不包含 j。实际上,我们可以用 dp[i][j-1] 表示包含 i 的可能序列,但 dp[i][j-1] 包括了包含 i 且可能包含 j-1 的序列,但 j 不在区间内。更精确地,dp[i][j-1] 包括了 (A) 和 (B),而 dp[i+1][j] 包括了 (A) 和 (C)。所以:
dp[i][j-1] = (A) + (B)
dp[i+1][j] = (A) + (C)
dp[i+1][j-1] = (A)
那么 (D) 的数量 = dp[i+1][j-1](因为对 s[i+1..j-1] 的每个回文子序列,添加首尾 s[i] 和 s[j] 后仍为回文)。
所以 dp[i][j] = (A) + (B) + (C) + (D)
= dp[i+1][j-1] + (B) + (C) + dp[i+1][j-1]
= dp[i][j-1] + dp[i+1][j] - dp[i+1][j-1] + dp[i+1][j-1](因为 (B) = dp[i][j-1] - dp[i+1][j-1],(C) = dp[i+1][j] - dp[i+1][j-1])
整理得:dp[i][j] = dp[i][j-1] + dp[i+1][j] + dp[i+1][j-1]?不对,检查一下:
实际上代入:
dp[i][j] = (A) + (B) + (C) + (D)
= dp[i+1][j-1] + [dp[i][j-1] - dp[i+1][j-1]] + [dp[i+1][j] - dp[i+1][j-1]] + dp[i+1][j-1]
= dp[i][j-1] + dp[i+1][j] - dp[i+1][j-1]。
这就是当 s[i] == s[j] 时的转移方程吗?注意 (D) 我们用了 dp[i+1][j-1],但这是正确的吗?考虑 s[i+1..j-1] 中的任意回文子序列,我们可以在其首尾添加 s[i] 和 s[j] 得到新回文,所以 (D) 的数量确实等于 dp[i+1][j-1]。所以最终:
dp[i][j] = dp[i][j-1] + dp[i+1][j] - dp[i+1][j-1] + dp[i+1][j-1]
= dp[i][j-1] + dp[i+1][j] 当 s[i] == s[j] 时。
但这样不对,因为 (D) 被重复计算了?我们检查:dp[i][j-1] 包含 (A) 和 (B),dp[i+1][j] 包含 (A) 和 (C),相加后 (A) 被加了两次,所以需要减去一个 (A),然后再加上 (D)。所以:
dp[i][j] = dp[i][j-1] + dp[i+1][j] - dp[i+1][j-1] + dp[i+1][j-1]
= dp[i][j-1] + dp[i+1][j] 当 s[i] == s[j]。
确实如此。但注意,当 s[i] == s[j] 时,dp[i+1][j-1] 被减掉又加回,抵消了。所以最终转移方程为:
当 s[i] == s[j] 时:
dp[i][j] = dp[i][j-1] + dp[i+1][j] + 1?等等,我们漏了什么?检查例子 s = "aa",长度 2,dp[0][1] 应该包含:"", "a"(第一个), "a"(第二个), "aa" 共 4 个。
计算:dp[0][0] = 2("", "a"),dp[1][1] = 2,dp[1][0] 无效。
按公式:dp[0][1] = dp[0][0] + dp[1][1] = 2 + 2 = 4,正确,不需要 +1。
所以当 s[i]==s[j] 时,dp[i][j] = dp[i][j-1] + dp[i+1][j]。
情况 2:s[i] != s[j]
此时 (D) 类子序列(同时包含 i 和 j)不可能是回文,所以 (D) 的数量为 0。
那么 dp[i][j] = (A) + (B) + (C) = dp[i][j-1] + dp[i+1][j] - dp[i+1][j-1]。
所以完整转移方程:
- 如果
i > j:dp[i][j] = 0?但之前我们约定空串时回文子序列只有空序列,即 1。所以我们调整:当i > j时,dp[i][j] = 0(因为后面我们只考虑 i<=j 的情况,初始化时长度为 1 的区间单独处理,避免 i>j 出现)。 - 如果
i == j:dp[i][j] = 2(空序列和单个字符序列)。 - 如果
i < j:- 如果
s[i] == s[j]:dp[i][j] = dp[i][j-1] + dp[i+1][j] + 1?我们验证一下。
设s = "aba",n=3。
dp[0][0]=2,dp[1][1]=2,dp[2][2]=2。
dp[1][2]:s[1]!=s[2]('b'!='a'),所以dp[1][2] = dp[1][1] + dp[2][2] - dp[2][1],其中dp[2][1]=0,所以dp[1][2] = 2+2-0=4。实际序列:"", "b", "a", "ba",但"ba"不是回文,所以错误。这说明我们的定义有问题:dp[i][j]必须只计数回文子序列,而不是所有子序列。所以需要重新定义。
- 如果
步骤 3:正确定义状态
定义 dp[i][j] 为子串 s[i..j] 中回文子序列的个数(包括空序列)。那么当 s[i] != s[j] 时,同时包含 i 和 j 的子序列不可能是回文,所以不能简单用包含所有子序列的公式。
我们需要用容斥原理,但只计数回文子序列。标准解法是:
设 dp[i][j] 表示子串 s[i..j] 中回文子序列的个数(包括空序列)。
那么:
- 当
i > j:dp[i][j] = 1(只有空序列)。 - 当
i == j:dp[i][j] = 2(空序列和单个字符序列)。 - 当
i < j:- 如果
s[i] == s[j]:那么所有回文子序列可以分为:- 包含
s[i]和s[j]的:在s[i+1..j-1]的每个回文子序列首尾添加s[i]和s[j],得到新的回文子序列,数量为dp[i+1][j-1]。 - 不包含
s[i]和s[j]的:数量为dp[i+1][j-1]?不对,这是s[i+1..j-1]的所有回文子序列,但我们已经计入了包含 i 和 j 的,所以这里不包含 i 和 j 的就是dp[i+1][j-1]吗?注意dp[i+1][j-1]包括了空序列,以及只包含 i+1..j-1 中的字符的子序列,这些都不含 i 和 j,所以正确。 - 包含
s[i]但不包含s[j]的:这些子序列的回文性要求内部是回文,且以s[i]开头,但不以s[j]结尾。实际上,这类子序列就是s[i..j-1]中包含s[i]的回文子序列,但s[i..j-1]中也可能包含s[j]吗?区间是 i 到 j-1,所以不包含 j。所以这类数量 =dp[i][j-1] - dp[i+1][j-1](即s[i..j-1]的所有回文子序列减去s[i+1..j-1]的所有回文子序列,因为减去的是既不包含 i 也不包含 j-1 的吗?实际上dp[i][j-1]包含所有以 i 开头和不以 i 开头的,我们要的是以 i 开头的。但更方便的方法是:我们不需要显式分类,可以用以下推导:
- 包含
- 如果
由于 s[i]==s[j],所以 s[i..j] 的回文子序列由以下部分组成:
s[i+1..j-1]的所有回文子序列(即不包含 i 和 j 的)。- 在上述每个回文子序列首尾添加
s[i]和s[j]得到的新回文子序列(包括空序列变成s[i]s[j])。
所以总数 =dp[i+1][j-1] + dp[i+1][j-1] = 2 * dp[i+1][j-1]?但这样漏掉了一些:比如只包含 i 不包含 j 的,或只包含 j 不包含 i 的。实际上,只包含 i 不包含 j 的回文子序列,可以通过在s[i+1..j-1]的回文子序列前添加s[i]得到,但还需要保证整个是回文,这要求内部子序列是回文且以s[i]开头?不,只包含 i 不包含 j 的子序列,可以是单个s[i],或者s[i]加上一个内部回文子序列(但内部子序列在s[i+1..j-1]中,且整个序列以s[i]开头,以某个内部字符结尾,要回文必须内部子序列是回文且整个序列对称,这要求内部子序列以s[i]结尾,但内部子序列不一定包含s[i]。所以这样分类很麻烦。
标准的状态转移方程是(参考经典问题“统计回文子序列个数”):
dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1],如果 s[i] != s[j]。
如果 s[i] == s[j],则 dp[i][j] = dp[i+1][j] + dp[i][j-1] + 1。
其中 dp[i][j] 表示子串 s[i..j] 中回文子序列的个数(包括空序列吗?)。验证一下:
s = "ab",dp[0][1]:s[0]!=s[1],所以 dp[0][1] = dp[1][1] + dp[0][0] - dp[1][0]。设 dp[i][j] 包括空序列,则 dp[0][0]=2, dp[1][1]=2, dp[1][0]=1(空序列)。所以 dp[0][1]=2+2-1=3。实际回文子序列:"", "a", "b",共 3 个,正确。
s = "aa",dp[0][1] = dp[1][1] + dp[0][0] + 1 = 2+2+1=5。实际:"", "a", "a", "aa" 共 4 个,这里多了一个。所以公式不对,应该是 dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1] 当 s[i]!=s[j];当 s[i]==s[j] 时,dp[i][j] = dp[i+1][j] + dp[i][j-1] + 1 会多计数。
实际上,当 s[i]==s[j] 时,dp[i+1][j] 包含了所有不包含 i 的回文子序列,dp[i][j-1] 包含了所有不包含 j 的回文子序列,两者交集是 dp[i+1][j-1](既不包含 i 也不包含 j 的回文子序列)。另外,我们还需要加上同时包含 i 和 j 的回文子序列,这些序列的个数等于 dp[i+1][j-1](因为对 s[i+1..j-1] 的每个回文子序列,添加首尾 i 和 j 后仍是回文)。所以总数 = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1] + dp[i+1][j-1] = dp[i+1][j] + dp[i][j-1]。但这样又回到了之前的公式,对 "aa" 计算:dp[0][1] = dp[1][1] + dp[0][0] = 2+2=4,正确。所以正确的转移方程是:
- 如果
s[i] == s[j]:dp[i][j] = dp[i+1][j] + dp[i][j-1] + 1?不对,我们验证"aaa":
n=3,dp[0][0]=2,dp[1][1]=2,dp[2][2]=2。
dp[1][2]:s[1]==s[2],所以dp[1][2] = dp[2][2] + dp[1][1] = 2+2=4。实际序列:"", "a", "a", "aa"共 4 个,正确。
dp[0][1]:s[0]==s[1],dp[0][1] = dp[1][1] + dp[0][0] = 2+2=4,正确。
dp[0][2]:s[0]==s[2],dp[0][2] = dp[1][2] + dp[0][1] = 4+4=8。实际序列:列出"aaa"的所有回文子序列(包括空序列):"","a"(3 个),"aa"(3 个:下标 (0,1), (0,2), (1,2)),"aaa"(1 个),共 1+3+3+1=8 个,正确。
所以当s[i]==s[j]时,dp[i][j] = dp[i+1][j] + dp[i][j-1]。
但注意,当 i+1 > j-1 时,即区间 s[i+1..j-1] 无效,此时 dp[i+1][j-1] 应设为 1(空序列)。在我们的公式中,当 s[i]==s[j] 时,我们没用到 dp[i+1][j-1],所以没问题。但考虑 i+1 == j 时,即 s[i]==s[j] 且相邻,如 "aa",dp[i+1][j-1] 是 dp[j][i],即 i>j 的情况,我们应设 dp[i+1][j-1] = 1。但在公式 dp[i][j] = dp[i+1][j] + dp[i][j-1] 中,不需要这个值。所以可以。
当 s[i] != s[j] 时,dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]。
验证 "ab":dp[0][1] = dp[1][1] + dp[0][0] - dp[1][0]。dp[1][0] 是无效区间,应视为 1(空序列)。所以 dp[0][1] = 2+2-1=3,正确。
步骤 4:初始化与计算顺序
- 初始化:对所有
i,dp[i][i] = 2(空序列和单个字符序列)。对所有i > j,dp[i][j] = 1(只有空序列)。 - 计算顺序:按区间长度从小到大计算。长度从 2 到 n。
步骤 5:最终答案
dp[0][n-1] 就是整个字符串的回文子序列总数(包括空序列)。如果题目要求不包括空序列,则答案减 1。
步骤 6:模运算
由于结果可能很大,每次计算都要对 \(10^9+7\) 取模。注意减法可能产生负数,需要加模数再取模。
步骤 7:示例代码(C++风格伪代码)
int countPalindromicSubsequences(string s) {
int n = s.length();
int MOD = 1e9+7;
vector<vector<long long>> dp(n, vector<long long>(n, 0));
for (int i = 0; i < n; i++) {
dp[i][i] = 2; // 空序列和单个字符
}
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (s[i] == s[j]) {
dp[i][j] = (dp[i+1][j] + dp[i][j-1]) % MOD;
} else {
dp[i][j] = (dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]) % MOD;
}
}
}
// 注意:dp[0][n-1] 包括空序列,如果题目要求不包括空序列,则减 1。
return (dp[0][n-1] + MOD) % MOD;
}
但注意,当 i+1 > j-1 时,dp[i+1][j-1] 需要设为 1。所以在代码中,我们需要处理 i+1 > j-1 的情况,即当 i+1 > j-1 时,dp[i+1][j-1] 应为 1。在转移中,当 s[i]!=s[j] 且 len==2 时,i+1 = j,j-1 = i,所以 i+1 > j-1,dp[i+1][j-1] = dp[j][i] 未定义,我们应手动设为 1。可以在计算前初始化 dp[i][i-1] = 1 对所有 i。或者特判。
修改:
int countPalindromicSubsequences(string s) {
int n = s.length();
int MOD = 1e9+7;
vector<vector<long long>> dp(n, vector<long long>(n, 0));
// 初始化长度为 1
for (int i = 0; i < n; i++) dp[i][i] = 2;
// 初始化 i>j 的情况为 1
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i][j] = 1;
}
}
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (s[i] == s[j]) {
dp[i][j] = (dp[i+1][j] + dp[i][j-1]) % MOD;
} else {
dp[i][j] = (dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]) % MOD;
}
}
}
// 减去空序列,如果题目要求不包括空序列
// 这里我们返回包括空序列的结果
return (dp[0][n-1] + MOD) % MOD;
}
注意:当 s[i]!=s[j] 时,公式中的 dp[i+1][j-1] 我们已初始化,所以可以直接用。
步骤 8:验证例子
s = "aab":
- n=3,初始化
dp[0][0]=2,dp[1][1]=2,dp[2][2]=2,其他 i>j 的为 1。 - len=2:
- i=0,j=1: s[0]=='a', s[1]=='a',相等,
dp[0][1] = dp[1][1] + dp[0][0] = 2+2=4。 - i=1,j=2: s[1]=='a', s[2]=='b',不等,
dp[1][2] = dp[2][2] + dp[1][1] - dp[2][1] = 2+2-1=3。
- i=0,j=1: s[0]=='a', s[1]=='a',相等,
- len=3: i=0,j=2: s[0]=='a', s[2]=='b',不等,
dp[0][2] = dp[1][2] + dp[0][1] - dp[1][1] = 3+4-2=5。
结果dp[0][2]=5,包括空序列。实际回文子序列:"", "a", "a", "b", "aa"共 5 个,正确。
最终答案:返回 dp[0][n-1] 即可(包括空序列)。如果题目要求不包括空序列,则减 1。