回文自动机(PAM)学习&练习笔记
好久没写博客了喵,其实是我最近太颓了,啥都没干。于是决定学点新东西防颓废。
学习笔记
感觉 PAM 对有 SAM 基础的人会很友好,你会发现这个东西比 SAM 好理解多了。
前置结论
一个字符串本质不同的回文串是 \(O(n)\) 级别的。
设原来有一个字符串 \(S\) (黑色部分),往后添加了一个字符 \(c\) (红色部分)。
我们假设蓝色字符与红色字符之间的字符串回文,且这个是以红色字符结尾的最长的回文串。
如果存在一个更短的回文串,比如绿色到红色之间的字符串。那么一定有:两个紫色区间的串相等,且都回文。
也就是说,每新加入一个字符至多产生一个新的本质不同的回文串。
PAM 的结构
-
有两个根,一个是奇根,一个是偶根,奇根底下的点表示的是长度为奇数的点,偶根底下表示的是长度为偶数的点。
-
和 trie 很像,但是每一个节点存的是一个回文串,表示的是从这个节点一直爬到根再回来的这个串。比如到奇根的路径是
abcd
,那么这个节点表示的就是abcdcba
。
建立 PAM
一般采用增量法构造。
维护的值
注意,以下用
fa
表示 trie 树上的父亲。
-
tr[N][S]
:trie 上的转移边。 -
fail[N]
:指向每一个节点除自己外最长的回文后缀。为什么要维护这个呢?可以看看上面那个前置结论,可能会明白了。 -
len[N]
: 每一个节点最长的回文串长度,根据 PAM 形态的定义,显然有 \(len(u)=len(fa(u))+2\) 。
构造方法
首先建立两个根:奇根,偶根。
我们考虑把偶根的 \(len\) 设为 \(0\) ,奇根的 \(len\) 设为 \(-1\) ,偶根的 \(fail\) 指向奇根。
\(len\) 这么开的原因很简单:\(len(u)=len(fa(u))+2\) ,偶根表示空串,而奇根底下的 \(len\) 会从 \(1\) 开始,方便很多。
\(fail\) 为什么这么开看完下面就懂了。
设我们要插入 \(S_i\)。
设 \(S_{i-1}\) 所在的节点为 las
,我们通过 las
以及它的 fail
等信息来找出当前插入的字符的位置。
考虑怎么找这个节点在 trie 上的父亲。
首先得要保证这个节点回文。
如果 \(S_{x,i}\) 回文,那么 \(S_{x+1,i-1}\) 也回文,所以我们直接遍历 \(las\) 所有的 fail
,找到最长的一个“合法”的回文串。
怎样的回文串合法呢?显然是两边都是 \(S_i\) 这个字符,也就是说,满足 \(S_{i-1-len(x)}=S_i\) 的节点 \(x\) 是我们要找的。不满足就跳 \(fail\) 。
会不会永远跳下去呢?这就是奇根 \(len=-1\) 的妙处了!发现沿着 \(fail\) 跳,总能跳到奇根上(这个也是偶根指向奇根的原因),因为必定有 \(S_{i-1+1}=S_i\) ,所以总会停止的。
于是我们找到了这个节点在 trie 树上的父亲 \(f\) ,tr[f][c]
就是这个节点。
还要维护 \(fail\) 。
因为 \(fail\) 不能指向自己,相当于指向次短的回文串,那么从 fail[f]
开始往上跳到一个“合法”的节点即可。
不要忘记更新 \(len(u)=len(f)+2\) 以及 \(las=u\),PAM就建完啦!
时间复杂度
发现每次 \(las\) 深度会增加 \(1\) ,每跳一次深度就会减少 \(1\) ,所以是线性的!
另一个有用的信息
这个做题很常用,trans
指针,指向 \(len\) 不大于当前节点 \(len\) 一半的节点。
维护方法和 \(fail\) 很像,从 \(trans(f)\) 开始不断跳 \(fail\) ,跳到满足 \(2*(len(x)+2)\le len(u)\) 并且 \(S_{i-len(x)-1}=S_i\) 的节点为止。
注意这里要加二!因为 trans
指向的应该是 \(x\) 的孩子。
还得注意特判 \(len(u)\le 2\) 的情况,这时候直接 \(trans(u)=fail(u)\) 即可。
复杂度证明和 \(fail\) 很像,还是线性的。
附上我第一代 PAM 板子。根据经验,板子会随时间变化而大型改变。。。
inline int getfail(int x,int i){
while(i-len[x]-1<0||str[i]!=str[i-len[x]-1])x=fail[x];
return x;
}
inline int gettrans(int x,int i,int lim){
while(2*(len[x]+2)>lim||str[i]!=str[i-len[x]-1])x=fail[x];
return x;
}
void build(char*str,int n){
len[0]=0,len[1]=-1,fail[1]=0,tot=1,las=0;
for(int i=0;i<n;++i){
int c=str[i]-'a',f=getfail(las,i);
if(!tr[f][c]){
fail[++tot]=tr[getfail(fail[f],i)][c];
tr[f][c]=tot;
len[tot]=len[f]+2;
if(len[tot]<=2)trans[tot]=fail[tot];
else trans[tot]=tr[gettrans(trans[f],i,len[tot])][c];
}
las=tr[f][c];
}
}
练习笔记
比 SAM 轻松多了!!! 没有大型DS,没有很长的代码,建 PAM 也比建 SAM 小清新的多!
容易发现,以 \(i\) 结尾的回文子串数量恰好为它所在节点在 fail 树上的深度,在新建节点的时候顺便维护深度即可。
找到 \(len(trans(u))\) 为偶数且 \(len(trans(u))*2=len(u)\) 的 \(u\) ,取最大的 \(len(u)\) 即可。
维护以 \(i\) 结尾的最长回文串长度 \(a_i\),反过来再做一遍以 \(i\) 结尾的最长回文串长度 \(b_i\),取 \(\max\{a_i+b_{i+1} \}\) 即可。
直接建个 PAM, 提取所有 \(len\) 为奇数的节点,按照 \(len\) 降序排序。再同时维护每一个串的出现次数,这在 fail 树上打标记即可轻松维护,不会建议重修 ACAM 和 SAM。比 SAM 小清新的是,PAM 节点编号就是拓扑序,不用拓扑排序了。
刚看完题吓我一跳,如果可以建出 “广义PAM” ,对两个串分别打标记,在 fail 树上跑出每一个节点的回文串在两个串的出现次数,找到都出现的,取 \(\max\{len \}\) 不就完事了么?后来一想,广义PAM不比广义SAM好建的多?直接 \(las\) 设为 \(0\) 再跑一遍就好了,不会像广义SAM一样假掉。
上一题的双倍经验,改成每一个节点两个串出现次数相乘即可。
这题在当年应该不简单吧,SAM+倍增+manacher,虽然也不难想。但是PAM出来了之后,这个比模板还模板啊!!!
好题!
首先我们发现答案一定是经过一堆以二操作结尾的操作序列,然后不断一操作得到的。
考虑先建 PAM,设 \(dp(u)\) 表示变成 \(u\) 这个节点代表的回文串的最小步数,那么答案就是 \(\min\{dp(u)+n-len(u) \}\)。
考虑怎么转移。我们发现能对答案产生贡献的串的长度一定是偶数(二操作完必然是偶数),所以我们把偶根丢进队列广搜偶树。通过一操作转移到这个节点:\(dp(u)=dp(fail(u))+1\) ,可以考虑它的 \(fa\) 先在末尾插入这个字符再执行二操作(显然偶树每一个节点最后二操作一下最优)。
通过二操作转移到这个节点:\(dp(u)=dp(trans(u))+1+\dfrac{len(u)}{2}-len(trans(u))\) 。先用一操作填满到根的路径,再用一次二操作。
要看我的实现的话,可以到码库里面找~