Manacher 和 回文自动机

引入

求串 \(s\) 中的回文子串数量。\(|s|\le 10^7\)

做法

定义一个长为 \(2k-1(k\in N)\) 的回文串 \(s\)回文中心\(s_k\)。则子串 \(s_2\sim s_{2k-2}\)\(s_3\sim s_{2k-3}\),一直到 \(s_k\) 均为回文串,回文中心也均是 \(s_k\)。对于任意一个字符 \(s_b\),若 \(s_{b-c}\sim s_{b+c}\) 非回文串,则 \(\forall p\ge c\)\(s_{b-p}\sim s_{b+p}\) 也不会是回文串。故我们对于串的任意一个字符 \(s_p\),求 \(\max_{k=0} (1+k\prod_{i=0}^k[s_{p+i}=s_{p-i}])\)(也就是以 \(s_p\) 为回文中心的最长回文串的长度的一半),即为 \(d_p\),则所有奇回文串的长度/数量之和即为 \(\sum d_p\)

对于偶回文串,直接在原串中每两个相邻的字符之间加上间隔符,以间隔符为回文中心的长度大于一的奇回文串就对应了原串中的偶回文串。注意这样后原串中的每个回文串均被计算了两遍,需要将 \(\sum d\) 除以 \(2\)

不使用下述算法的情况下,一种简单的方法是 hash + 二分,单次处理一个 \(d\) 值的时间复杂度为 \(O(\log |s|)\)

Manacher 算法

原理

为了方便,我们按照上述方式处理原串,在原串中每两个相邻的字符之间加上间隔符,这样就只用考虑奇回文串。令处理后的串长为 \(n\)

考虑某个性质:在一个回文串中,若某个子串是回文串,则对称位置长度相同的子串也是回文串。由此可得:若某个回文串为 \(s_l\sim s_r\),回文中心为 \(s_p\),且 \(s_{p-i-d_{p-i}+1}\sim s_{p-i+d_{p-i}-1}\) 被完全包含在 \(s_{l+1}\sim s_{r-1}\) 内,记 \(rev(s)\)\(s\) 的反串,则

\[\begin{aligned}&{\color{white}=\ }s_{p-i-d_{p-i}+1}\sim s_{p-i+d_{p-i}-1}\\&=rev(s_{p-i-d_{p-i}+1}\sim s_{p-i+d_{p-i}-1})\\&=rev(rev(s_{p+i-d_{p-i}+1}\sim s_{p+i-d_{p-i}-1}))\\&=s_{p+i-d_{p-i}+1}\sim s_{p+i-d_{p-i}-1}\end{aligned} \]

且由于 \(s_{p-i-d_{p-i}}\ne s_{p-i+d_{p-i}}\)\(s_{p+i+d_{p-i}}\ne s_{p+i-d_{p-i}}\),故 \(d_{p+i}=d_{p-i}\)

故我们可以维护目前由 \(d_1\sim d_{i-1}\) 得出的右端最靠右的回文子串 \(s_l\sim s_r\),以此从 \(d_1\sim d_{i-1}\) 推出 \(d_i\)。记 \(r(i)=l+r-i\),分类讨论一下各种情况:

  • \(i>r\)。此时显然只能暴力处理。
  • \(s_{r(i)-d_{r(i)}+1}>l\)。由于 \(\frac{l+r}2<i\),此时显然有 \(s_{r(i)-d_{r(i)}+1}<r\)。按上述的方式处理。
  • \(s_{r(i)-d_{r(i)}+1}\le l\)。此时不能直接得出 \(d_i\),但是由于 \(s_{r(i)-d_{r(i)}+1}\sim s_{r(i)+d_{r(i)}-1}\) 的子串 \(s_l\sim s_{2r(i)-l}\) 是回文串,我们可以得出 \(d_i\) 的下界为 \(r(i)-l+1\)。在这个下界的基础上,继续暴力处理即可。

综上,我们就得到了 Manacher 算法的全流程。同时由于第一种和第三种情况的暴力处理 \(x\) 位,新的 \(r\) 就会增加 \(x\),且处理到最后一位时的 \(r\) 显然是 \(n\),故整个算法的时间复杂度是 \(O(n)\) 的。同时这种方法较其他方法常数小得多,理解和操作也很简单。

代码实现中,\(d_p\)\(\max_{k=0} k\prod_{i=0}^k[s_{p+i}=s_{p-i}]\),但原理与上述相同。

模板代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
char ch,p[22000030];
int d[22000030],i,l,r=-1,k,mx,siz=1;
bool o;
int main(){
    p[1]='T';
    for(;;){
        ch=getchar();
        if(ch<'a'||ch>'z') break;
        p[++siz]=ch;
        p[++siz]='M';
    }
    p[siz]='D';
    for(i=1;i<=siz;++i){
        if(i>r) k=1;
        else k=min(d[l+r-i],r-i+1);
        while(i-k>=0&&i+k<siz&&p[i-k]==p[i+k]) ++k;
        d[i]=k--;if(i+k>r){l=i-k;r=i+k;}
    }
    for(i=1;i<=siz;++i){
        o=!((i==d[i]+1)||(i+d[i]==siz));
        if(mx<d[i]-o) mx=d[i]-o;
    }
    printf("%d\n",mx);
    return 0;
}

引入

给定一个空串 \(s\),每次在 \(s\) 的末尾加入一个字符,询问末尾为 \(s\) 末尾的回文子串个数。强制在线。保证最后 \(s\) 的总长度不超过 \(5\times 10^5\)

回文树/回文自动机(PAM,Palindromic Tree/Palindromic AutoMaton)

特点

同 Manacher 对字符串处理,只考虑奇回文串不同,回文树同时处理长度为奇数和偶数的回文串,分别用两棵树维护。由于从根节点出发可以遍历到原串所有的本质不同的回文子串,故回文树又称回文自动机。

构造

同 SAM 一样,构造 PAM 时我们仍然可以采用增量法,依次插入原串的每一个字符。然而 PAM 本质上是两棵树,每个节点均只代表一个回文子串。

下面令 \(len(p)\) 表示 \(p\) 节点对应的回文子串长度,\(fail(p)\) 表示 \(p\) 节点对应的回文子串最长回文后缀对应的节点编号,\(son(p,c)\) 表示在 \(p\) 节点对应的回文子串 两边 加上字符 \(c\) 形成的回文子串对应的编号。

引理:

? 每次在当前字符串后添加一个字符,若新增了回文串,则新增的本质不同的回文串最多只有只有一个;若有,就是添加字符后的字符串的最长回文后缀。

? 证明:这个最长回文后缀的任意回文后缀一定会与其某个回文前缀对称,而这个回文前缀一定是原串的回文子串,在 PAM 内出现过。

同时这证明了 PAM 的点数为 \(O(n)\) 级别的。这也证明了一个字符串的所有本质不同的回文子串的数量为 \(O(n)\) 的。由于 PAM 为两棵树的形态,故边数也是 \(O(n)\) 的。

具体地,最开始时没有插入字符,只有两个根节点(令奇回文串的根节点为 \(t_1\),偶回文串的根节点为 \(t_0\))。由于保证插入字符合法,故 \(len(t_1)=-1\)\(len(t_0)=0\)。为何方便后面的操作,我们令 \(fail(t_1)=t_0\)\(fail(t_0)=t_1\)

\(last\) 为插入上一个字符之后,最长回文后缀的编号,\(last\) 开始时可以为 \(t_0\)\(t_1\)。注意回文串两边删除一个字符后仍然是回文串(或是空串)。设当前插入的是字符串 \(s\) 的第 \(k\) 个字符 \(c\),则我们需要从 \(last\) 开始不断跳 fail 直到找到第一个 \(p\) 满足 \(s_{k-len(p)-1}=c\)

\(son(p,c)\) 存在,则这个最长后缀已经出现在之前的部分中。否则首先需要新建节点 \(q\) 代表这个新的最长回文后缀,然后将 \(son(p,c)\) 赋为 \(q\),最后从 \(p\) 开始不断跳 \(fail\) 直到跳到第一个节点 \(p'\) 满足 \(s_{k-len_{p'}-1}=c\)(注意条件不是存在 \(son(p',c)\),可能这个回文串在其他位置出现时两边有 \(c\),如字符串 \(ccbbc\)),然后将 \(fail(q)\) 设为 \(son(p',c)\)。若直到跳到 \(t_0\) 再跳到 \(t_1\) 都没有找到 \(p'\),则将 \(fail(q)\) 设为空串对应的节点 \(t_0\)

此时将 \(fail(t_1)\) 设为 \(t_0\),将 \(fail(t_0)\) 设为 \(t_1\) 的用途就体现出来了:若插入字符 \(c\) 之前的串形如为 \(c+A+c+B\)(其中 \(B\) 即为 \(p\) 代表的偶回文串,且不包含字符 \(c\)),则最后 \(fail(q)\) 应该为 \(son(t_1,c)\),而 \(p\) 原本以 \(t_0\) 为根节点,若无 \(fail(t_0)=t_1\) 就难以实现较为简单的判别。而若原串为 \(A+c\) 的形式(其中 \(A\) 未出现过 \(c\)\(A+c\) 为奇回文串),则最后我们需要将 \(son(t_0,c)\) 赋为 \(q\),最后一步需要从 \(t_1\) 跳到 \(t_0\)。像这样的特例还有许多,这样做明显更简洁地处理了这些特例。

时间复杂度

考虑 \(len(fail(last))\) 的变化。可以发现每一次跳 fail 指针均会使当前的节点的 \(len\) 减少,而最后 \(len(fail(last))\) 一定小于 \(n\),故时间复杂度均摊下来是 \(O(n)\) 的。和 SAM 的时间复杂度证明很像。

(辟谣:CF 上的 一篇博客 说时间复杂度为 \(O(n\log\Sigma)\),其中 \(\Sigma\) 是字符集大小,因为用到了主席树维护转移边)

模板代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=500010;
int ch,p,q,len,cur,lst,tot,ans; 
char s[maxn];
struct PAM_Node{
    int fail,len,dep;
    int nxt[26];
}N[maxn];
#define len(p) N[p].len
#define dep(p) N[p].dep
#define nxt(p) N[p].nxt
#define fail(p) N[p].fail
int main(){
    N[0].fail=tot=1;
    N[0].len=-1;
    for(;;){
        ch=getchar();
        if(ch<'a') return 0;
        ch=(ch-97+ans)%26;
        s[++len]=ch+97;
        p=lst;
        while(s[len-len(p)-1]!=ch+'a') p=fail(p);
        if(nxt(p)[ch]) lst=nxt(p)[ch];
        else{
            cur=++tot; len(cur)=len(p)+2;
            lst=p;p=fail(p);q=0;
            while((q!=1)&&(s[len-len(p)-1]!=ch+'a')){
                q=p;
                p=fail(p);
            }
            if(!nxt(p)[ch]) fail(cur)=1;
            else fail(cur)=nxt(p)[ch];
            dep(cur)=dep(fail(cur))+1;
            nxt(lst)[ch]=cur; lst=cur;
        }
        ans=dep(lst);
        printf("%d ",ans);
    }
    return 0; 
}

例题

初始有一个空串,利用下面的操作构造给定串 \(S\)

  1. 串开头或末尾加一个字符
  2. 串开头或末尾加一个该串的逆串

求最小化操作数,\(|S|\le 10^5\),字符集为 \(\{A,T,C,G\}\)

解法(暂缺)

代码(暂缺)

点此查看代码

posted @ 2022-10-10 14:32  Fran-Cen  阅读(45)  评论(0编辑  收藏  举报