从KMP到AC自动机

SuperJvRuo

2018-12-20 16:18:33

Personal

### 0 什么是自动机 我到现在也不知道什么是自动机。 你们也没必要深刻理解这个问题。 ### 1 要学什么自动机 AC自动机、回文自动机(PAM,回文树)、后缀自动机(SAM)。 事实上,OI中最实用的字符串算法只有:Hash、PAM、SAM。KMP可以现场脑补出来,manacher可以用Hash替代(多一个$\log$),AC自动机基本可以用SA(后缀数组)替代,SAM可以建出SA,PAM可以秒杀各种SAM+manacher的神题。 在各种膜你赛中,我只用过Hash和SA。 ### 2 什么是AC自动机 一种多模式串的字符串匹配算法。不能用于自动AC。 ### 3 AC自动机的原理 AC自动机就是Trie树上的KMP。 | KMP | AC Automaton | | :----------- | :----------- | | 字符串 | Trie | | 失配数组```next[]``` | 失配数组```fail[]``` | 回忆一下KMP: 我们一位一位匹配模式串与文本串。在失配时,我们借助预先求出的next数组在模式串上向前跳转。 其实,单个模式串可以看做是一个单链形的Trie树。所以在AC自动机中: 我们在模式串构成的Trie树上顺着给出的文本串走路。当我们发现没有相应的子节点时,类似于KMP,我们借助预先求出的fail数组在Trie树上向前跳转。 ### 4 如何借助(朴素方法构造的)fail跳转 假如我们已经构造了下图的AC自动机,途中的黑边为Trie树,红边和绿边为fail数组。 ![](https://cdn.luogu.com.cn/upload/pic/46692.png ) 可以发现AC自动机的```fail```和KMP的```next```就是一个东西。对于```v=fail[u]```来说,在```u```的所有后缀中,```v```是Trie树中存在的最长前缀。 我们类比KMP进行跳转。 ``` int query(char T[]) { int res=0,pos=0; for(int i=0;T[i];++i) { int trans=T[i]-'a'; while(!trie[pos].next[trans]&&pos) { pos=trie[pos].fail; } pos=trie[pos].next[trans]; int temp=pos; while(temp&&trie[temp].tail!=-1) { res+=trie[temp].tail; trie[temp].tail=-1; temp=trie[temp].fail; } } return res; } ``` ### 4 AC自动机的构造 先建Trie树,正常建就行。关键在于如何在线性时间内构造fail数组。构造AC自动机的过程是一个对Trie树进行BFS的过程。 和KMP差不多,我们不难想到: #### 朴素的构造方法 设这个节点上的字母为c,沿着他父亲的fail走,直到走到一个节点,他的儿子中也有字母为c的节点,我们就找到了所求的fail。 ``` void Get_Fail() { std::queue<int>Q; Q.push(0); while(!Q.empty()) { int temp=Q.front(); Q.pop(); for(int i=0;i<26;++i) { if(trie[temp].next[i]) { int prev=trie[temp].fail; while(prev) { if(trie[prev].next[i]) { trie[trie[temp].next[i]].fail=trie[prev].next[i]; break; } else { prev=trie[prev].fail; } } Q.push(trie[temp].next[i]); } } } } ``` 这个方法的弊端很明显,往前跳fail的时候可能要跳很远才能找到匹配。 #### 从字典树到字典图 还有一种加特技的写法。 ``` void Get_Fail() { queue<int>qu; for(int i=0;i<26;++i)//迷思1 { if(trie[0].next[i]) { trie[trie[0].next[i]].fail=0; qu.push(trie[0].next[i]); } } int fa; while(!qu.empty()) { fa=qu.front(); qu.pop(); for(int i=0;i<26;++i) { if(trie[fa].next[i]) { trie[trie[fa].next[i]].fail=trie[trie[fa].fail].next[i];//迷思2 qu.push(trie[fa].next[i]); } else trie[fa].next[i]=trie[trie[fa].fail].next[i];//迷思3 } } } ``` 迷思1:为什么要先让根节点的所有儿子入队?只让根节点入队不行吗? 真的不行。我们模拟一遍试试(暂时认为while里面的代码是完全正确的)。 如果直接让根节点入队,考虑根节点的所有儿子: ``` for(c in 字符集) { if(存在这个子节点c) { 这个子节点的fail=根节点的这个子节点 } else { 根节点的这个子节点=根节点的这个子节点 } } ``` 自己的fail怎么指向自己了?显然不对。根据定义,每个子节点的fail明明一定指向根节点。 于是我们强行钦点他们的fail为根节点,也就是0。我们发现,这样构造出的AC自动机是正确的。 迷思2:为什么直接把fail指针的next赋给子节点的fail?你怎么就知道fail一定有这个儿子呢? 迷思3:怎么直接把trie树的结构改了?随随便便就加儿子? 这其实是一种路径压缩。为了达到路径压缩的目的,我们首先要将所有节点的26个子节点补齐。我们已经发现,在这种加特技的方法里,子节点是可以往回连的。因此这个东西已经不满足树的定义,不能被称为trie树了。我们称它为trie图。 (在黑板上画一下) 要是仍然不理解,先把代码背下来,以后就会明白的。 这种构造方法还有一个好处,那就是查询函数也可以化简。 ``` int Query() { int res=0,pos=0; for(int i=0;i<length;++i) { pos=trie[pos].next[s[i]-'a']; for(int j=pos;j&&trie[j].tail!=-1;j=trie[j].fail) { res+=trie[j].tail; trie[j].tail=-1; } } printf("%d",res); } ```