最长回文子序列的变种:统计回文子序列的个数(进阶版:考虑字符集限制,并统计长度至少为L的不同回文子序列个数)
这是一个关于最长回文子序列的进阶问题,我们需要统计给定字符串中所有不同的回文子序列的个数,但这里有一些额外条件:字符串由小写英文字母组成(字符集限制),并且我们只关心长度至少为L的不同回文子序列。我们将详细、循序渐进地讲解解题思路和步骤。
第一步:问题理解与定义
题目描述
给定一个字符串 s(只包含小写字母,长度设为 n),以及一个正整数 L。要求统计 s 中所有不同的回文子序列的数量,其中每个回文子序列的长度必须至少为 L。
注意:
- 子序列:从原字符串中删除一些字符(也可以不删除)后,剩余的字符相对顺序保持不变形成的序列。
- 回文子序列:该子序列正读和反读相同。
- 不同:即使子序列在
s中出现多次,只要内容相同,只算一种。 - 字符集限制:只有小写字母(26种字符),这会影响我们设计状态。
- 长度至少 L:最终统计时,只考虑长度 >= L 的子序列。
举例:
s = "abca", L = 2
回文子序列有:
- 长度为1: "a", "b", "c"(忽略,因为长度<2)
- 长度为2: "aa", "bb", "cc"(其中"aa"出现2次,但只算1个),"ab"不是回文,"ac"不是,"bc"不是,"ba"不是... 实际上符合的:"aa", "bb", "cc" 都不是子序列?等一下,检查:"aa" 是子序列吗?取下标0和3的字符'a','a',是的。
- 长度为3: "aba", "aca", "aaa"("aaa" 能组成吗?s中只有两个'a',所以不行)。"aba":取0,1,3(a,b,a)是回文,长度为3。
- 长度为4: 整个"abca"不是回文。
但我们需要不同的回文子序列,长度>=2。
列出所有回文子序列(不同):
长度2: "aa"(下标0,3)、"bb"(下标1,1?不行,单个字符不算长度2,需要不同下标),"bb"不存在(只有一个b),"cc"不存在(只有一个c),所以只有"aa"。
长度3: "aba"(0,1,3),"aca"(0,2,3),"aaa"不存在。
长度4: 无。
所以不同回文子序列:{"aa", "aba", "aca"},共3个。
目标:编写算法高效计算这个数目。
第二步:基础思路
如果不考虑“不同”,只统计所有回文子序列的个数(包括重复位置但相同内容),那是一个经典DP问题:设 dp[i][j] 表示子串 s[i..j] 中回文子序列的个数(包含空序列吗?通常不包含,但我们只统计非空回文子序列)。转移方程是:
如果 s[i] == s[j]:
- 新增的回文子序列包括:
s[i] + 子序列 + s[j],其中子序列来自dp[i+1][j-1]中的每一个回文子序列(包括空序列,对应长度为2的回文s[i]s[j]),所以新增数量 =dp[i+1][j-1] + 1(这个+1是s[i]s[j]和可能只取两端的组合?实际上经典公式是:dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1] + (s[i]==s[j] ? dp[i+1][j-1] + 1 : 0))。
但这样会重复计算相同内容但不同下标的子序列。比如 s = "aaa",子序列 "aa" 可以由多种下标组合得到,但内容相同。我们的目标是不同内容,所以需要去重。
第三步:去重与字符集利用
由于字符集只有26个小写字母,我们可以利用字符的最后一次出现位置来去重。
一个常用技巧是:
定义 dp[i][j] 表示在子串 s[i..j] 中,以字符 s[i] 开头(或任意字符) 的回文子序列个数?不,这样不便于去重。
更好方法是:定义 f[i][j] 为子串 s[i..j] 中不同的回文子序列个数(长度任意,包括空序列吗?我们最后会减去长度<L的部分)。
为了去重,我们考虑:当 s[i] == s[j] 时,如果中间有同样的字符,要避免重复计算。
标准解法(LeetCode 730 统计不同回文子序列)的DP定义是:
dp[i][j] 表示子串 s[i..j] 中不同的回文子序列个数(包括空序列吗?通常包括,但最后减去空序列和长度为1的)。
转移时,如果 s[i] != s[j],则 dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1](容斥原理)。
如果 s[i] == s[j],假设字符是 ch = s[i],我们找到子串 s[i+1..j-1] 中左侧第一个 ch 的位置 left 和右侧第一个 ch 的位置 right(相对于 i+1 和 j-1 的索引)。
- 如果
left > right,说明中间没有字符ch,则新增的回文子序列是:ch和chXch形式,其中 X 是dp[i+1][j-1]中的任意回文子序列,再加上ch和chch(长度为2)。实际上公式是:dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1] + dp[i+1][j-1] + 1吗?需要细化。
更精确的(LeetCode 730 解法):
- 如果
s[i] != s[j],dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]。 - 如果
s[i] == s[j],令ch = s[i],找到i < left <= right < j使得s[left] = ch且left最小,s[right] = ch且right最大。- 如果
left不存在(即中间没有相同字符),则dp[i][j] = dp[i+1][j-1] * 2 + 2。这里的+2是新增ch和chch两个回文序列。 - 如果
left == right(中间恰好一个相同字符),则dp[i][j] = dp[i+1][j-1] * 2 + 1。 - 如果
left < right(中间至少两个相同字符),则dp[i][j] = dp[i+1][j-1] * 2 - dp[left+1][right-1]。
- 如果
这个公式的目的是去重,确保相同的回文序列不重复计数。
但我们现在还需要考虑长度至少 L 的限制。
第四步:加入长度限制
我们可以修改 DP 状态,让它同时记录长度信息。但那样复杂度会变高(n^2 * L)。
另一种思路:先计算所有不同回文子序列(长度任意),但最后只累加长度 >= L 的。问题是我们 DP 的 dp[i][j] 是总数,没有按长度分类。
所以我们需要 DP 状态增加一维:长度。
定义 dp[i][j][k] 为子串 s[i..j] 中,长度为 k 的不同回文子序列的个数。
但 k 最大为 n,空间 O(n^3) 太大(n 可能 1000+),不可行。
观察到字符集很小(26),我们可以用另一种状态表示:
定义 f[len][c] 表示长度为 len,以字符 c 开头(和结尾)的不同回文子序列的个数。但中间部分不确定,难以转移。
更好的方法是:在原来的去重 DP 基础上,我们额外记录长度信息。
原来 dp[i][j] 是一个整数(个数),现在我们让 dp[i][j] 变成一个长度为 n+1 的数组(或列表),dp[i][j][l] 表示子串 s[i..j] 中,长度为 l 的不同回文子序列个数。
但这样空间 O(n^3) 仍然大,但 n 较小时可行(比如 n <= 200)。如果 n 更大,需要优化。
第五步:优化思路(字符集小)
因为字符集只有 26 种,我们可以用另一种 DP:
定义 f[l][c1][c2] 表示长度为 l,开头字符为 c1,结尾字符为 c2 的不同回文子序列个数。但中间部分也需要匹配回文,这需要更复杂状态。
一个可行方法是:
- 先预处理出每个字符在字符串中的出现位置列表。
- 用区间 DP,状态为
(i, j, len),但我们可以用记忆化搜索,只计算必要的状态。 - 由于要求不同,我们强制规定:对于一个回文子序列,我们取它在原字符串中最早出现的那组下标作为代表,避免重复。如何判断最早?可以定义:对于子序列,我们取它在 s 中所有可能下标组合中,字典序最小的一组下标。但这在 DP 中不容易直接体现。
实际上,LeetCode 730 的 DP 已经统计了所有不同回文子序列(任意长度),我们可以用类似的 DP,但把长度信息嵌入。
第六步:最终 DP 设计
我们定义 dp[i][j] 为一个长度为 (j-i+2) 的数组(或列表),dp[i][j][l] 表示在子串 s[i..j] 中,长度为 l 的不同回文子序列的个数。
转移方程(基于 LeetCode 730 的思路扩展):
-
如果
s[i] != s[j]:
dp[i][j][l] = dp[i+1][j][l] + dp[i][j-1][l] - dp[i+1][j-1][l](对于所有 l)。
注意:长度为 0 的序列(空序列)我们不考虑,所以 l 从 1 开始。 -
如果
s[i] == s[j],设ch = s[i],找到left为(i, j)中大于 i 的最小索引使s[left] = ch,right为小于 j 的最大索引使s[right] = ch。- 如果
left不存在(即中间没有相同字符):
新增的回文序列是:
a) 单个字符ch(长度 1):dp[i][j][1] += 1(如果之前没有计数过,但这里要小心去重,单个字符在更小区间可能被计过,所以要用新计算)。
更系统的做法是:
我们先把dp[i+1][j-1]的所有长度序列搬过来,然后对每个长度len,新增dp[i+1][j-1][len]个序列(两边加 ch,长度+2)。
另外新增两个序列:ch(长1)和chch(长2)。
所以:
dp[i][j][1] = 1(如果之前没有,实际上要合并dp[i+1][j]等,我们得用总公式)。
实际上,经典总个数公式是:dp[i][j] = 2*dp[i+1][j-1] + 2。
对应到长度:新增长度 = 原长度+2,所以dp[i][j][l] += dp[i+1][j-1][l-2](对 l>=2),再加上 l=1 和 l=2 各加1(如果没被前面区间算过)。
但注意,dp[i+1][j-1]中可能已经有长度为 1 的ch,但那是子串 i+1..j-1 里的,我们现在是 i..j 区间,新加的ch是 s[i] 这个位置的字符,可能和之前的ch重复吗?不会,因为中间没有相同字符,所以这个ch是新的不同子序列(单个字符)。同样chch也是新的。
所以公式:
dp[i][j][l] = dp[i+1][j][l] + dp[i][j-1][l] - dp[i+1][j-1][l](先继承),
然后对 l>=2:dp[i][j][l] += dp[i+1][j-1][l-2],
然后dp[i][j][1] += 1,dp[i][j][2] += 1。
但这样会重复计算,因为dp[i+1][j]中可能已经包含单个ch(如果 s[i+1]=ch),我们要去重。这就是为什么需要找 left, right 的原因。
为了去重,我们分情况(基于 left, right 位置):
-
如果
left不存在:
中间没有相同字符,则新增的序列是:ch和chch,以及所有dp[i+1][j-1]中的序列两边加 ch。
所以:dp[i][j][l] = (dp[i+1][j][l] + dp[i][j-1][l] - dp[i+1][j-1][l]) + (l>=2 ? dp[i+1][j-1][l-2] : 0) + (l==1 ? 1 : 0) + (l==2 ? 1 : 0)。
但要注意,dp[i+1][j]中可能已经有ch了(如果 s[j]=ch),但 s[i] 和 s[j] 相同,我们新增的ch和已有的可能重复,但因为我们定义“不同”是指内容,所以同一个字符 'a' 无论在哪个位置,单个 'a' 是同一个子序列。所以如果dp[i+1][j]中已经包含 'a',我们不能再加。
所以更好的做法是:不去加 l=1 的 1,而是看当前区间是否第一次出现 'a' 这个字符作为单个字符子序列。实际上,单个字符子序列的个数就是区间内不同字符的个数。所以我们不能简单加1。为了统一,我们采用 LeetCode 730 的总数公式,然后拆分成长度分布。
设total[i][j]为不同回文子序列总个数(长度任意)。
公式:total[i][j] = total[i+1][j] + total[i][j-1] - total[i+1][j-1](如果 s[i]!=s[j])。
如果 s[i]==s[j]:
找 left, right:- 如果 left 不存在:
total[i][j] = total[i+1][j-1] * 2 + 2 - 如果 left == right:
total[i][j] = total[i+1][j-1] * 2 + 1 - 如果 left < right:
total[i][j] = total[i+1][j-1] * 2 - total[left+1][right-1]
这个公式已经去重。我们需要把 total 拆分成按长度的计数。
注意到:新增的部分主要是由total[i+1][j-1]中的每个回文子序列两边加 ch 得到的,所以长度+2。
另外额外新增的 1 或 2 个回文,分别是长度为 1 的 ch 和长度为 2 的 chch。
所以我们可以用类似 DP 推长度分布。 - 如果 left 不存在:
- 如果
第七步:递推长度分布公式
设 lenDP[i][j][l] 表示区间 [i,j] 中长度为 l 的不同回文子序列个数。
我们也可以用递归/记忆化搜索实现。
递归思路:
函数 dfs(i, j) 返回一个数组 cnt[1..n],表示区间 [i,j] 中不同回文子序列按长度的计数。
递归过程:
- 如果 i > j: 返回全 0 数组(没有子序列)。
- 如果 i == j: 只有 1 个长度为 1 的子序列(s[i]),所以 cnt[1] = 1,其他为 0。
- 否则,先计算
left = dfs(i+1, j),right = dfs(i, j-1),mid = dfs(i+1, j-1)的计数数组。 - 如果 s[i] != s[j],则 cnt[l] = left[l] + right[l] - mid[l](注意去重,但这里 left 和 right 可能有重叠,重叠就是 mid 中的序列)。
- 如果 s[i] == s[j],找到 leftPos, rightPos:
-
如果 leftPos 不存在:则新增的序列是:mid 中每个长度 l 的序列变成 l+2 的序列,再加一个长度为 1 的 ch 和一个长度为 2 的 chch。
但要注意 mid 中可能已经有长度为 1 的 ch 吗?如果有,说明在更小区间已经有 ch 这个子序列,那么当前区间新增的长度 1 的 ch 和之前的是同一个,不能重复加。
但我们的mid是区间 [i+1, j-1],如果中间有 ch,则 leftPos 存在,所以这种情况不会发生。
所以:
cnt[l] = left[l] + right[l] - mid[l](先合并)
然后对 l >= 3: cnt[l] += mid[l-2](因为 l=2 时,mid[0] 不存在,我们单独处理 l=2)
对于 l=2: cnt[2] += 1(新增 chch)
对于 l=1: cnt[1] += 1(新增 ch) -
如果 leftPos == rightPos:
中间只有一个 ch,那么新增的序列是:mid 中每个序列两边加 ch(长度+2),以及新增一个长度为 2 的 chch(但长度为 1 的 ch 已经存在,因为中间有一个 ch,所以 mid 中已有长度为 1 的 ch,不能再加)。
所以:
cnt[l] = left[l] + right[l] - mid[l]
对 l>=3: cnt[l] += mid[l-2]
对 l=2: cnt[2] += 1(新增 chch) -
如果 leftPos < rightPos:
中间有至少两个 ch,那么新增的序列是:mid 中每个序列两边加 ch(长度+2),但要减去重复部分,即 leftPos+1 .. rightPos-1 区间内形成的回文子序列两边加 ch 得到的序列(因为这部分在更内层已经算过)。
设 inner = dfs(leftPos+1, rightPos-1) 的计数数组。
则 cnt[l] = left[l] + right[l] - mid[l]
对 l>=3: cnt[l] += mid[l-2] - inner[l-2](注意 inner[l-2] 可能为0)
对 l=2: cnt[2] += 1(新增 chch)
对 l=1: 不新增,因为 ch 已经存在。
-
这样我们就得到了每个区间的长度分布。
第八步:计算答案
我们只需计算 dfs(0, n-1) 得到的数组,对 l >= L 的部分求和即可。
第九步:复杂度与优化
空间:O(n^2 * n) 在 n=200 时是 200^3=8e6,可能勉强,但我们可以用 short 类型存储计数,因为结果可能很大,需要取模。
时间:O(n^3) 在 n=200 时 8e6 可接受,但 n 再大就不行。
优化:由于字符集只有 26,我们可以预处理每个字符的下一个和上一个出现位置,这样找 leftPos, rightPos 可以 O(1)。但 DP 仍需 O(n^3)。
如果 n 较大(如 1000),我们可以用另一种思路:
定义 dp[l][i] 表示以 i 开头、长度为 l 的回文子序列的个数,然后用字符位置转移,但去重困难。
在竞赛中,通常 n<=200,O(n^3) 可接受。
第十步:举例验证
s = "abca", L=2。
区间 [0,3]:
s[0]=a, s[3]=a 相等,找 leftPos: 下一个 a 在位置 3(但 leftPos 要在 (0,3) 内),没有,所以 leftPos 不存在。
mid = dfs(1,2) = 区间 "bc" 的回文子序列:b, c 两个长度为1的。
所以 mid[1]=2, mid[2]=0, ...
cnt[l] 先由 left=[1,2], right=[0,2] 合并计算(略)。
然后新增:
l>=3: cnt[l] += mid[l-2] → 没有。
l=2: cnt[2] += 1(新增 "aa")
l=1: cnt[1] += 1(新增 "a")
但注意区间 [0,2] 和 [1,3] 等会贡献其他回文。
最终我们得到 cnt[2]=1 ("aa"), cnt[3]=2 ("aba","aca"),长度>=2 的总数=1+2=3,与手工计算一致。
总结
我们通过扩展经典统计不同回文子序列的 DP,加入长度维度,按照字符匹配情况分三类递推长度分布,最终求和长度 >= L 的部分,得到答案。这个算法在字符集小、n 不太大时可行,时间复杂度 O(n^3),空间 O(n^3) 可优化到 O(n^2) 如果只存必要区间长度数组。