AC 自动机

· · 算法·理论

网上详细讲 AC 自动机的太少了,于是我也来写一篇。

AC 自动机

Q\&A

Q1:AC 自动机是啥?

A1:它是专门用来解决优化 KMP 的多串匹配问题。

Q2:它怎么用?

A2:可以适用于解决多串匹配问题,似乎还可以加上 DP。

Q3:它有什么前置知识?

A3:KMP,Trie,自动机(大致了解即可)。

自动机

又叫做有限状态自动机,其实就是流程图,比如 OI WiKi 的一张图:

这就是一个自动机,可以由一个状态转移到下一个状态,所以可以加上前面说的 DP。

字典树

概念

什么你不了解字典树?

其实我也是零时学的。

它的本质其实是将一堆字符串放到一棵树上面节省空间和时间。

算法流程

OI-WiKi 的图片也是太好用了,可惜 GIF 不多。

建树部分

  1. 依次加入这个字符串的字母。
  2. 如果发现当前节点没有对应字母的路径新建一个节点对应的字母路径。
  3. 如果到头了记录这个节点为结尾。

:::info[模拟例子(注意节点顺序不与上图相符)] 当前例子的节点顺序是 DFS 序。

加入 aa(此例子展示字母全不在树上情况)。

节点一没有 a 的边,创建节点二,连接一条边。

节点二没有 a 的边,创建节点三,连接一条边,记录为结尾。

加入 aba(此例子展示字母部分在树上情况)。

节点一有 a 的边,前往节点二。

节点二没有 b 的边,创建节点四,连接一条边。

节点四没有 a 的边,创建节点五,连接一条边,记录为结尾。

省略一部分重复过程。

加入 cb(此例子展示字母全在树上情况)。

节点一有 c 的边,前往节点八。

节点八有 b 的边,前往节点十三,记录为结尾。

查询

和建树操作类似。

只不过遇到不存在的字母边或结尾没有标记时则没有当前字符串。

这里给出完整操作的代码:

void insert(string &s,int idx){
    int u=0;
    for(char ch:s){
        int c=ch-'a';
        if(!t[u][c])
            t[u][c]=++tot;
        u=t[u][c];
    }
}

void query(){
    int u=0;
    for(char ch:s)
        u=t[u][ch-'a'];
}

KMP

有两种方法,最让人大开眼界的方法在这:

KMP B 站视频

这个视频讲的很详细,我这里具体讲解第二种做法。

相信你已经在上面学会 nxt_i 也就是 \pi_i 是啥意思了,也就是最长相同真相同前后缀 (又臭又长)。

我们对于第二种做法就是暴力匹配,当指针匹配失败时就跳转到 nxt 数组里的位置。

这里给出两种办法的代码。

    cin>>A>>B,n=A.size(),m=B.size(),S=B+"#"+A;
    for(int i=1;i<=n+m+1;i++){
        int len=nxt[i-1];
        while(len!=0&&S[i]!=S[len])
            len=nxt[len-1];
        if(S[i]==S[len])
            nxt[i]=len+1;
    }
    for(int i=m+1;i<=n+m+1;i++)
        if(nxt[i]==m)
            cout<<i-2*m+1<<endl;
    for(int i=0;i<m;i++)
        cout<<nxt[i]<<' ';//No.1

    cin>>A>>B,n=A.size(),m=B.size(),A=' '+A,B=' '+B;
    for(int i=2,j=0;i<=m;i++){
        while(j!=0&&B[i]!=B[j+1])
            j=nxt[j];
        if(B[i]==B[j+1])
            j++;
        nxt[i]=j;
    }
    for(int i=1,j=0;i<=n;i++){
        while(j>0&&A[i]!=B[j+1])
            j=nxt[j];
        if(A[i]==B[j+1])
            j++;
        if(j==m)
            cout<<i-m+1<<endl;
    }
    for(int i=1;i<=m;i++)
        cout<<nxt[i]<<' ';//No.2

注意这是模板 KMP 的代码。

AC 自动机

说一句废话:AC 自动机就是字典树的结构加上 KMP 的思想。

我们首先把一堆待匹配的串全加入一棵字典树里面。

然后我们把他加上失配指针 fail_i。

最后按照 KMP 的方法进行匹配。

现在问题来了:

我们怎么去加上失配指针?

我们回看 KMP。

相信你已经在上面学会 nxt_i 也就是 \pi_i 是啥意思了,也就是最长相同真相同前后缀 (又臭又长)。

但是我们真的需要最长相同真相同前后缀吗?

不用,我们要求的是后缀相同的位置。

使用 OI WiKi 的原句:

不同点:next 指针求的是最长 Border(即最长的相同前后缀),而 fail 指针指向所有模式串的前缀中匹配当前状态的最长后缀。

我们考虑怎么求:

首先根节点的子节点的 fail 指针一定指向根。

然后按照 BFS 的顺序依次求解 fail 指针,这样可以保证 fail 指针指向的节点一定处理过了。

对于两种不同的情况进行处理:

将它儿子的 fail 指针设为它的 fail 指向节点的对应儿子,将它儿子加入队列。
将它儿子设为它的 fail 指向节点的对应儿子。

为何这样是对的?

因为它父亲的 fail 指针一定指向的是它父亲的匹配最佳的位置,所以我们只要把当前节点设为它父亲的 fail 指向节点的对应儿子即可。

可能会有点绕口看这张手绘图:

我们拿字符串 ab 中的 b 举例:

他父亲的 fail 指针是根节点,于是他就指向根节点的 儿子 b。

这下终于把所有东西讲解清楚了。

放一张 OI WiKi 的图片欣赏一下:

代码如下:

现在还没完!!!

我们发现他的时间照样不对于是我们要考虑优化。

不信你看:

:::info[code]

#include<bits/stdc++.h>

using namespace std;

typedef long long ll;

const ll N=2e5+1;

ll n,tr[N][26],fail[N],tot,ans[N],orz[N];
vector<int>End[N];
string s,t[N];

void insert(string s,ll idx){
    ll x=0;
    for(auto ch:s){
        ll t=ch-'a';
        if(!tr[x][t])
            tr[x][t]=++tot;
        x=tr[x][t];
    }
    End[x].push_back(idx);
}

void build(){
    queue<ll>q;
    for(ll i=0;i<26;i++)
        if(tr[0][i])
            q.push(tr[0][i]);
    while(!q.empty()){
        ll x=q.front();q.pop();
        for(ll i=0;i<26;i++)
            if(tr[x][i])
                fail[tr[x][i]]=tr[fail[x]][i],q.push(tr[x][i]);
            else
                tr[x][i]=tr[fail[x]][i];
    }
}

void query(){
    ll x=0;
    for(char i:s){
        x=tr[x][i-'a'];
        for(int j=x;j;j=fail[j])
            for(int k:End[j])
                ans[k]++;
    }
}

int main(){
    cin>>n;
    for(ll i=1;i<=n;i++)
        cin>>t[i],insert(t[i],i);
    build(),cin>>s,query();
    for(ll i=1;i<=n;i++)
        cout<<ans[i]<<endl;
}

:::

提交记录 68pts。

于是我们考虑优化。

优化

找到 TLE 的问题:

void query(){
    ll x=0;
    for(char i:s){
        x=tr[x][i-'a'];
        for(int j=x;j;j=fail[j])//看这里
            for(int k:End[j])
                ans[k]++;
    }
}

我们可爱的查询爆炸了。

它被一组数据卡了:

a
aa
aaa
aaaa
...
aaaaa...aaa

Oh No,我无疑是愤怒的。

我们用 fail 构成一棵 fail 树。

那么每走一步,fail 链都可能很长,最坏会退化到:

关键性质:**fail 边构成一棵树**。 每个节点只有一个 fail 指针,而且 fail 一定指向深度更小的节点,所以 fail 不可能成环。 如果只保留 fail 边,就会得到一棵以根为根的树 ~~(废话)~~,称为 fail 树。 fail 树有一个非常重要的含义: 如果节点 $u$ 在文本串上被经过了一次,那么 $u$ 到根这条 fail 链上的所有节点,都相当于多匹配了一次。 换句话说: > 节点 $u$ 被经过,说明 $u$ 对应的字符串出现了一次; > > $fail_u$ 对应的字符串也出现了一次; > > $fail_{fail_u}$ 也出现了一次; > > … 一直沿 fail 树向上,所有祖先都应该 $+1$。 所以,朴素做法是每经过一个点就立刻向上跳 $fail$。 优化做法是: 查询时只在自己身上打标记,最后再统一把标记沿 fail 树向上传播。 有点点像树上前缀和的方法来合并。 #### 拓扑排序优化 fail 树是一棵内向树: 每个节点指向自己的 fail 父节点。 如果我们对 fail 树做拓扑排序,让叶子先出队,父节点后出队,那么处理到某个节点时,它的所有子节点的贡献都已经累加完毕了。 所以可以这样做: > 建 AC 自动机时,顺便统计 fail 树上每个节点的入度; > > 查询时只给经过的节点 $cnt_u+1$; > > 查询结束后,用拓扑排序从叶子向根累加。 棒棒哒,终于不会炸了! #### DFS 优化 ~~怎么还有?(⊙\_⊙)~~ DFS 优化和拓扑优化完全等价,区别只是遍历顺序: > 拓扑排序:用入度队列,从叶子向根递推; > > DFS 优化:显式建出 fail 树,然后做后序遍历,把子树贡献累加到父节点。 但是为了保险起见一般使用拓扑排序优化,因为 DFS 优化可能爆栈! :::info[code(这里只展示拓扑排序优化)] ```cpp #include<bits/stdc++.h> using namespace std; typedef long long ll; const ll N=2e5+1; int n,tr[N][26],fail[N],in[N],cnt[N*10],id[N],tot; string t[N],s; void insert(string &s,int idx){ int u=0; for(char ch:s){ int c=ch-'a'; if(!tr[u][c]) tr[u][c]=++tot; u=tr[u][c]; } id[idx]=u; } void build(){ queue<int>q; for(int i=0;i<26;i++) if(tr[0][i]) q.push(tr[0][i]); while(!q.empty()){ int u=q.front();q.pop(); for(int i=0;i<26;i++) if(tr[u][i]) fail[tr[u][i]]=tr[fail[u]][i],in[tr[fail[u]][i]]++,q.push(tr[u][i]); else tr[u][i]=tr[fail[u]][i]; } } void query(){ int u=0; for(char ch:s) u=tr[u][ch-'a'],cnt[u]++; } void topo(){ queue<int> q; for(int i=1;i<=tot;i++) if(!in[i]) q.push(i); while(!q.empty()){ int u=q.front(),v=fail[u]; q.pop(),cnt[v]+=cnt[u],in[v]--; if(!in[v]) q.push(v); } } int main(){ cin>>n; for(int i=1;i<=n;i++) cin>>t[i],insert(t[i],i); build(),cin>>s,query(),topo(); for(int i=1;i<=n;i++) cout<<cnt[id[i]]<<endl; }//似乎有个变量没用 ``` ::: # 题目 ## [P5357](https://www.luogu.com.cn/problem/P5357),[P3808](https://www.luogu.com.cn/problem/P3808),[P3796](https://www.luogu.com.cn/problem/P3796) 模板题,没啥好讲的。 ## [P3966](https://www.luogu.com.cn/problem/P3966) 我们其实根据板子直接统计即可,但是要记得加上拓扑排序优化。 ## [P4052](https://www.luogu.com.cn/problem/P4052) 这道题是经典的 AC 自动机 $+$ DP 题目。 直接算包含至少一个模式串比较困难,所以采用补集。 答案 $=26^m−$ 不包含任何模式串的字符串个数。 $26^m$:所有长度为 $m$ 的字符串总数。 不包含任何模式串的个数:用 AC 自动机上的 DP 来统计。 这里 $danger_u$ 表示:走到节点 $u$ 时,已经匹配到了某个模式串。 它由两部分决定: $vis_u$:$u$ 本身是某个模式串的结尾。 $danger_{fail_u}$:$u$ 的 fail 祖先已经匹配了某个模式串(即某个模式串是当前路径的后缀)。 为什么需要 $danger_{fail_u}$? 假设模式串是 `AB`,当前路径是 `XAB`。 走到 `B` 时,$vis_B=true$,没问题。 但如果模式串是 `B`,走到 `XB` 时,当前节点是 `B` 对应的 Trie 节点,但 fail 指针指向的才是 "B" 的终止节点。 所以必须沿 fail 链传递危险标记。 同时,$tr_{u,i}=tr_{fail_u,i}$ 这一步把 AC 自动机变成了完整的转移图 —— 每个节点对每个字符都有确定的后继,DP 时不需要特判。 code: ```cpp #include<bits/stdc++.h> using namespace std; typedef long long ll; const ll N=2e5+1,MOD=10007; int n,m,tot,tr[N][26],fail[N],dp[101][N],ans=0; bool vis[N],danger[N]; string s; void insert(string &s){ int u=0; for(char ch:s){ int c=ch-'A'; if(!tr[u][c]) tr[u][c]=++tot; u=tr[u][c]; } vis[u]=true; } void build(){ queue<int>q; for(int i=0;i<26;i++) if(tr[0][i]) q.push(tr[0][i]); while(!q.empty()){ int u=q.front();q.pop(); if(danger[fail[u]]) danger[u]=true; for(int i=0;i<26;i++) if(tr[u][i]){ fail[tr[u][i]]=tr[fail[u]][i]; if (vis[tr[u][i]]) danger[tr[u][i]]=true; q.push(tr[u][i]); }else tr[u][i]=tr[fail[u]][i]; } } int quickpow(int a,int b){ int res=1; while(b){ if(b&1) res*=a,res%=MOD; a*=a,a%=MOD,b>>=1; } return res; } int main() { cin>>n>>m; for(int i=1;i<=n;i++) cin>>s,insert(s); for(int i=0;i<=tot;i++) if(vis[i]) danger[i]=true; build(),dp[0][0]=1; for(int i=0;i<m;i++) for(int u=0;u<=tot;u++){ if(danger[u]) continue; if(!dp[i][u]) continue; for(int c=0;c<26;c++){ int v=tr[u][c]; if(!danger[v]) dp[i+1][v]=(dp[i+1][v]+dp[i][u])%MOD; } } for(int u=0;u<=tot;u++) if(!danger[u]) ans=(ans+dp[m][u])%MOD; cout<<(quickpow(26,m)-ans+MOD)%MOD; } ``` ## [P5231](https://www.luogu.com.cn/problem/P5231) 我们其实只需要将前缀划分出来,在统计他们是在哪个子串里即可。 ```cpp #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=1e7+1,M=1e5+1; int n,m,tot,tr[N][4],fail[N],id[M]; char s[N],t[M][110]; bool vis[N]; int calc(char c){ if(c=='E') return 0; if(c=='S') return 1; if(c=='W') return 2; return 3; } void insert(char*s,int idx){ int u=0; for(int i=0;s[i];i++){ int c=calc(s[i]); if(!tr[u][c]) tr[u][c]=++tot; u=tr[u][c]; } id[idx]=u; } void build(){ queue<int>q; for(int i=0;i<4;i++) if(tr[0][i])q.push(tr[0][i]); while(!q.empty()){ int u=q.front();q.pop(); for(int i=0;i<4;i++) if(tr[u][i]) fail[tr[u][i]]=tr[fail[u]][i],q.push(tr[u][i]); else tr[u][i]=tr[fail[u]][i]; } } void mark(){ int u=0; for(int i=0;s[i];i++){ u=tr[u][calc(s[i])]; int v=u; while(v&&!vis[v]) vis[v]=true,v=fail[v]; } } int solve(int idx){ int u=0,ans=0; for(int i=0;t[idx][i];i++){ u=tr[u][calc(t[idx][i])]; if(vis[u]) ans=i+1; } return ans; } int main(){ cin>>n>>m>>s; for(int i=1;i<=m;i++) cin>>t[i],insert(t[i],i); build(),mark(); for(int i=1;i<=m;i++) cout<<solve(i)<<endl; } ``` ## [P2414](https://www.luogu.com.cn/problem/P2414) 先把所有打印串建成一棵 Trie,再在上面建 AC 自动机。 设第 $x$ 个串在 Trie 上的终止节点是 $ed_x$。 一个串 $x$ 在串 $y$ 中出现,等价于: > 串 $y$ 的每个前缀在 AC 自动机上的对应节点中,有多少个节点的 fail 链能跳到 $ed_x$。 把 fail 指针反向,就得到一棵 fail 树。能跳到 $ed_x$ 的点,恰好就是 fail 树中 $ed_x$ 子树里的点。 所以: 询问 $(x,y)=fail$ 树中 $ed_x$ 子树内,有多少个节点属于 $y$ 到根的路径 按输入模拟 `B/P/a/.../z`,建出 Trie,并记录每个打印串对应的终止节点 $ed_i$。 在 Trie 上建 AC 自动机,得到 fail 指针。 对 fail 树做 DFS,求出每个节点的 $dfn$ 和子树区间 $dfn_u\sim dfn_u+sz_u-1$。 把所有询问按 $y$ 分组。 重新模拟打字过程,维护当前在 Trie 上的节点: > 遇到字母: > > > 进入子节点,在树状数组的 $dfn_x+1$; > > 遇到 `B`: > > > 回到父节点,在树状数组的 $dfn_x-1$; > > 遇到 `P`: > > > 当前节点就是第 $y$ 个串的终止节点,处理所有 $(x,y)$ 询问。 每次询问答案就是: `query(dfn[ed[x]]+sz[ed[x]]-1)-query(dfn[ed[x]]-1)` 也就是 fail 树中 $ed_x$ 子树区间内,当前被标记的节点数。 ```cpp #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=1e5+1; int n,m,now,len,u,pc,tot,fa[N],ed[N],dfn[N],sz[N],cnt,tr[N][26],fail[N],h[N],nxt[N],to[N],idx,bit[N],ans[N]; vector<pair<int,int>>qy[N]; char s[N]; void add(int u,int v){ nxt[++idx]=h[u],to[idx]=v,h[u]=idx; } void upd(int x,int v){ for(;x<=cnt;x+=x&-x) bit[x]+=v; } int qry(int x){ int r=0; for(;x;x-=x&-x) r+=bit[x]; return r; } void dfs(int u){ dfn[u]=++cnt,sz[u]=1; for(int i=h[u];i;i=nxt[i]) dfs(to[i]),sz[u]+=sz[to[i]]; } void build(){ queue<int>q; for(int i=0;i<26;i++) if(tr[0][i]) q.push(tr[0][i]); while(!q.empty()){ int u=q.front();q.pop(); for(int i=0;i<26;i++) if(tr[u][i]) fail[tr[u][i]]=tr[fail[u]][i],q.push(tr[u][i]); else tr[u][i]=tr[fail[u]][i]; } } int main(){ cin>>s+1,len=strlen(s+1); for(int i=1;i<=len;i++) if(s[i]=='P') ed[++pc]=u; else if(s[i]=='B') u=fa[u]; else{ int c=s[i]-'a'; if(!tr[u][c]) tr[u][c]=++tot,fa[tot]=u; u=tr[u][c]; } build(); for(int i=1;i<=tot;i++) add(fail[i],i); dfs(0),cin>>m; for(int i=1,x,y;i<=m;i++) cin>>x>>y,qy[y].push_back({x,i}); u=now=0; for(int i=1;i<=len;i++){ if(s[i]=='P'){ now++; for(auto &[x,id]:qy[now]) ans[id]=qry(dfn[ed[x]]+sz[ed[x]]-1)-qry(dfn[ed[x]]-1); }else if(s[i]=='B') upd(dfn[u],-1),u=fa[u]; else u=tr[u][s[i]-'a'],upd(dfn[u],1); } for(int i=1;i<=m;i++) cout<<ans[i]<<endl; } ``` # 后记 :::info[润色说明] 本文未使用 AI。 使用[格式化工具](https://tj.imken.dev/#/)格式化。 ::: :::info[参考文献] 洛谷有关 AC 自动机的文章。 B 站上有关 AC 自动机的文章。 [OI WiKi](https://oi-wiki.org/string/ac-automaton/) [博客园](https://www.cnblogs.com/alkaid16/articles/19023791/AC_automaton) [CSDN](https://blog.csdn.net/qq_52989046/article/details/129525842) ::: 本文是因为有关 AC 自动机文章看不懂而写的。 欢迎指出讲解错误、不够详细的地方。