字串演算法 —— 迴文自動機(迴文樹)
先引入一道題:
URAL1297. Palindrome
題意
給定一個字串 \(s\), 保證 \(|s| \le 1000\),求出任意一個最長迴文子串。
迴文自動機
迴文自動機是一種能夠儲存所有迴文串資訊的一種很好用的工具,它可以在 \(O(n)\) 時間求出字串的所有迴文子串。
節點含義
和AC自動機的節點含義比較像,迴文自動機的一個節點表示在他的父節點兩端各加一個字元形成的迴文子串。
奇偶根
既然每個節點有父親,就需要有根。由於迴文串分為奇數和偶數兩類,所以就需要奇根和偶根。
變數
- \(len_i\):第 \(i\) 號節點所表示的迴文字串長度。
-
\(son_{i\texttt{ } j}\)
- \(fail_i\):第 \(i\) 號節點的最長迴文字尾。
- \(now\):以當前字元為結尾的最長字尾所表示的節點。
- \(fa_i\):第 \(i\) 號節點的父親。(這個不需要特殊統計)
構建
注:本文下面的圖所表示的迴文自動機,帶箭頭實線表示 \(fail\) 指標,普通實線表示 \(son\)。
之後每次插入一個字元,假設這個字元為 \(i\),從 \(now\) 開始找,從之前的最長迴文字尾開始,找到一個最長的迴文字尾且這個迴文字尾的前一個字元與當前字元相同(暴力跳 \(fail\) 指標),\(now\)
- 如果當前節點有 \(i\) 兒子,就直接把 \(now\) 跳到 \(i\) 兒子,
- 如果沒有,就要新建一個節點,並確定新建節點的所有資訊,\(now\) 為這個新建節點的父親。
這裡主要對 2 操作講解:假設當前節點編號為tot:
- \(fail_{tot}\):一樣暴力跳 \(fail\) 指標,找到一個最長的迴文字尾且這個迴文字尾的前一個字元與當前字元相同,節點為 \(cur\),那麼 \(fail_{tot}=son_{cur\texttt{ } i}\)。
- \(len_{tot}\):\(len_{tot}=len_{fa_{tot}}+2\)。
好像這樣就可以了。
舉個栗子吧,假設現在需要處理的字串為 \(S=abaabaab\)
首先需要一個奇根(\(1\) 號節點)和一個偶根(\(0\) 號節點),並且偶根的 \(fail\) 指標連向奇根,奇根的 \(len=-1\),偶根 \(len=0\),初始的 \(now\) 為奇根,如圖
之後插入第一個字元 \(a\):從 \(now\) 開始找,找到一個最長的迴文字尾且這個迴文字尾的前一個字元與當前字元相同,即 \(s_i=s_{i-len_{now}-1}\),沒找到就跳 \(fail\) ,容易證明只要一定會找到(跳到奇根就找到了),現在 \(now=1\),並發現 \(son_{now\texttt{ } 0}=0\),所以要新建一個節點 \(2\),\(2\) 的父親是 \(1\),\(len_2=len_1+2=1\),之後找 \(fail\) ,一樣暴力跳到一個最長的迴文字尾且這個迴文字尾的前一個字元與當前字元相同,這個節點還是 \(1\) ,那麼 \(a\) 的 \(fail\) 指標就是 \(son_{1\texttt{ }0}=0\),於是自動機如下圖:
再插入一個字元 \(b\),找到他的父親為 \(1\),\(fail\) 還是 \(0\)。
之後再插入 \(a\),發現父親是 \(3\),\(fail\) 是 \(1\)。
以此類推,把所有字元全插進去。最終構造的迴文自動機就是這個樣子的:
\(get\texttt{_}fail(x,y)\) 函式(找到 \(x\) 的一個最長的迴文字尾且這個迴文字尾的前一個字元與第 \(y\) 個字元相同)
int get_fail(int x, int y) {
while (s[y - len[x] - 1] != s[y]) x = fail[x];
return x;
}
構造迴文自動機程式碼:
len[0] = 0, len[1] = -1, fail[0] = 1;
for (int i = 1; i <= n; ++i) {
now=get_fail(now,i);
if (!son[now][s[i] - 'a']) {
fail[++tot] = son[get_fail(fail[now],i)][s[i] - 'a'];
son[now][s[i] - 'a'] = tot;
len[tot] = len[now] + 2;
}
now = son[now][s[i] - 'a'];
}
本題做法
構造迴文自動機,找出所有迴文串長度最大值,並記錄最長迴文子串的最後一個字元的位置即可。
程式碼
// Problem: 1297. Palindrome
// Contest: Timus - IX Open Collegiate Programming Contest of the High School
// Pupils (13.03.2004) URL: https://acm.timus.ru/problem.aspx?space=1&num=1297
// Memory Limit: 64 MB
// Time Limit: 1000 ms
//
// Powered by CP Editor (https://cpeditor.org)
#include <cstdio>
#include <cstring>
using namespace std;
namespace Std {
char s[500010];
int n, son[500010][26], tot = 1, now = 1, len[500010], fail[500010], ls = 0,
ans, lst;
int get_fail(int x, int y) {
while (s[y - len[x] - 1] != s[y]) x = fail[x];
return x;
}
int main(void) {
scanf("%s", s + 1);
n = strlen(s + 1);
len[0] = 0, len[1] = -1, fail[0] = 1;
for (int i = 1; i <= n; ++i) {
now = get_fail(now, i);
if (!son[now][s[i] - 'a']) {
fail[++tot] = son[get_fail(fail[now], i)][s[i] - 'a'];
son[now][s[i] - 'a'] = tot;
len[tot] = len[now] + 2;
if (len[tot] > ans) ans = len[tot], lst = i;
}
now = son[now][s[i] - 'a'];
}
for (int i = lst - ans + 1; i <= lst; ++i) {
putchar(s[i]);
}
puts("");
return 0;
}
} // namespace Std
int main(int argc, char *argv[]) { return Std::main(); }
P5496 【模板】迴文自動機(PAM)
題意
給定一個字串 \(s\)。保證每個字元為小寫字母。對於 \(s\) 的每個位置,請求出以該位置結尾的迴文子串個數。
這個字串被進行了加密,除了第一個字元,其他字元都需要通過上一個位置的答案來解密。
具體地,若第 \(i(i\geq 1)\) 個位置的答案是 \(k\),第 \(i+1\) 個字元讀入時的 \(\rm ASCII\) 碼為 \(c\),則第 \(i+1\) 個字元實際的 \(\rm ASCII\) 碼為 \((c-97+k)\bmod 26+97\)。所有字元在加密前後都為小寫字母。
做法
先解出原串,然後構建迴文自動機,設 \(num_i\) 表示以第 \(i\) 個節點所表示的子串的迴文字尾子串(包括它自己)的個數,根據迴文自動機的性質可知:\(num_i=num_{fail_i}+1\),因為在此之間的所有串一定沒有新的迴文字尾了。
程式碼
// Problem: P5496 【模板】迴文自動機(PAM)
// Contest: Luogu
// URL: https://www.luogu.com.cn/problem/P5496
// Memory Limit: 256 MB
// Time Limit: 500 ms
//
// Powered by CP Editor (https://cpeditor.org)
#include <bits/stdc++.h>
using namespace std;
namespace Std {
char s[500010];
int n, son[500010][26], tot = 1, now = 1, len[500010], fail[500010], cs[500010],
num[500010], ls = 0;
int main(void) {
scanf("%s", s + 1);
n = strlen(s + 1);
len[0] = 0, len[1] = -1, fail[0] = 1, num[0] = -1;
for (int i = 1; i <= n; ++i) {
num[i] = (s[i] - 97 + ls) % 26;
while (num[i] != num[i - len[now] - 1]) now = fail[now];
if (!son[now][num[i]]) {
int nw = fail[now];
while (num[i - len[nw] - 1] != num[i]) nw = fail[nw];
fail[++tot] = son[nw][num[i]];
son[now][num[i]] = tot;
len[tot] = len[now] + 2;
cs[tot] = cs[fail[tot]] + 1;
}
now = son[now][num[i]];
ls = cs[now];
printf("%d ", cs[now]);
}
puts("");
return 0;
}
} // namespace Std
int main(int argc, char *argv[]) { return Std::main(); }
P3649 [APIO2014]迴文串
題意
給定字串 \(s\),定義該字串一個子串的存在值為這個字串的長度 \(\times\) 它在原串中出現次數,求出所有迴文子串中的最大存在值。
做法
還是需要先構建迴文自動機,然後每加一個字元,以當前字元結尾的最長字串出現次數\(+1\),容易證明每個長的子串多出現一次,他的字尾迴文子串也會多出現一次。所以把所有節點向它的 \(fail\) 指標連邊,跑一邊拓撲,統計答案即可。
程式碼
// Problem: P3649 [APIO2014]迴文串
// Contest: Luogu
// URL: https://www.luogu.com.cn/problem/P3649
// Memory Limit: 125 MB
// Time Limit: 1000 ms
//
// Powered by CP Editor (https://cpeditor.org)
#include <bits/stdc++.h>
#define int long long
using namespace std;
namespace Std {
char s[300010];
int n, son[300010][26], len[300010], num[300010], fail[300010],
tot = 1, now = 1, ans, in[300010];
vector<int> ed[300010];
queue<int> q;
void add(int x, int y) {
ed[x].push_back(y);
++in[y];
}
int get_fail(int x, int y) {
while (s[y - len[x] - 1] != s[y]) x = fail[x];
return x;
}
int main(void) {
scanf("%s", s + 1);
n = strlen(s + 1);
len[1] = -1;
fail[0] = 1;
for (int i = 1; i <= n; ++i) {
int opt = s[i] - 'a';
now = get_fail(now, i);
if (!son[now][opt]) {
fail[++tot] = son[get_fail(fail[now], i)][opt];
son[now][opt] = tot;
len[tot] = len[now] + 2;
add(tot, fail[tot]);
}
now = son[now][opt];
num[now]++;
}
for (int i = 2; i <= tot; ++i) {
if (!in[i]) q.push(i);
}
while (!q.empty()) {
int u = q.front();
ans = max(ans, num[u] * len[u]);
q.pop();
for (int i : ed[u]) {
--in[i];
num[i] += num[u];
if ((!in[i]) && i != 0 && i != 1) q.push(i);
}
}
printf("%lld\n", ans);
return 0;
}
} // namespace Std
#undef int
int main(int argc, char *argv[]) { return Std::main(); }
P4287 [SHOI2011]雙倍迴文
題意
定義雙倍迴文串滿足以下條件:
- 長度是 \(4\) 的倍數
- 整個串是個迴文串
- 把這個串分成長度相等的連續兩部分,每部分都是迴文串。
給定一個長度為 \(n\) 的字串 \(s\),求出最長的雙倍迴文子串。
做法
首先,先定義一個新變數 \(pre_i\) 表示 \(i\) 號節點的字尾迴文串中長度\(\le \frac{len_i}{2}\) 中最長的字尾迴文子串。
構建迴文自動機,與此同時求出 \(pre\),\(pre\) 的求法與 \(fail\) 指標求法很像,從父親的 \(pre\) 開始跳 \(fail\) 指標,只要遇到合法的串就是它的 \(pre\) 。
求出了 \(pre\) 就可以求答案了,只要 \(len_{pre_i}\times 2=len_i\) 且 \(len_{pre_i}\) 是偶數,就可以更新答案。
程式碼
// Problem: P4287 [SHOI2011]雙倍迴文
// Contest: Luogu
// URL: https://www.luogu.com.cn/problem/P4287
// Memory Limit: 125 MB
// Time Limit: 1000 ms
//
// Powered by CP Editor (https://cpeditor.org)
#include <bits/stdc++.h>
using namespace std;
namespace Std {
char s[500010];
int n, son[500010][26], len[500010], fail[500010], pre[500010], tot = 1,
now = 1, ans;
int get_fail(int x, int y) {
while (s[y - len[x] - 1] != s[y]) x = fail[x];
return x;
}
int get_pre(int x, int y, int z) {
while (s[y - len[x] - 1] != s[y] || ((len[x] + 2) << 1) > len[z]) x = fail[x];
return x;
}
int main(void) {
scanf("%d", &n);
scanf("%s", s + 1);
len[1] = -1;
fail[0] = 1;
fail[1] = 1;
for (int i = 1; i <= n; ++i) {
int opt = s[i] - 'a';
now = get_fail(now, i);
if (!son[now][opt]) {
fail[++tot] = son[get_fail(fail[now], i)][opt];
son[now][opt] = tot;
len[tot] = len[now] + 2;
if (len[tot] <= 2)
pre[tot] = fail[tot];
else
pre[tot] = son[get_pre(pre[now], i, tot)][opt];
if (len[tot] == (len[pre[tot]] << 1) && (!(len[pre[tot]] & 1)))
ans = max(ans, len[tot]);
}
now = son[now][opt];
}
printf("%d\n", ans);
return 0;
}
} // namespace Std
int main(int argc, char *argv[]) { return Std::main(); }