回文自动机
自动机入门——回文自动机 PAM
1 算法简介
回文自动机,也称为回文树,是由俄罗斯人 MikhailRubinchik 在 \(2014\) 年夏发明的,回文树可以高效的解决一系列设计回文串的问题。
2 结构
回文树由转移边和后缀链接组成,每一个节点可以代表一个回文子串,因为回文串长度分为奇数和偶数,我们建两颗树,其中一棵树中的节点对应的回文子串长度为奇数,另一颗树中的节点对应的回文子串长度都是偶数。
和其他的自动机一样,一个节点的 \(fail\) 指针指向的是这个节点所代表的回文串的最长回文后缀所对应的节点,但是转移边并非代表在原节点代表的回文串后加一个字符,而是表示在原节点代表的回文串前后各加一个相同的字符。
我们还需要在每个节点上维护此节点对应回文子串的长度 \(len\) ,这个信息保证了我们可以轻松的构造出回文树。
3 建造
回文树有两个初始状态,分别表示长度为 \(-1,0\) 的回文串,我们可以称它们为奇根,偶根,它们不表示任何实际的字符串,仅仅作为初始状态出现,这与其他自动机的根节点是异曲同工的。
偶根的 \(fail\) 指针指向奇根,而我们并不关心奇根的 \(fail\) 指针,因为奇根不可能失配,这是因为奇根转移出的下一个字符为单个字符。
类似于 SAM 我们增量去构造 PAM ,考虑构造完前 \(p-1\) 个字符的回文树后,向自动机中添加在原串里位置为 \(p\) 的字符。我们从以上一个字符结尾的最长回文子串对应的节点开始,不断沿着 \(fail\) 指针走,直到找到一个节点满足 \(s_p=s_{p-len-1}\) ,即满足回文子串的前一个字符与字符 \(s_p\) 相同。
然后我们在这个节点两边都添加这个字符就构造完了现在的回文串,注意判断这个节点是否之前就存在。显然,这个节点就是以 \(p\) 结尾的最长回文子串对应的树上节点。我们从这个节点继续跳 \(fail\) 指针,就可以找到新建节点的最长回文后缀。
注意我们给字符串的最开头建立一个哨兵字符。
4 代码与分析
上面说的还是有点笼统,不过我们先写代码,展示完代码之后我们上图说明。
struct node{
int link,len,ch[26];
};
struct PAM{
int size,last;
node p[N];char s[N];
inline PAM(){
p[0].link=1;p[1].len=-1;
s[0]='#';size=1;last=0;
}
inline void insert(int c,int id){
int j;s[++tail]=c;
while(s[id-p[last].len-1]!=s[id]) last=p[last].link;
if(!p[last].ch[c]){
p[++size].len=p[last].len+2;
j=p[last].link;
while(s[id-p[j].len-1]!=s[id]) j=p[j].link;
p[size].link=p[j].ch[c];
p[last].ch[c]=size;
}
last=p[last].ch[c];
}
};
代码第 \(8\) 到 \(9\) 行,初始化。第 \(14\) 行,跳后缀链接知道存在这个字符串的前一个字符与当前字符相等,那么我们就可以从那里转移过来。这个新建节点的后缀链接需要从这个节点继续向上找到一个满足相同条件的节点,我们就找到了这个节点的最大回文后缀。正确性是显然的,我们用图加强理解。
读者可以自己模拟一下,当加入第一个节点的时候,因为我们上面的一些预处理,不会出现问题,我们会正确的把它连接在奇根下面。
5 图解
(图均来自洛谷博客,引用在最后)
注意,奇根的后缀链接是无关紧要的,但是在实现上我们通常认为奇根的后缀链接是偶根,这个在程序中不用特别指出。
6 正确性与复杂度分析
我们先证明 PAM 状态数是线性的。
- 定理 \(1\) 对于一个字符串 \(s\) ,它的本质不同的字符串不同回文子串个数最多只有 \(|s|\) 个。
考虑使用数学归纳法来证明。
- 当 \(|s|=1\) ,平凡。
- 当 \(|s|>1\) 的时候,设 \(t=sc\) ,假设结论对 \(s\) 串成立,考虑以最后一个字符 \(c\) 结尾的回文子串,假设它们的左端点由小到大排序为 \(l_1,l_2,...l_k\),由于 \(t_{l_1,|t|}\) 是回文串,因此对于所有位置 \(l_1\le p\le |t|\) ,如果 \(t_{p,|t|}\) 是一个回文串, 那么有 \(t_{p,|t|}=t_{l_1,l_1+|t|-p}\) 。所以 \(t_{l_i,|t|}\) 在之前就出现过,所以每次增加一个字符,本质不多的字符串最多只增加一个。
对于回文树的构造,显然除了跳后缀链接其他操作都是 \(O(|s|)\) 的。
而加入字符的时候,每次跳后缀链接后对应节点在 \(fail\) 树上的深度 \(-1\) ,而连接 \(fail\) 后,仅为深度加一。所以每一次加入字符最多只会加 \(n\) 次深度,最多跳 \(2n\) 次 \(fail\) 。所以回文自动机是线性的。
7 例题
每一次加入一个新节点,从自己的后缀链接那里承接信息就可以了。
#include<bits/stdc++.h>
#define dd double
#define ld long double
#define ll long long
#define uint unsigned int
#define ull unsigned long long
#define N 900100
#define M number
using namespace std;
const int INF=0x3f3f3f3f;
template<typename T> inline void read(T &x) {
x=0; int f=1;
char c=getchar();
for(;!isdigit(c);c=getchar()) if(c == '-') f=-f;
for(;isdigit(c);c=getchar()) x=x*10+c-'0';
x*=f;
}
int f[N];
struct node{
int link,len,ch[26];
};
struct PAM{
int size,last,tail;
node p[N];int s[N];
inline PAM(){
p[0].link=1;p[1].len=-1;
size=1;last=0;s[0]='#';
}
inline void insert(int c,int id){
int j;s[++tail]=c;
while(s[id-p[last].len-1]!=s[id]) last=p[last].link;
if(!p[last].ch[c]){
p[++size].len=p[last].len+2;
j=p[last].link;
while(s[id-p[j].len-1]!=s[id]) j=p[j].link;
p[size].link=p[j].ch[c];
p[last].ch[c]=size;
}
last=p[last].ch[c];f[last]=f[p[last].link]+1;
}
};
PAM pam;
char s[N];
int main(){
scanf("%s",s+1);int len=strlen(s+1);
for(int i=1;i<=len;i++){
pam.insert(s[i]-'a',i);
int ans=f[pam.last];s[i+1]=(s[i+1]-97+ans)%26+97;
printf("%d ",ans);
}
return 0;
}
注意可能 \(size\) 并不是当前的结束节点,所以我们一定要在 \(last\) 上进行修改。