• 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 于厦门外国语学校石狮分校

  • 相关阅读:
    javascript中的几种遍历方法浅析
    实用的正则表达式
    关于git中的merge和rebase
    油猴脚本-3
    油猴脚本-2
    油猴脚本-1
    hadoop各个组件之间的通信
    mysql 表数据修改的方法,单标修改、多表修改--将一张表里面的其中一个字段的值赋值给另一张表
    kafka的副本同步机制(ISR)
    sql的over函数的作用和方法
  • 原文地址:https://www.cnblogs.com/TEoS/p/11384548.html
Copyright © 2020-2023  润新知