回文自动机 PAM
约定字符串下标从 \(0\) 开始。
定义
回文自动机,又称回文树,是一种 2014 年才发表的新算法。顾名思义,回文自动机用于求解回文串问题。它相较于 Manacher 算法的优点在于支持在线修改且复杂度不变。
实现
回文自动机的关键技术可以概括为 “奇偶字典树 + 后缀链跳跃”。
后缀链跳跃
考虑如何设计一种高效的求回文串的算法。Manacher 采用了递推,从 \(P(0)\cdots P(i-1)\) 递推到 \(P(i)\)。那么放到回文自动机上,也可以试图动态地加入一个个字符,判断它是否能和前面的字符形成回文。显然,如果新字符能产生回文,一定是和前面的后缀一起,并且应该从尽可能远的地方开始检查,才能节省时间复杂度。同时,如果当前字符是 \(i\),在前方不远处有一个 \(j\) 和字符 \(i\) 相同,那么 \([j+1,i-1]\) 这一段区间应该也是一个回文串。
说了这么多,我们应当试图从 \(i\) 前面最长的回文串的位置开始查找,跳向使得 \([j+1,i-1]\) 是一个新的回文串的 \(j\) 的位置,直到发现 \(j=i\)。
这个操作,就是后缀链跳跃。
奇偶字典树
类似于 AC 自动机的 \(\text{fail}\) 标记,上面所说的后缀链也应当有一个标记,同时在字典树上进行。我们将 PAM 上的这个后缀链也叫做 \(\text{fail}\)。说到字典树,因为要体现回文串之间的包含关系,而字典树的一段路径刚好对应一个子串,所以用字典树是合适的。但因为回文串分奇偶两种,所以需要建两个字典树,分别为奇字典树和偶字典树,根分别设置为 \(0\) 和 \(1\)。
字典树上的每个节点的回文串实际上是从它到根再到它这么一个串。每个点需要维护的是这个回文串的长度,以及在字典树上的父亲和儿子。
建立 PAM 是一个动态的过程,考虑到每一个节点代表的回文串一定是某一个位置为结束位置的最长回文串,所以每加入一个字符 \(c\),都可以沿着 \(c\) 在字典树上父亲的 \(\text{fail}\) 标记跳下去,直到满足上文所说的回文性质。对于长度,容易发现 PAM 上儿子的回文串长度是父亲的回文串长度加 \(2\)。
最后,对于初始状态来说,我们让偶字典树的根的 \(\text{fail}\) 指针指向奇字典树的根,因为作为空串的长度为奇数的字符串长度可以看作 \(-1\),是长度为 \(0\) 的字符串的后缀。可以认为奇字典树没有 \(\text{fail}\) 指针,因为每一个字符都是一个回文串,所以即使根没有 \(\text{fail}\) 指针也不会失配。
代码(P5496 【模板】回文自动机(PAM))
#include<bits/stdc++.h>
using namespace std;constexpr int MAXN=5e5+5;
string s;
int n,tot=1,lst;
struct PAM{int len,fail,dep,s[26];
}p[MAXN];// 背板!
void init(){p[0].fail=1;p[1].len=-1;
}
int gtf(int x,int i){while(i-p[x].len-1<0||s[i-p[x].len-1]!=s[i]) x=p[x].fail;return x;
}
void ins(int i){int pos=gtf(lst,i),ch=s[i]-'a';if(!p[pos].s[ch]){p[++tot].fail=p[gtf(p[pos].fail,i)].s[ch];p[tot].len=p[pos].len+2;p[tot].dep=p[p[tot].fail].dep+1;p[pos].s[ch]=tot;}lst=p[pos].s[ch];
}int main(){cin.tie(nullptr)->sync_with_stdio(0);cin>>s;n=s.size();init();for(int i=0,ans=0;i<n;i++){s[i]=(s[i]-97+ans)%26+97;ins(i);cout<<(ans=p[lst].dep)<<' ';}cout<<'\n';return 0;
}
复杂度
回文自动机中最耗时的操作是跳 \(\text{fail}\) 指针,这个操作是跳跃进行的,因为可以看作深度每次减少然后加 \(1\) 的过程,最多有 \(N\) 次加操作和 \(N\) 次减操作,所以复杂度还是 \(O(N)\) 的。
应用
结尾回文串个数
题意:给出一个字符串 \(S\),求以每个位置为结尾的回文串个数。
典型例题:P5496 【模板】回文自动机(PAM)、P3649 [APIO2014] 回文串。
注意到我们上面维护的 \(\mathit{dep}\) 值,这个值记录的是每个节点在回文树上的深度,由回文自动机的定义,从它开始跳 \(\text{fail}\) 指针直到根的节点个数就是答案。
所以每插入一个字符输出 p[lst].dep
即可。
双倍回文子串
题意:求一个字符串的最长双倍回文子串长度。
所谓双倍回文,指的是由两个回文串拼起来得到的串,不同题目里的定义各略有不同。
典型例题:P4287 [SHOI2011] 双倍回文,[HDU6599] I Love Palindrome String。
我们已经有了一个 \(\text{fail}\) 指针,现在要新增一个 \(\text{trans}\) 指针,意义为:该节点对应的回文串最长的、满足长度不超过该回文串 \(\boldsymbol{1/2}\) 的后缀回文串对应的节点。相当于在 \(\text{fail}\) 的基础上增加了一个长度限制。
求 \(\text{trans}\) 的方式和求 \(\text{fail}\) 是类似的。
int gtf(int x,int i){while(i-p[x].len-1<0||s[i-p[x].len-1]!=s[i]) x=p[x].fail;return x;
}
int gts(int x,int i,int len){while(x!=1&&(s[i-p[x].len-1]!=s[i]||p[x].len+2>(len+1)>>1)) x=p[x].fail;return x;
}
void ins(int i){int pos=gtf(lst,i),ch=s[i]-'a';if(!p[pos].s[ch]){p[++tot].fail=p[gtf(p[pos].fail,i)].s[ch];p[pos].s[ch]=tot;p[tot].len=p[pos].len+2;p[tot].trans=p[gts(p[pos].trans,i,p[tot].len)].s[ch];}lst=p[pos].s[ch];p[lst].ans++;
}
那么有了这个 \(\text{trans}\) 指针,我们在建立 PAM 之后挨个检查每个节点是否满足条件就方便很多了。以上面例题的第一道双倍回文为例,我们只需检查每个节点是否满足 \(\text{len}(\text{trans}(i))\times2=\text{len}(i)\land \text{len}(i)\bmod4=0\) 即可。
另外,PAM 还可以辅助做一些 DP 问题,比如 P4762 [CERC2014] Virus synthesis。它的本质还是利用 \(\text{trans}\) 数组找出转移点,再结合情况分类讨论,需要具体问题具体分析。