回文自动机学习笔记

最近学了回文自动机这个神奇的东西,写篇文章加深下印象,如有不足或错误,欢迎指出。

墙裂建议自行画图加深理解。

回文串的定义点

问题引入

给定一个字符串 s,求以 s 每个位置结尾的回文子串个数。

如果用暴力枚举每个串的方式,时间复杂度可能是 O(n2) 或者更高,主要是花在寻找以新位置结尾的回文串的重新枚举上。

回文串有个显然的性质,即去掉相同长度的前缀与后缀仍是回文串,利用这种包含关系,也许可以提高计算的效率。

回文自动机利用了这个性质,可以 O(n) 处理求本质不同回文子串个数的这类回文串题目。

基本概念

回文自动机,即 Palindrome Automaton(PAM),又名回文树。

回文自动机由两颗树构成,分别有奇根和偶根,对应着长度为奇数和偶数的回文串,奇根编号是 1,偶根编号是 0

在回文自动机中,包含了所有的回文串,每个节点表示一个回文串,其信息存到边上,维护 last 表示以上次插入的字符结尾的最长回文串对应节点,last 初始值为 1。对于点 x,维护 lenx,表示 x 代表的回文串长度。用 chx,Σ 表示 x 每条出边对应的儿子,类似 Trie。

特别地,len0=0,len1=1,即偶根长度为 0,奇根长度为 1。奇根长度 1 的妙处后文会提到。

以回文串 abaabba 为例,下图即为仅含回文树边的回文自动机。

某个点代表的回文串在图上的表示,是从奇根或偶根开始,经过一条转移边,即在回文串的前后各加一个相同的字符,这样对于回文串的表示显然是有正确性的。特别地,奇根所在树的节点回文中心的字符仅加一次。如路径 134 表示的回文串是 aba,路径 056 表示的回文串是 baab

因为每次转移会使其表示的回文串前后各扩展一个相同的字符,使长度加 2,所以对于点 x,若其父节点为 fa,则 lenx=lenfa+2

和其他自动机一样,回文自动机也有 fail 指针,对于点 xfailx 指向点 x 代表的回文串最长的回文真后缀所对应节点,特别地,fail0=1,fail1=1。很多题解都说奇根的 fail 指针没有意义,但在实现中指向自身要好处理。

还是回文串 abaabba,下图即其完整的回文自动机。

构造方法

对于字符串 s,若已构造好前 i1 个字符的回文自动机,加入字符 si,从 last 表示的回文串开始,然后不断跳 fail 边,直到找到某个节点 x,使 silenx1=si,即该节点表示的回文串上一个字符与待添加字符相同时,silenx1si 所构成的回文串即为以 i 结尾的最长回文串。

定义一个 getfail 函数,getfail(x,i) 返回的即为加入 si 后满足上述条件的点 x

int getfail(int x, int i) {
    while(i - len[x] - 1 < 0/*判断边界*/ || s[i - len[x] - 1] != s[i]) x = fail[x];
    return x;
}

这个正确性证明很显然,因为以 i 结尾的长度不为 1 的回文串,必定包含 si1,又因为回文串去掉相同长度的前后缀仍是回文串,所以若以 i 结尾的最长回文串长度不为 1,必定包含以 i1 结尾的回文串,可以通过 fail 指针找到所有以 i1 结尾的回文串。

i 结尾的回文串中必有长度为 1 的回文串,即 si 单独成串,此时不包含 si1,需单独处理。前文提到 len1=1,所以 silen11=si,即 fail 指针指向奇根时,奇根会让下一次匹配到自身,显然符合,又因为所有节点的 fail 指针都直接或间接指向奇根,也就保证了必定能找到满足条件的点 x,且 i1 结尾的回文串均可通过 fail 指针找到,getfail 的正确性得证。

x 现在有一条表示 si 的出边,即已存在与 i 结尾的最长回文串本质相同的节点,直接更新 last 就好。若没有,则新建一个,再求它的 fail 指针。还是先找到 last 表示的回文串中满足两边扩展 si 仍是回文串的回文后缀,发现和上文的求法是类似的,可以用 getfail(failx,i) 来求出其满足条件的回文后缀所对应的节点。

插入 si 的代码如下:

void insert(char c, int i) {
    int x = getfail(last, i), w = c - 'a';
    if(!ch[x][w]) {
        len[++cnt] = len[x] + 2;
        int tmp = getfail(fail[x], i);
        fail[cnt] = ch[tmp][w];
        //things to do
        ch[x][w] = cnt;
    }
    last = ch[x][w];
}

注意设定父子关系是最后处理,原因是当 chtmp,w 不存在时,即新建的节点的 fail 未匹配到,因 chtwp,w 初值为 0failcnt 会被设为 0,即新建的节点 fail 指针指向偶根,len0=0,空串显然可行。

复杂度证明

证明过程和 KMP 很相似。

可以证明,对于一个字符串 s,它的本质不同回文子串个数最多只有 |s|个(然而我不会,网上应该都有)。

因为回文自动机上每个节点代表的回文串均不同且包含了 s 所有的回文串,所以最多有 |s| 个节点,令 n=|s|

若不计 getfail 复杂度,其它操作复杂度 O(n) 比较显然。

因为 fail 指向的是回文真后缀,所以长度必更小,跳 fail 边深度单调非递增,且同一深度最多跳两次 fail 边(奇根所在树与偶根所在树),因每次新建节点 fail 指针指向节点深度只会加 1,所以最多可跳 2nfail 边。

总时间复杂度 O(n),空间复杂度为 O(nΣ)

例题

模板题为例。

sumx 表示答案,显然以 i 结尾的回文串个数即以 i 结尾的最长回文串的最长回文真后缀的回文串个数,即 sumx=sumfailx+1

代码如下:

#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10, Sigma = 26;
char s[N];
int lastans, n;
struct Palindrome_Automaton {
    int ch[N][Sigma], fail[N], len[N], sum[N], cnt, last;
    Palindrome_Automaton() {
        cnt = 1;
        fail[0] = 1, fail[1] = 1, len[1] = -1;
    }
    int getfail(int x, int i) {
        while(i - len[x] - 1 < 0 || s[i - len[x] - 1] != s[i]) x = fail[x];
        return x;
    }
    void insert(char c, int i) {
        int x = getfail(last, i), w = c - 'a';
        if(!ch[x][w]) {
            len[++cnt] = len[x] + 2;
            int tmp = getfail(fail[x], i);
            fail[cnt] = ch[tmp][w];
            sum[cnt] = sum[fail[cnt]] + 1;
            ch[x][w] = cnt;
        }
        last = ch[x][w];
    }

} PAM;
int main() {
    scanf("%s", s + 1);
    int len = strlen(s + 1);
    for(n = 1; n <= len; n++) {
        s[n] = (s[n] - 97 + lastans) % 26 + 97;
        PAM.insert(s[n], n);
        printf("%d ", lastans = PAM.sum[PAM.last]);
    }
    return 0;
}

还有一些简单题:

P4555 [国家集训队]最长双回文串

P4287 [SHOI2011]双倍回文

P3649 [APIO2014]回文串

posted @   Terac  阅读(12)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示