回文自动机学习笔记

回文自动机学习笔记

定义

所谓自动机,是一个对信号序列进行判定的数学模型。即对一连串有顺序的信号关于某一个判定给出或真或假的判定。

所谓回文自动机,就是对一个字符串进行其是否为回文串的判定。也就是存储字符串 \(s\) 中的所有的回文串。与 \(\text{Manacher}\) 不同的是,\(\text{Manacher}\) 存储的是以每一个位置为对称中心的回文串共有几个,而 \(\text{PAM}\) 存储的则是以每一个位置为结束位置的最长的回文串,通过建立 \(\text{fail}\) 树从而进行统计。

实现

首先考虑回文串有长度为奇和长度为偶两种不同类型,因此我们的回文自动机也需要将两种状态分开存储。因此我们的自动机有两个根,一个奇根一个偶根。顾名思义,奇根所连接的都是长度为奇数的回文串,偶根所连接的都是长度为偶数的回文串。因为回文串有对称的特性,所以只需要存储一半的内容即可。因为后加入的在 \(\text{PAM}\) 上的深度会更深,因此我们相当于存储了每个回文串的后一半内容。我们也可以看作每一个节点代表了一个回文子串。

接着考虑 \(\text{fail}\) 树的建立,因为 \(\text{PAM}\) 存储了每一个位置为结束位置的最长回文串,如果需要知道字符串 \(s\) 中的所有回文子串,我们便有必要去求解所有以某一个位置为结束位置的所有回文串的个数。因此我们令 \(\text{fail}\) 指针指向当前回文串的最长回文后缀。因为 \(\text{PAM}\) 只存储本质不同的回文子串(即相同内容的回文串被合并成了一个点),因此 \(\text{fail}\) 指针所指向的子串所对应的节点是唯一的。

建立 \(\text{PAM}\) 是一个动态的过程,即我们需要一个个加入字符去更新 \(\text{PAM}\) 的状态。考虑怎么求解每一个点在 \(\text{PAM}\) 上的位置,考虑到每一个节点代表的回文串一定是某一个位置为结束位置的最长回文串,因此我们可以将此时需要插入的最终点的状态一定可以表示为 \(c\dots c\)\(c\) 表示新插入的字符。此时要求 \(\dots\) 部分是一个回文串,因为它的终止位置是前一位,假设枚举到第 \(i\) 位,我们需要求解结束位置为 \(i-1\) 并且起始位置前一位为 \(c\) 的最长的回文串,我们发现这个可以通过跳 \(\text{fail}\) 指针求解。因为这样的两个点的后半段是前后缀关系,并且长度只差 \(1\),于是这两个点在 \(\text{PAM}\) 上是父子关系。于是我们知道了这个点在 \(\text{PAM}\) 上的位置。接着我们考虑 \(\text{fail}\) 指针,我们发现这只需要从之前的节点的 \(\text{fail}\) 指针开始继续向下跳下去即可。

除此之外还有一些常见的需要维护的信息:节点代表的回文串的长度。我们能够发现,一个节点的长度等于它在 \(\text{PAM}\) 上的父亲节点的长度加 \(2\)

对于初始状态来说,我们让偶根的 \(\text{fail}\) 指针指向奇根,因为作为空串的长度为奇数的字符串的长度为 \(-1\),是长度为 \(0\) 的长度为偶数的空串的后缀。而我们可以认为奇根没有 \(\text{fail}\) 指针,因为每一个字符一定都是一个回文串,所以奇根的时候不会失配。事实上,偶根的深度并不重要,因为并不会有节点的 \(\text{fail}\) 指针指向偶根。

复杂度是线性的。事实上,整个代码唯一可能不是线性的地方在于暴力跳转 \(\text{fail}\) 指针,现在解释为什么跳转 \(\text{fail}\) 指针是线性的。考虑当前的 \(\text{fail}\) 树,我们相当于从一个节点开始,不断跳至它的父亲节点直到符合条件,此时在这个节点下新增一个儿子节点,也就是说,每次深度的变化是不断减少接着加 \(1\) 的过程。将加减顺序调整后可以得到总共会进行 \(n\) 次加操作和最多 \(n\) 次减操作,因此复杂度是 \(O(n+n)=O(2n)=O(n)\) 的。

由此我们可以得到代码:

代码

struct PAM{
    int len,fail,son[26];
}p[N+5];//结构体定义

void Init(){
    p[1].len=-1;
    tot=lst=1;
    return ;
}//初始化

int GetFail(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 Insert(int i){
    int pos=GetFail(lst,i),ch=s[i]-'a';//找到 PAM 上的父亲节点
    if(p[pos].son[ch]==0){
        p[++tot].fail=p[GetFail(p[pos].fail,i)].son[ch];//找到 fail 树上的父亲节点
        p[pos].son[ch]=tot;
        p[tot].len=p[pos].len+2;//维护节点信息
    }
    lst=p[pos].son[ch];//记录上一个位置的最长回文子串对应的节点编号
    return ;
}

应用

结尾回文串个数

这种问题要求求解以每一个位置为结尾的回文串个数,我们发现,每次我们找到下一个长度更小的后缀回文串就是在 \(\text{fail}\) 树上一直向上跳,直到跳到奇根,那么这就说明答案即为每个位置的最长回文后缀对应的节点在 \(\text{fail}\) 树上的深度,我们新增一个信息进行维护即可。

下面是加入 \(\text{fail}\) 树深度后的回文自动机添加节点的代码。

代码

void Insert(int i){
    int pos=GetFail(lst,i),ch=s[i]-'a';
    if(p[pos].son[ch]==0){
        p[++tot].fail=p[GetFail(p[pos].fail,i)].son[ch];
        p[pos].son[ch]=tot;
        p[tot].len=p[pos].len+2;
        p[tot].dep=p[p[tot].fail].dep+1;
    }
    lst=p[pos].son[ch];
    return ;
}

双倍回文串

双倍回文串是回文自动机题目中最常见的题型,其通常表现为 \(s\) 是回文串,且 \(s\) 的某个长度范围内的前缀也是回文串。此时我们通常通过添加一个 \(\text{defail}\) 指针来解决问题。

求解 \(\text{defail}\) 指针时,我们需要先行判断当前节点的 \(\text{fail}\) 指针是否符合条件。如果不符合条件,那么我们就可以先找到一个可能符合条件的节点:当前节点在 \(\text{PAM}\) 上的父亲节点的 \(\text{defail}\) 指针,此时,我们找到的是一个尽可能小的以当前位置的前一位结尾的回文串。那么我们只需要继续在 \(\text{fail}\) 树上向上跳,直到找到符合条件的回文串,将 \(\text{defail}\)​​ 指针指向对应位置即可。

下面是加入 \(\text{defail}\) 之后的回文自动机添加节点的代码。

代码

void Insert(int i){
    int pos=GetFail(lst,i),ch=s[i]-'a';
    if(p[pos].son[ch]==0){
        p[++tot].fail=p[GetFail(p[pos].fail,i)].son[ch];
        p[pos].son[ch]=tot;
        p[tot].len=p[pos].len+2;
        if(p[p[tot].fail].len<=p[tot].len/2)p[tot].defail=p[tot].fail;
        else{
            int cnr=p[pos].defail;
            while(i-p[cnr].len-1<0||p[cnr].len+2>p[tot].len/2||s[i]!=s[i-p[cnr].len-1])cnr=p[cnr].fail;
            p[tot].defail=p[cnr].son[ch];
        }
    lst=p[pos].son[ch];
    return ;
}
posted @ 2024-03-12 21:55  DycIsMyName  阅读(10)  评论(0编辑  收藏  举报