AC自动机详解

首先,看清楚了,这是AC自动机不是自动AC机

引用AC自动机模板题上的一句话:

 

ovo


在学习AC自动机之前,应该先掌握一下两个前置技能:

AC自动机,通俗地讲,就是在Trie上跑KMP。AC自动机利用Trie的性质和KMP的思想,可以实现字符串的多模匹配。KMP是单模匹配,而它与AC自动机最大的区别就在fail指针的求法,其余思想基本相同。

所谓多模匹配,即给出若干个模式串和一个文本串,需要查找这些模式串在文本串中出现的情况。

AC自动机的操作分为三步:


一、建树

既然是要利用Trie,自然要先建立一棵Trie了。本文以she,he,say,her,shr五个字符串为例建立一棵Trie: 

其中,root为根节点,绿色节点表示该节点为某个单词的结尾,也就是结尾标记。

AC自动机的建树方法与Trie完全相同,在这里就不再赘述。

建树代码:

void add(string s)
{
    int p=0;
    for(int i=0;i<s.size();i++)
    {
        if(!ac[p].son[s[i]-'a'])
            ac[p].son[s[i]-'a']=++tot;
        p=ac[p].son[s[i]-'a'];
    }
    ac[p].end++;
}

二、求fail指针

求fail指针是AC自动机最精髓的地方,也是最大的难点。不过,在掌握了KMP算法之后,理解起来也不难。

AC自动机中fail指针的作用与KMP中next数组的作用相同,都是在当前字符串失配时跳转到它指向的位置继续匹配。而AC自动机之所以能够进行多模匹配,就是因为fail指针。

在AC自动机中,fail指针用BFS来求。

步骤:

  1. 建立一个队列
  2. 将根的fail指针指向自己
  3. 将与根相连的节点的fail指针指向根,并将它们入队
  4. 取出队头h,遍历它的儿子。设当前遍历到的儿子节点为x,找到h节点的fail指针指向的节点,设其为k
  5. 若k有与x相同的儿子s,则将x的fail指针指向s;否则,找到k的fail指针,重复第5步,若一直都没有找到,则将x的fail指针指向根节点。将x入队,重复第4、5步,直到队列为空

仍然以she,he,say,her,shr五个字符串为例,如图:

 

  1. 如图中红线所示,将与root相连的h、s的fail指针指向root并将它们入队
  2. 如图中蓝线所示,取出h,找到h的儿子e,因为h的fail指针指向root且root的儿子没有e,因此e的fail指针指向root,并将e入队;取出s,找到s的儿子a,因为s的fail指针指向root且root的儿子没有a,因此a的fail指针指向root,并将a入队;找到s的儿子h,因为h的fail指针指向root且root的儿子有h,因此h的fail指针指向与root相连的h,并将h入队
  3. 如图中绿线所示,取出e,找到e的儿子r,因为e的fail指针指向root且root的儿子没有r,因此r的fail指针指向root,并将r入队;取出a,找到a的儿子y,因为a的fail指针指向root且root的儿子没有y,因此y的fail指针指向root,并将y入队;取出h,找到h的儿子e,因为h的fail指针指向与root相连的h且该节点的儿子有e,因此e的指针指向与root相连的h的儿子e,并将e入队;找到h的儿子r,因为h的fail指针指向与root相连的h且该节点的儿子没有r,因此找到与root相连的h的fail指针指向的root,而root的儿子也没有r,因此r的指针指向root,并将r入队
  4. 最后,取出r,y,e,r,发现它们均没有儿子节点。此时队列为空,停止遍历。

队列的状态是这样的:

h s
s e
e a h
a h r
h r y
r y e r

这样讲可能有点乱,请结合图和队列状态理解,不会难。

在实际实现过程中,若一直重复以上的第4、5步,时间复杂度难免会高。这里有一个巧妙的方法:当发现一个节点x没有某一个儿子s时,直接将s作为指针指向x的fail指针与s相同的这个儿子。这样实际上就是在模拟第4、5步反复查找的过程,这个指针会从上到下传递下来。因为当我们将根节点的编号设为0时,若一个节点没有儿子,就相当于这个儿子作为指针指向了根节点。这样可以更加方便地实现第4、5步。

求fail指针代码:

void build()
{
    for(int i=0;i<26;i++)
        if(ac[0].son[i])
        {
            ac[ac[0].son[i]].fail=0;
            q.push(ac[0].son[i]);
        }//将与根相连的节点的fail指针指向根节点并将它们入队
    while(q.size())
    {
        int now=q.front();
        q.pop();//取出队头
        for(int i=0;i<26;i++)
            if(ac[now].son[i])
            {
                ac[ac[now].son[i]].fail=ac[ac[now].fail].son[i];
                q.push(ac[now].son[i]);
            }
            else
                ac[now].son[i]=ac[ac[now].fail].son[i];
    }//重复第4、5步
}

三、字符串匹配

字符串匹配的思想与KMP基本相同,实现方式与Trie上查找字符串类似。将文本串从头到尾一位一位在Trie上查找,对于每一个节点,若没有被遍历过,沿着它的fail指针走,直到根节点或一个已遍历过的点。对于路径上的所有点,将答案加上它的结尾标记,即当前节点为几个字符串的结尾,然后将其结尾标记改为-1,以显示其已遍历过。

还是以这个图为例: 

若文本串为yasherhs,则:

  1. 对于y,a,Trie中没有对应的路径
  2. 对于s,h,e,在Trie中可以沿着root-s-h-e这条路径走到第四层节点e,答案加1,沿着其fail路径向上可以走到第三层节点e,答案加1;
  3. 对于r,此时指针指向第四层节点e的儿子指向的节点,也就是其fail指针指向的第三层节点e,随后指向右下角节点r,答案加1;
  4. 对于h,s,因为已经遍历过了,因此不会再进行遍历

为什么走到一个已遍历过的点也要停止呢?因为若一个节点已被遍历,则沿着它的fail指针走直到根节点的这条路径上的所有节点一定都被遍历过了,若在走一遍则属于浪费时间,因此直接停止即可。

字符串匹配代码:

int get()
{
    int p=0,ans=0;
    for(int i=0;i<f.size();i++)
    {
        p=ac[p].son[f[i]-'a'];
        for(int j=p;j && ac[j].end!=-1;j=ac[j].fail)
        {
            ans+=ac[j].end;
            ac[j].end=-1;
        }
    }
    return ans;
}

最后奉上完整代码:

#include<iostream>
#include<string>
#include<queue>
using namespace std;
const int N=1e6;
int tot=0,n;
string f;
queue<int> q;
struct T
{
    int end=0,fail=0,son[26];分别表示结尾标记,fail指针和儿子节点
}ac[N];
void add(string s)
{
    int p=0;
    for(int i=0;i<s.size();i++)
    {
        if(!ac[p].son[s[i]-'a'])
            ac[p].son[s[i]-'a']=++tot;
        p=ac[p].son[s[i]-'a'];
    }
    ac[p].end++;
}//建树
void build()
{
    for(int i=0;i<26;i++)
        if(ac[0].son[i])
        {
            ac[ac[0].son[i]].fail=0;
            q.push(ac[0].son[i]);
        }
    while(q.size())
    {
        int now=q.front();
        q.pop();
        for(int i=0;i<26;i++)
            if(ac[now].son[i])
            {
                ac[ac[now].son[i]].fail=ac[ac[now].fail].son[i];
                q.push(ac[now].son[i]);
            }
            else
                ac[now].son[i]=ac[ac[now].fail].son[i];
    }
}//求fail指针
int get()
{
    int p=0,ans=0;
    for(int i=0;i<f.size();i++)
    {
        p=ac[p].son[f[i]-'a'];
        for(int j=p;j && ac[j].end!=-1;j=ac[j].fail)
        {
            ans+=ac[j].end;
            ac[j].end=-1;
        }
    }
    return ans;
}//字符串匹配
int main()
{
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        string s;
        cin>>s;
        add(s);
    }//输入模式串并插入Trie
    ac[0].fail=0;//将根节点的fail指针指向自己,其实这步可以不要,因为默认就是0
    build();//求fail指针
    cin>>f;
    cout<<get()<<endl;//输入文本串并匹配,直接输出答案
    return 0;
}

习题:


声明:本文部分内容参考了一些大佬的博客

参考资料:

AC自动机算法详解 (转载)

AC自动机-巨佬yyb


2019.5.2 于厦门外国语学校石狮分校

原文地址:https://www.cnblogs.com/TEoS/p/11384548.html