算法基础
最长回文子串问题
-
如果一个字符串从左往右看和从右往左看是一样的,我们把它叫做回文串
-
回文串可以按长度的奇偶性分为奇数长度的回文串和偶数长度的回文串。
-
最长回文子串(Longest palindromic substring)问题指的是对于任意一个给定的字符串,我们要求出这个字符串中最长的回文子串。
-
我们很容易就可以想到最暴力的做法:
枚举所有子串,然后检查当前枚举到的子串是否为回文串,最后从符合条件的子串中找出最长的就可以啦!
暴力的时间复杂度是 O ( n 3 ) O(n^3) O(n3)的, 枚举起点,终点, 判断回文串都是O(n)
-
稍作优化,可以发现如果一个串已经不是回文串了,那么再在两边添加同样数量的字符得到的串也不可能是回文串。
因此我们可以枚举回文串的中心位置(中间位置可能是两个字符中间),然后往两边扩展看看最远能够达到的位置。这个做法的时间复杂度是 O ( n 2 ) O(n^2) O(n2)的。
-
使用二分+哈希可以做到 O ( n l o g n ) O(nlog n) O(nlogn)的时间复杂度。
字符串处理技巧
- 避免讨论奇偶性的技巧
-
对于字符串s,我们先用一个s 中不存在的字符(比如说$)把 s 中的字符隔开(开头和结尾也要加)。
-
加完后, 原字符串就变成奇数长度了
因为两倍都加
$
不改变奇偶性, 原来偶数长度的有奇数个空隙, 奇数长度的有偶数个空隙,奇数+偶数 = 奇数并且偶数+奇数=奇数。所以在所有字符中间加$
后原串长度就变成奇数了 -
例如,字符串abacc会变成
$a$b$a$c$c$
。 -
然后再来求新得到的串中奇数长度的最长回文子串。
奇数长度的回文串就是以字符为中心开始的回文串, 就不需要考虑两个字符串中间作为回文中心的情况了
奇数回文串aba,length = 3 →
$a$b$a$
, length = 7。偶数回文串cc, length = 2 →
$c$c$
, length = 5。 -
将新串中的LPS长度 / 2 就是原串的LPS
-
算法思想
对于新得到的字符串s, 我们的目标是求出从它的任意位置 i 出发,往两边最远能够扩展出的回文子串的长度(往一边能够扩展的字符数即为从 i 开始(包括i
)的最大回文半径,记做 p[i]
)
p[i]
如何维护
-
我们要从左往右一个位置一个位置计算
p[i]
的值 -
假设现在我们要算
p[i]
。和之前介绍的扩展KMP算法类似的,我们要维护一个到目前为止R最大的区间[L,R],其中L= M - p[M] + 1(M < i)
,R = M + p[M] - 1
。 -
[L,R]是一个回文串
-
如果
i ≤ R
,则情况如下:-
找到 i 关于M的对称点 k,此时
i - M = M - k
,k = 2 M - i;此时有两种情况:-
如果
p[k]
对应的回文区间[k - p[k]+1, k + p[k] -1]
不含左端点L(L < k - p[k]+1)
,说明[k – p[k] + 1, k + p[k] - 1]
在[L,R]
之中,此时有p[i] = p[k]
由于此时
p[k]
在之前已经求出了, 此时从位置k
向左扩p[k]
长度和i
向右扩p[k]
长度是对称的, 同时有k
向右扩p[k]
长度和i
向左扩p[k]
长度也是对称的, 再由于k向左和向右扩p[k]
长度也是对称的, 所以i
向左向右扩p[k]长度也是对称的, 因为p[k]已经是k能扩展的最长回文半径, 所以对于位置i
也是最大的回文半径。 -
如果
p[k]
对应的回文区间[k- p[k]+ 1, k+ p[k]-1]
包含了左端点L,即L >= k - p[k] + 1
。此时[L,2k - L]
这一段为回文串。由于[L,R]是回文串,[2i - R, R]
是[L,2k -L]关于M的对称区间, 则可以推出[2i-R,R]
这一段也是回文串, 然后再暴力往两边扩展即可。还是同一个思想, 因为
i
与k
关于M对称, 所以这两个点向反方向扩展相同长度的字符串都是对称的, 所以两个点同时向左右扩展的字符串也是对称的, 因为回文串对称后不变, 所以i
为中心的回文串最基础内容与k的最长相等。因为k可扩展的半径超过左端点, 所以L左边的情况未知, 即以
k
为中心的回文串所提供的信息被L所限制, 所以位置i
的回文串长度至少是位置k的回文串长度, 所以还得继续暴力扩展。
-
-
如果 i > R:
此时暴力往两边扩展即可。因为已经无法从向右覆盖范围最大的回文区间获取任何回文信息了。
-
记得最后要更新M、L、R的值
用当前位置
i
的右半径i + p[i] - 1
与R比较, 如果大于R, 则更新。
-
-
核心思想
- 充分利用之前求过的
p[j], j < i
的信息 - 使右端点尽量大是使更多点 i 可以利用之前求过的
p[]
- 根据
i
与k
的对称性获取i
的回文信息
- 充分利用之前求过的
-
时间复杂度
暴力扩展的过程中R的值也在同步增加,而R最多只会被加O(n)次,如果R扩到n此时不再需要暴力扩展, 因为
i
的位置一定在R的左边。因为所有的回文信息都已知。因此算法的时间复杂度为O(n)。
模板
新字符串的回文半径-1就是原字符串中以该字符为中心($表示两个字符中间)的回文长度
$A$B$A$
: 回文半径为4, 则ABA回文长度为3
$B$B$
: 回文半径为3, 则BB回文长度为2
int n, p[2 * N]; // p[i] 表示以i为中心的最大回文半径
char t[N], s[2 * N];
void manacher() {
n = strlen(t + 1);
int m = 0;
// 插入'$', 形成奇数长度串s
s[++m] = '$';
for(int i = 1; i <= n; i++)
s[++m] = t[i], s[++m] = '$';
// 三个数, 知2求1
int M = 0, R = 0;
for(int i = 1; i <= m; i++) {
if(i > R) p[i] = 1; // 等会暴力外扩
// 如果 p[k] < k - L + 1, p[i] = p[k] = p[2 * M - i]
// 如果 p[k] >= k - L + 1, p[i] = K - L + 1 = R - i + 1;
else p[i] = min(p[2 * M - i], R - i + 1);
// 暴力外扩, 保证在范围内
while(i - p[i] >= 1 && i + p[i] <= m && s[i - p[i]] == s[i + p[i]]) p[i]++;
// 更新R最大的区间
if(i + p[i] - 1 > R) M = i, R = i + p[i] - 1;
}
// 求新串s的最长回文半径(算中心字符长度)
int ans = 0;
for(int i = 1; i <= m; i++) ans = max(ans, p[i]);
printf("%d\n", ans - 1);
}
例题
最长回文子串
给你一个字符串 𝑠,字符串由小写字母组成,现在你需要求出 𝑠 中最长的回文子串的长度。对于所有数据,保证 1 ≤ ∣ s ∣ ≤ 1 0 5 1\le |s| \le 10^5 1≤∣s∣≤105,字符串均由小写字母构成
#include<iostream>
#include<cstring>
using namespace std;
#define _for(i,a,b) for(int i=(a);i<(b);i++)
#define _rep(i,a,b) for(int i=(a);i<=(b);i++)
typedef unsigned long long ULL;
typedef pair<int, int> PLL;
typedef long long ll;
const int N = 1e5+5;
const int INF = 0x3f3f3f3f;
int readint() {
int x;scanf("%d",&x);return x;
}
int n, m, p[2 * N];
char s[N], t[2 * N];
void manacher() {
n = strlen(s + 1);
int m = 0;
t[++m] = '$';
for(int i = 1; i <= n; i++)
t[++m] = s[i], t[++m] = '$';
int M = 0, R = 0;
for(int i = 1; i <= m; i++) {
if(i > R) p[i] = 1;
else p[i] = min(p[2 * M - i], R - i + 1);
while(i - p[i] >= 1 && i + p[i] <= m && t[i - p[i]] == t[i + p[i]]) p[i]++;
if(i + p[i] - 1 > R) M = i, R = i + p[i] - 1;
}
int ans = 0;
for(int i = 1; i <= m; i++) ans = max(ans, p[i]);
printf("%d\n", ans - 1);
}
int main()
{
scanf("%s", s + 1);
manacher();
return 0;
}
Extend_to_Palindrome
给出仅由大小写英文字母组成的长度为n( n ≤ 1 0 5 n \le 10^5 n≤105)的字符串, 求在S后面增加最少的字符形成一个回文串。
因为只能在原串的后面加字符形成回文串, 所以要照原串的最长回文后缀, 再将剩余前缀反转后加到原串末尾, 这样使得添加的字符最少。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 2e5+5;
int p[2 * N + 1];
string ss;
void manacher() {
int n = ss.size();
string s = ".$";
for (int i = 1; i <= n; i++) {
s.push_back(ss[i - 1]);
s.push_back('$');
}
int m = s.size() - 1;
int M = 0, R = 0;
for (int i = 1; i <= m; i++) {
if (i > R) p[i] = 1;
else p[i] = min(p[2 * M - i], R - i + 1);
while (i - p[i] >= 1 && i + p[i] <= m
&& s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
if (i + p[i] - 1 > R) {
M = i, R = i + p[i] - 1;
}
}
// 找最长回文后缀及其开始位置
int maxL = 1, pos = 1;
for (int i = 1; i <= m; i++) {
// 以i位为中心的回文串的右端点要落在m上, 保证是后缀
if (i + p[i] - 1 == m) {
if (p[i] - 1 >= maxL) {
maxL = p[i] - 1;
pos = i - p[i] + 1;
}
}
}
// 改造后的串中的位置/2就是再原串中的位置
pos /= 2;
// 将原串的前面剩余部分截取并反转拼接
string add = ss.substr(0, pos);
reverse(add.begin(), add.end());
string ans = ss + add;
// amanap lanacanal
cout << ans << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr); cout.tie(nullptr);
while (cin >> ss) {
manacher();
}
return 0;
}
最长双回文串
顺序和逆序读起来完全一样的串叫做回文串。比如
acbca
是回文串,而abc
不是:abc
的顺序为abc
,逆序为cba
,不相同。输入长度为 n n n 的串 S S S,求 S S S 的最长双回文子串 T T T,即可将 T T T 分为两部分 X , Y X, Y X,Y( ∣ X ∣ , ∣ Y ∣ ≥ 1 |X|,|Y|≥1 ∣X∣,∣Y∣≥1)且 X X X 和 Y Y Y 都是回文串。
对于 100 % 100\% 100% 的数据, 2 ≤ ∣ S ∣ ≤ 1 0 5 2\leq |S|\leq 10^5 2≤∣S∣≤105。
双回文子串即可以再回文串中间切开,两边都是回文子串,
要求得最长双回文子串, 则先要求以每个点为终点和起点的最长回文子串长度
设endMax[i]
保存以i
为终点的最大回文串的长度, startMax[i]
保存以i
为起点的最大回文串的长度, 位置和长度都是原始字符串中的。
对于位置i
, 已知p[i]
则[i - p[i] + 1, i]
是回文串的起点, [i, i + p[i] - 1]
是回文串的终点
此时很快可以知道以i - p[i] + 1
为起点的回文串长度和以i + p[i] - 1
为终点的回文串长度, 但是两个端点中间还有些点是回文串的起点或终点, 也应该考虑它们的位置和对于回文串的长度,
例如: CBABC , 遍历到A时才会更新: endMax[5] = startMax[1] = 5
, 但内部的BAB子回文串也需要考虑, 即要进行更新: endMax[4] = startMax[2] = 3
假设以i
为起点存在回文串, 则由对称性可知以i+1
为起点也存在不超过大回文串终点的回文串, 对应长度为以i
为起点的回文串的长度-2, 以此类推可求得以i
为起点的回文串内部所有回文串的位置和长度信息
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e5+5;
const int INF = 0x3f3f3f3f;
int p[2 * N + 1], endMax[2 * N], startMax[2 * N];
string ss;
void manacher() {
int n = ss.size();
string s = ".$";
for (int i = 1; i <= n; i++) {
s.push_back(ss[i - 1]);
s.push_back('$');
}
int m = s.size() - 1;
int M = 0, R = 0;
for (int i = 1; i <= m; i++) {
if (i > R) p[i] = 1;
else p[i] = min(p[2 * M - i], R - i + 1);
while (i - p[i] >= 1 && i + p[i] <= m
&& s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
if (i + p[i] - 1 > R) {
M = i, R = i + p[i] - 1;
}
}
int ans = 1;
// b aacaa bbacabb
// $b$ a$a$c$a$a $b$b$a$c$a$b$b$
// 5 7
// 求每个位置i, 以i结尾最长回文串和以i开始的最长回文串
for (int i = 1; i <= m; i++) {
int l = i - p[i] + 2, r = i + p[i] - 2;
l /= 2, r /= 2;
endMax[r] = max(endMax[r], p[i] - 1);
startMax[l] = max(startMax[l], p[i] - 1);
}
// 位置i的最长回文串内部也存在回文串, 也要考虑其对某个位置的长度贡献
for (int i = n; i >= 1; i--) {
endMax[i] = max(endMax[i], endMax[i + 1] - 2);
}
for (int i = 1; i <= n; i++) {
startMax[i] = max(startMax[i], startMax[i - 1] - 2);
}
for (int i = 1; i <= n - 1; i ++) {
ans = max(ans, endMax[i] + startMax[i + 1]);
}
cout << ans << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr); cout.tie(nullptr);
cin >> ss;
manacher();
return 0;
}
拉拉队排练
一个阳光明媚的早晨,雨荨带领拉拉队的队员们开始了排练。 n ( 1 ≤ n ≤ 1 0 6 ) n(1 \le n \le 10^6) n(1≤n≤106) 个女生从左到右排成一行,每个人手中都举了一个写有 26 26 26 个小写字母中的某一个的牌子,在比赛的时候挥舞,为小伙子们呐喊、加油。
雨荨发现,如果连续的一段女生,有奇数个,并且他们手中的牌子所写的字母,从左到右和从右到左读起来一样,那么这一段女生就被称作和谐小群体。
现在雨荨想找出所有和谐小群体,并且按照女生的个数降序排序之后,前 K ( 1 ≤ K ≤ 1 0 12 ) K(1\le K \le 10^{12}) K(1≤K≤1012) 个和谐小群体的女生个数的乘积是多少。由于答案可能很大,雨荨只要你告诉她,答案除以 19930726 19930726 19930726 的余数是多少就行了。
首先应该想到统计每个长度的回文串有多少个, 由Manacher算法很容易得知以某个中心位置的最长回文串长度, 可以很方便地统计。但是内部回文串会被忽略,如果此时暴力得遍历内部的子回文串(就是不断去除回文串两边字符所形成的回文串)统计长度, 时间复杂度为 O ( n 2 ) O(n^2) O(n2)
因为我们考虑地是前K大的奇数长回文串, 所以优先会使用长度大的回文串, 当我们使用完长度大的回文串后, 我们在利用大回文串的出现次数, 更新内部回文串的出现次数
如果我们已经知道回文串 A B A B A ABABA ABABA, 第一次统计完后我们只知道长度为5的回文串出现了1次(“ABABA”), 长度为3的回文串(“ABA”)出现了2次, 长度为1的回文串(A)出现了2次2, 实际上长度为3的字符串"BAB"也出现了一次, 但是我们不一定使用到长度为3的回文串, 所以当我们使用到长度为5的回文串时, 我们在更新长度为3的回文串的出现次数
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e6+5;
const int P = 19930726;
const int INF = 0x3f3f3f3f;
int p[2 * N + 1];
ll cnt[N];
string ss;
ll n, k;
ll quickPower(ll a, ll b) {
ll res = 1;
for(; b; b >>= 1, a = a * a % P)
if(b & 1) res = res * a % P;
return res;
}
void manacher() {
int n = ss.size();
string s = ".$";
for (int i = 1; i <= n; i++) {
s.push_back(ss[i - 1]);
s.push_back('$');
}
int m = s.size() - 1;
int M = 0, R = 0;
for (int i = 1; i <= m; i++) {
if (i > R) p[i] = 1;
else p[i] = min(p[2 * M - i], R - i + 1);
while (i - p[i] >= 1 && i + p[i] <= m
&& s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
if (i + p[i] - 1 > R) {
M = i, R = i + p[i] - 1;
}
}
for (int i = 1; i <= m; i++) {
int l = i - p[i] + 2, r = i + p[i] - 2;
l /= 2, r /= 2;
// 统计以某位置为中心的回文长度的出现次数
cnt[r - l + 1]++;
}
ll ans = 1;
// 统计奇数长度回文串
for (int i = n; i >= 1 && k; i--) if (i & 1) {
// 判断能使用长度为i的奇数长度回文串多少个
int pow = 0;
if (k >= cnt[i]) {
k -= cnt[i];
pow = cnt[i];
} else {
pow = k;
k = 0;
}
ans = ans * quickPower(i, pow) % P;
// 每次将使用完长串的长度后
// 去掉两边字符, 更新内部最大子回文串出现次数
if (i >= 3) cnt[i - 2] += cnt[i];
}
// 奇数长度回文串数量不足k个
if (k) cout << -1 << endl;
else cout << ans << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr); cout.tie(nullptr);
cin >> n >> k >> ss;
manacher();
return 0;
}
Palisection
给你一个字符串 𝑠,字符串由小写字母组成,现在你需要求出 𝑠 中有多少对有公共部分的回文子串,请输出答案 mod 51123987
对于所有数据,保证 1 ≤ n ≤ 2 × 1 0 6 1 \le n \le 2\times10^6 1≤n≤2×106, 字符串均由小写字母构成。
-
容斥原理
有公共部分的回文子串对数 = 总回文子串对数 - 没有公共部分的回文子串对数
-
计算出回文子串数
x
即每个位置为中心的回文半径的和
-
计算出回文子串对数 x × ( x − 1 ) / 2 x\times(x - 1) / 2 x×(x−1)/2
- 即x个回文子串两两配对且不重复
- 不能先对x取模, 不然x-1会改变
计算没有公共部分的回文子串对数
以某位置结束的回文子串数
- 以
i
为中心的右半段[i, i + p[i] - 1]
是回文子串结尾位置 - 利用前缀和给计数数组
cntEnd[i, i + p[i] - 1]
段都+1
以某位置开始的回文子串数
- 以
i
为中心的左半段[i - p[i] + 1, i]
是回文子串起始位置 - 利用前缀和给计数数组
cntStart[i - p[i] + 1, i]
段都+1
知道了以某位置开始的回文子串数, 对cntStart算后缀和即可快速得到大于某个位置的回文子串数
记以位置i
结束的回文子串数为y, 以> i
位置开始的回文子串数为z,
y
×
z
y \times z
y×z就是位置i
对无公共部分的回文子串对数的贡献
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 2e6+5;
const int mod = 51123987;
const int INF = 0x3f3f3f3f;
int p[2 * N + 1], cntEnd[N], cntStart[N];
string ss;
void manacher() {
int n = ss.size();
string s = ".$";
for (int i = 1; i <= n; i++) {
s.push_back(ss[i - 1]);
s.push_back('$');
}
int m = s.size() - 1;
int M = 0, R = 0;
for (int i = 1; i <= m; i++) {
if (i > R) p[i] = 1;
else p[i] = min(p[2 * M - i], R - i + 1);
while (i - p[i] >= 1 && i + p[i] <= m
&& s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
if (i + p[i] - 1 > R) {
M = i, R = i + p[i] - 1;
}
}
ll ans = 0;
for (int i = 1; i <= m; i++) {
int olen = p[i] - 1;
ans += (olen + 1) / 2;
}
ans = (ans % mod * (ans % mod - 1) / 2) % mod;
for (int i = 1; i <= m; i++) {
// 左右端点的$略去
int l = i - p[i] + 2, r = i + p[i] - 2;
l /= 2, r /= 2;
// 差分修改
cntStart[l]++; cntStart[i / 2 + 1]--;
cntEnd[(i + 1) / 2]++; cntEnd[r + 1]--;
}
// 对差分数组求前缀和得到原数组
for (int i = 1; i <= n; i++) {
cntEnd[i] += cntEnd[i - 1];
cntStart[i] += cntStart[i - 1];
}
// 对cntStart[]求后缀和, 和是O(n^2)的所以要模
for (int i = n - 1; i >= 1; i--) {
cntStart[i] += cntStart[i + 1];
cntStart[i] %= mod;
}
for (int i = 1; i <= n - 1; i++) {
ans -= 1ll * cntEnd[i] * cntStart[i + 1] % mod;
if (ans < 0) ans += mod;
}
cout << ans << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr); cout.tie(nullptr);
int n; cin >> n;
cin >> ss;
manacher();
return 0;
}
总结
回文串性质: 在一个回文串两边同时加上相同字符仍然是回文串, 所以在考虑回文子串贡献(数量或长度)时不要忘了, 大回文串内部的小串
一个回文串内部包含其回文半径个子回文串, 在对称位置的右边字符是这些回文串的终点, 左边是回文串的起点
回文串关于其中心对称: 回文串的左右两个子串对称
适时地将串的下标改为原串下标会简化理解