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,s_i]\) 遍历字典树,遍历一个节点,就沿着这个节点的 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 @ 2024-01-16 20:08  彬彬冰激凌  阅读(86)  评论(0)    收藏  举报