AC快乐机——最最通俗易懂的AC自动机讲解!

AC快乐机

众所周知,KMP是算法竞赛中常用的字符串匹配算法,该算法通过对模式串构建next数组的方式,十分有效的提高了匹配的效率。

单一模式串的匹配可以构造next,那如果模式串有多个,也同样能通过构造next的方式匹配吗?

小企鹅

Fail指针

给你多个模式串,也就是给你一棵Trie,在Trie上进行匹配。假设我们有能力构建出一棵Trie的next,考虑KMP中的next的定义(指向最长的后缀),那么一棵Trie的next是长这样的(以模式串he,she,him,hers,shit为例):

这是个Trie

这棵Trie的next是这样的(没画的都指向根):

它的fail

现在要拿主串ashers上去匹配,匹配的方式和KMP一样:如果可以接着向下走则向下走,否则爬next

那么匹配的路径为root-root-s-h-e-e-r-s,最终匹配成功

匹配的复杂度显然是O(n+min(n,m))O(n+min(n,m))的(其中nn为主串长度,mm为最长的模式串长度)

如果在Trie树上能构造出next的话,匹配复杂度就会变成线性,这就是AC自动机基础的原理啦,AC自动机中next的名字叫做Fail指针(也称后缀节点),接下来的所有next都会用Fail代替

Fail指针是从一个Trie节点(对应字符串α\alpha)到另一个Trie节点(对应的字符串ω\omegaα\alpha的最长后缀)的有向边

匹配时,如果我们到达了不能继续匹配的节点,我们沿着Fail指针爬以维持尽可能更大的前缀

对于Trie树上每一个节点,我们都可以构造一个Fail指针

Fail指针的构造?

Fail指针的些许小性质:

1.根的所有儿子的Fail都是根

2.节点x的Fail指向y(非根),那么x的t儿子的Fail指向y的t儿子的Fail

以上图为例,sheh的Fail指向的是he中的h,那么she儿子she的Fail就会指he儿子he

3.一个节点一直沿着Fail爬会爬到根,期间深度是不断减小的

这样我们有了一个构建Fail的大体思路,对于每一个点,对他所有的儿子构建Fail,然后bfs下去,对t儿子构建Fail的过程可以通过不断爬Fail找到第一个有t儿子的点这一途径得到

但也会有一些不美妙的情况使这个过程变得很不美观

一个栗子

假设上图苟利国家生死以有一个儿子,用刚才所说的方法构造的Fail指针就需要沿着蓝色箭头爬好远好远好远好远好远好远才能找到,这个过程会让过程及复杂度极其不优美,AC自动机用Trie图简化了这个过程

Trie图

(为了方便,将根所有不存在的儿子都设为根)

对于Trie树上的每一个节点xx,它的α\alpha边指向的是它下一个字符tt,表示如果当前状态是xx,待匹配字符是α\alpha,那么匹配到的点是tt

设字符集为SS,点xx可匹配的(向外延伸出去的)字符集SSS'\subseteq S,即αS\forall \alpha \in S',都可以xx(不通过爬Fail的方式)由向外延伸(到pp)

此过程描述为:当xx的下一个字符是α\alpha(αS\alpha \in S')时,走到的节点是pp

但是如果当前状态是xx,下一个字符βS\beta \notin S',也就代表着在此失配,那么根据刚才所描述的方法,AC自动机需要爬Fail找到第一个有β\beta儿子的点yy继续匹配(到qq)。

此过程可以描述为:当xx的下一个字符是β\beta(βS\beta \notin S')时,走到的节点是qq

这两个过程描述起来十分相似,那么我们为什么不将qq直接看作xxβ\beta儿子呢?

对于节点xx,如果它并没有β\beta儿子,那么沿着找到第一个有β\beta儿子的节点yy,并把yyβ\beta儿子看作是自己的β\beta儿子

我们把这个操作称为NTR,NTR过后的图叫Trie图

上图理论上的Trie图是长这样的

Trie图

但实际上是这样的

真实Trie图

如果xxNTR了yyα\alphayyNTR了zzα\alpha,可以直接看作xxNTR了zzα\alpha,也就是说如果我们按照Fail的顺序构建,NTR操作只要考虑一个Fail就可以了

在Trie图上的匹配只要无脑向后走就可以了,也就是说,构建完Trie图,Fail就没什么用了

Trie图本质上是个有向图

Fail指针 Trie图的构造!

struct Node{
    Node *ch[26],*fail;
    bool b;
    Node():fail(NULL){
        b=false;
        for(int i=0;i<26;i++)
        	ch[i]=NULL;
    }
}*root=new Node;
queue<Node*>q;
inline void Insert(char *s){///构建Trie
    Node *x=root;
    int len=strlen(s+1);
    for(int i=1;i<=len;i++){
        if(!x->ch[s[i]-'0']) x->ch[s[i]-'0']=new Node;
        x=x->ch[s[i]-'0'];
    }
    x->b=true;
}
inline void GetFail(){///AC自动机可以以bfs的方式构建
    root->fail=root;///根的Fail是他自己
    for(int i=0;i<26;i++){
        if(root->ch[i]) q.push(root->ch[i]),root->ch[i]->fail=root;
        ///将根儿子的Fail指向根,并放入队列里
        else root->ch[i]=root;
        ///如果root没有i这个儿子,就把他的儿子赋为自己,这样以上的性质2对根也满足了
    }
    while(!q.empty()){
        Node *x=q.front();q.pop();
        for(int i=0;i<26;i++){
            if(x->ch[i]) x->ch[i]->fail=x->fail->ch[i],q.push(x->ch[i]);
            ///根据性质2搞出各个儿子的Fail
            else x->ch[i]=x->fail->ch[i];///NTR操作(
        }
        Node *tmp=x->fail;
        while(tmp!=root && !tmp->b) tmp=tmp->fail;
        if(tmp->b) x->b=true;
        ///如果它的某一后缀是一个模式串,那么它也肯定包含模式串(一些特定的题统计时会用到)
        
    }
}

在Trie图上的匹配过程可以直接当成Trie树上匹配,代码也是一样的

inline int Match(char *c){
    int len=strlen(c+1);
    int ans=0;
    Node *x=root;
    for(int i=1;i<=len;i++){
        int k=c[i]-'a';
        x=x->ch[k];///一直往下走就行了
        Node *tmp=x;///这部分是统计的...
        while(tmp!=root && !tmp->b){
            ans+=tmp->num,tmp->b=true;
            tmp=tmp->nex;
        }
    }
    return ans;
}

Fail树

把所有Fail指针当作一条无向边,可以发现构成的是一个树形结构

小例题:

给定一个n个单词的文章,求每个单词在文章中的出现次数 (n200n\le200,单词总长106\le 10^6)

小企鹅

大体思路:先将所有的串建一个AC自动机,构建的Fail时候到一个点就将所有它的所有Fail都加上1

显然会T

可以发现,一个点一定是它Fail树中子树点的后缀(如果串s能匹配到这里,s就会是子树所有点的后缀)

那么一个串的出现次数事实上就是它Fail树子树中单词数量的和

然后没了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章