前言: 有半年时间没有写博客了,今天写题目的时候遇到了一个之前完全没有见过的题目,因此前来记录一下。
问题描述
- 对于字符串 s s s,需要计算其中长度为 k k k 的 回文子序列 数目,通常 1 < k ≤ 5 1 < k \le 5 1<k≤5。
1. k = 2 k = 2 k=2
- 先简化问题,当 k = 2 k = 2 k=2 时,回文子序列的个数即为 s s s 中的相同字符对,采用一次遍历便可以完成计算
long long solve(string s){
int cnt[26] = {0};
long long ans = 0;
for(char c : s){
int x = c - 'a';
ans += cnt[x];
cnt[x]++;
}
return ans;
}
- 时间复杂度: O ( n ) O(n) O(n), n n n 为字符串 s s s 的长度。
2. k = 3 k = 3 k=3
- 进一步考虑 k = 3 k = 3 k=3 的情况,由于 k k k 为奇数,枚举 s s s 中的每一个字符 c c c 作为中心字符,同时枚举第一个字符的可能取值。
- 实现细节:当下标为 i i i 时,需要考虑 i i i 的左右两部分,即分别进行 前缀 和 后缀 计数。首先逆向遍历 s s s,统计每个字符出现的数目 s u f [ a ] suf[a] suf[a],再正向遍历,统计 p r e [ a ] pre[a] pre[a] 的同时更新 s u f suf suf,实现前后缀同步。
- 题目示范:L.C. 1930. 长度为 3 的不同回文子序列
class Solution {
public:
int countPalindromicSubsequence(string s) {
int n = s.size();
int suf[26] = {0}, pre[26] = {0};
for(int i = 0; i < n; ++i){
suf[s[i] - 'a']++;
}
int ans[26][26];
memset(ans, 0, sizeof(ans));
for(int i = 0; i < n; ++i){
int x = s[i] - 'a';
suf[x]--;
for(int j = 0; j < 26; ++j){
if(pre[j] > 0 && suf[j] > 0)
ans[x][j] = 1;
}
pre[x]++;
}
int ret = 0;
for(int i = 0; i < 26; ++i){
for(int j = 0; j < 26; ++j){
ret += ans[i][j];
}
}
return ret;
}
};
- 时间复杂度: O ( n ∣ Σ ∣ ) O(n \left | \Sigma \right |) O(n∣Σ∣), n n n 为字符串 s s s 的长度, ∣ Σ ∣ \left | \Sigma \right | ∣Σ∣ 为字符集大小
3. k ∈ [ 4 , 5 ] k \in [4, 5] k∈[4,5]
-
难度升级,考虑 k ∈ [ 4 , 5 ] k \in [4, 5] k∈[4,5] 的情况。对于 k ∈ [ 4 , 5 ] k \in [4, 5] k∈[4,5],可以分别从 k = 2 k = 2 k=2 和 k = 3 k = 3 k=3 的情况实现进一步递推。
-
对于 k ∈ [ 2 , 3 ] k \in [2, 3] k∈[2,3],我们枚举右边的一个字符,因此对于 k ∈ [ 4 , 5 ] k \in [4, 5] k∈[4,5],我们需要枚举右边的两个字符。
-
实现细节:
- 首先逆向遍历 s s s,统计每一个字符出现的数目 s u f [ a ] suf[a] suf[a] 和两个字符的组合数目 s u f 2 [ a ] [ b ] suf2[a][b] suf2[a][b]。
- 再正向遍历 s s s,同样维护两个数组 p r e [ a ] pre[a] pre[a] 和 p r e 2 [ a ] [ b ] pre2[a][b] pre2[a][b],并在遍历过程中枚举两个字符的所有组合。
-
题目示范:L.C. 6251. 统计回文子序列数目
class Solution {
public:
const int mod = 1e9 + 7;
int suf2[10][10], suf[10];
int pre2[10][10], pre[10];
int countPalindromes(string s) {
int n = s.size();
for(int i = n - 1; i >= 0; --i){
int x = s[i] - '0';
for(int j = 0; j < 10; ++j){
suf2[x][j] = (suf2[x][j] + suf[j]) % mod;
}
suf[x]++;
}
int ans = 0;
for(char c : s){
int x = c - '0';
// 撤销后缀
suf[x]--;
for(int j = 0; j < 10; ++j){
suf2[x][j] = (suf2[x][j] - suf[j] + mod) % mod;
}
// 枚举两个字符的所有可能情况
for(int j = 0; j < 10; ++j){
for(int k = 0; k < 10; ++k){
ans = (ans + (1LL * pre2[j][k] * suf2[j][k]) % mod) % mod;
}
}
// 计算前缀
for(int j = 0; j < 10; ++j){
pre2[x][j] = (pre2[x][j] + pre[j]) % mod;
}
pre[x]++;
}
return ans;
}
};
-
时间复杂度: O ( n ∣ Σ ∣ 2 ) O(n \left | \Sigma \right |^2) O(n∣Σ∣2), n n n 为字符串 s s s 的长度, ∣ Σ ∣ \left | \Sigma \right | ∣Σ∣ 为字符集大小
-
注意:对于 k = 4 k = 4 k=4 和 k = 5 k = 5 k=5 虽然在算法的具体思路上是相似的,但是在实现细节上有些许不同:
- 对于 k = 5 k = 5 k=5,需要枚举中心字符,因此当遍历到下标 i i i 时,需要先撤销后缀,再枚举字符组合,最后更新前缀,总是仅覆盖 n − 1 n - 1 n−1 个字符。
- 对于 k = 4 k = 4 k=4,由于不存在中心字符,因此可以 在撤销后缀的同时更新前缀,再枚举字符组合,覆盖 n n n 个字符。
-
k = 4 k = 4 k=4 的部分代码
for(char c : s){
int x = c - '0';
// 撤销后缀 + 更新前缀
suf[x]--;
for(int j = 0; j < 10; ++j){
suf2[x][j] = (suf2[x][j] - suf[j] + mod) % mod;
pre2[x][j] = (pre2[x][j] + pre[j]) % mod;
}
pre[x]++;
// 枚举两个字符的所有可能情况
for(int j = 0; j < 10; ++j){
for(int k = 0; k < 10; ++k){
ans = (ans + (1LL * pre2[j][k] * suf2[j][k]) % mod) % mod;
}
}
}