AC 自动机学习笔记

AC 自动机学习笔记

AC 自动机可以用于解决字符串上的出现次数,出现位置问题。结合了 Trie 树和 KMP 的思想,在 O(n) 的时间内完成查询 。相较于 KMP 的好处在于,AC 自动机不仅速度快,而且支持多个模式串同时在一个文本串内查询。

算法

前置知识:Trie 树,KMP,自动机基本概念。

构建 AC 自动机有两个步骤:

1.基础 Trie 结构:将所有模式串构建成一颗 Trie 树。

2.KMP 思想:对 Trie 树上的节点构造失配指针。

然后进行多模式匹配。

构建 Trie 树

直接把所有模式串构建普通的 Trie 树,同时强调,Trie 树中的某个节点表示了某个模式串的前缀。

后文将前缀也成为状态。

失配指针

AC 自动机利用 fail 指针来优化多模式串的匹配。

状态 u 的 fail 指针指向状态 v,这里状态 v 是状态 u 的最长后缀。

如果不存在最长后缀,那么 fail 会指向 Trie 树的根节点。

匹配时,文本串会从根开始沿着和当前字符一样的边向下,如果在某一个节点无法向下,我们称为失配。

在文本串失配时,将会沿着当前节点的 fail 指针前往下一个点继续匹配。

(而且你会发现,文本串到达了某个状态,那么沿着这个状态的 fail 指针遍历,这些遍历的状态也都在文本串中出现过,这个性质后面会用到。)

证明求出 fail 指针呢?

p 为点 u 的父亲,且深度小于 u 的点的 fail 指针均已经求出,节点 p 通过字符 c 指向 u。这条边为 trie[p,c]

1.如果 trie[fail[p],c] 存在,u 的 fail 指针直接指向 trie[fail[p],c]

2.否则重复使 p=fail[p](这里会改变 p,u 的关系,但是不重要),直到寻找的一个存在的 trie[fail[p],c],能够让 u 的 fail 指针指向 trie[fail[p],c]

3.真的不存在,让 fail 指针指向根。

如此完成 fail 的构建。

字典树与字典图

1.字典树:

void insert(char *s,int num)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
    {
        int v=s[i]-'a';
        if(!trie[u].son[v]) trie[u].son[v]=++cnt;
        u=trie[u].son[v];
    }
    if(!trie[u].flg) trie[u].flg=num;
    rev[num]=trie[u].flg;
}

这里的 flg 指向了到达这个节点的第一个模式串,如果有别的模式串相同,那么我们让这个模式串指向第一个相同的即可。

2.字典图

我们用文本串遍历字典树时,如果失配要沿着 fail 指针找到一个可以匹配的节点,但这个过程往往会耗费很多时间。

考虑能不能优化这个过程,我们设 tr[u,c] 为 Trie 树上的节点 u 在末尾添加一个字符 c 到达的节点,这里 tr[u,c] 优先指向自己的儿子。

特别的,如果根节点的 tr[rt,c] 不存在,那么会指向根节点自己。

不难发现,fail[u] 的深度肯定小于 u,在处理 u 节点时,所有深度小于 u 节点的 tr 已经求出。

如果 u 节点有通过字符 c 到达的儿子,那么 tr[u,c] 指向自己的儿子;如果没有,那么 tr[u,c] 指向 tr[fail[u],c]

这样子我们就省去了遍历 fail 链的时间,直接通过 tr[u,c] 跳节点即可。

void getfail()
{
    for(int i=0;i<26;i++) trie[0].son[i]=1;//tr[0,c] 均指向根
    que.push(1);
    trie[1].fail=0;//根的 fail 指针指向 0,方便后面求 tr[u,c]
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        int fail=trie[u].fail;
        for(int i=0;i<26;i++)
        {
            int v=trie[u].son[i];
            if(!v)
            {
                trie[u].son[i]=trie[fail].son[i];
                continue;
            }
            trie[v].fail=trie[fail].son[i];
            que.push(v);
        }
    }
}

trie[u].son[i] 就是 tr[u,i]

多模式匹配

朴实无华的做法:

设当前的字文本串为 s,是第 i 个字符,所在节点为 u

一开始 u 节点为根,i=1

沿着 tr[u,si] 遍历字典树,遍历一个节点,就沿着这个节点的 fail 链走一次,fail 链上的节点(包括自己)的出现次数 +1

这里之前分析过,如果该状态出现过,那么该状态的最长后缀也出现过。为了避免漏掉状态,这也是我们 fail 指针指向最长后缀。

最后输出结束位置的出现次数即可。

华丽的做法:

式证明 fail 链将会形成树。

显然,由于 fail 指针指向比自己深度小的节点,而且不可能有环。得证。

由于每个节点都可以通过 fail 链回到根,那我们可以基于朴实无华的做法分出两种做法。

1.拓扑排序优化

遍历字典树时,只在当前节点出现次数 +1

最后,通过在 fail 树上拓扑排序求出答案。

建图这么写(记录入度即可):

void getfail()
{
    for(int i=0;i<26;i++) trie[0].son[i]=1;
    que.push(1);
    trie[1].fail=0;
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        int fail=trie[u].fail;
        for(int i=0;i<26;i++)
        {
            int v=trie[u].son[i];
            if(!v)
            {
                trie[u].son[i]=trie[fail].son[i];
                continue;
            }
            trie[v].fail=trie[fail].son[i];
            indeg[trie[fail].son[i]]++;//记录入度
            que.push(v);
        }
    }
}

拓扑排序加查询这么写:

void query(char *s)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
        u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
void topu()
{
    for(int i=1;i<=cnt;i++) if(!indeg[i]) que.push(i);
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        vis[trie[u].flg]=trie[u].ans;
        int v=trie[u].fail;
        trie[v].ans+=trie[u].ans;
        if(!(--indeg[v])) que.push(v);
    }
}

然后合起来:

例题:P5357 【模板】AC 自动机

#include<bits/stdc++.h>
using namespace std;

const int maxn=8e5+5,maxm=2e6+5;

int n,cnt=1,ans;
int vis[maxn],rev[maxn],indeg[maxn];

char s[maxm];

struct node
{
    int son[27];
    int fail,ans,flg;
    void clr(){memset(son,0,sizeof(son));fail=flg=0;}
}trie[maxn];

void insert(char *s,int num)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
    {
        int v=s[i]-'a';
        if(!trie[u].son[v]) trie[u].son[v]=++cnt;
        u=trie[u].son[v];
    }
    if(!trie[u].flg) trie[u].flg=num;
    rev[num]=trie[u].flg;
}
queue<int>que;
void getfail()
{
    for(int i=0;i<26;i++) trie[0].son[i]=1;
    que.push(1);
    trie[1].fail=0;
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        int fail=trie[u].fail;
        for(int i=0;i<26;i++)
        {
            int v=trie[u].son[i];
            if(!v)
            {
                trie[u].son[i]=trie[fail].son[i];
                continue;
            }
            trie[v].fail=trie[fail].son[i];
            indeg[trie[fail].son[i]]++;
            que.push(v);
        }
    }
}
void query(char *s)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
        u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
void topu()
{
    for(int i=1;i<=cnt;i++) if(!indeg[i]) que.push(i);
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        vis[trie[u].flg]=trie[u].ans;
        int v=trie[u].fail;
        trie[v].ans+=trie[u].ans;
        if(!(--indeg[v])) que.push(v);
    }
}
int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%s",s+1),insert(s,i);

    getfail();
    scanf("%s",s+1);
    query(s);
    topu();
    for(int i=1;i<=n;i++) printf("%d\n",vis[rev[i]]);
}

子树求和

通过上述查询方法,在最后每个节点用 fail 树上信息求和即可。

例题

挖个坑。

推荐阅读

AC 自动机 - OI Wiki

通过 gif 的方式,写的很好。

posted @   彬彬冰激凌  阅读(12)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示