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  彬彬冰激凌  阅读(5)  评论(0编辑  收藏  举报