AC自动机(Aho-Corasick Automaton),虽然不能够帮你自动AC,但是真的还是非常神奇的一个数据结构。AC自动机用来处理多模式串匹配问题,可以看做是KMP(单模式串匹配问题)的升级版。常常见到这样的说法,AC自动机 = Trie树 + KMP。
原理初步
首先对于所有的模式串,我们先需要利用Trie树将其建起来。AC自动机最巧妙的部分在于失配指针(fail)的构建,也就类似KMP中的next数组,只不过现在变为了多模式串。在匹配的时候沿着trie树走,发现不匹配即跳转失配指针,因此复杂度已经达到了理论下界$O(n + L*m) (其中L表示模式串数量)$
构建Trie树
最基本的Trie树的建立。其中End表示单词结尾
inline void Insert(char* s){ , c; ; i < len; ++i){ c = s[i] - 'a'; if(!ch[u][c]) ch[u][c] = ++num_node; u = ch[u][c]; } End[u]++; }
构造失配指针
何为失配指针?对于任意一个节点$u$,$fail[u]$表示当节点$u$失配时它所应当到达的最优位置。暴力的做法中,当一个节点失配,我们即要从本次的开头往后移一格为开头重新匹配。就像KMP一样,我们的优化也就在于是不是只移一格?移动之后是否还从头开始匹配?
容易发现,如果我们移动一格以后没有东西与它匹配,那么以它作为开头毫无意义。而我们又不想浪费我们的历史信息。可以这样考虑——当我们确定一个新的开头时,为了考虑完全所有情况,我们要在理想情况下使得符合要求的开头的这一段长度尽量长。——当有一个部分失配的时候,意味着前面部分全部匹配。因此我们要考虑跳转一个位置,这个位置是当前已经匹配的这个串的最长公共前缀。因此我们又可以知道fail的另一个意义:指向当前节点所对应的串的最长公共前缀字符串的结尾节点。
那么具体怎么找呢?联系到KMP算法,KMP算法求解next数组是一个不断迭代的过程,如果$s[i]≠s[k]$,则不停做$k=next[k]$……直到找到$s[i]=s[k]$才能够转移。那么AC自动机也一样,不停做$u=fail[u]$,直到这两个节点相等。此时构建fail指针即可。事实上,它的原理和KMP一模一样
然而这里并不是一个模式串,我们要以什么顺序去遍历构造fail指针呢?既然想构造一个节点的fail需要访问诸多别的模式串的父亲节点,并且它访问的节点的深度不可能比自己还大(因为是前缀),所以采用BFS顺序来遍历即可
进行匹配
构造好了fail指针,现在要正式进行匹配了。其实还是和KMP一样。逐位比较,不停fail直到当前位匹配为止,然后按照文本串的下一位继续走下去,如果不行继续fail。如果到达某一个单词的结尾了,意味着找到了一个匹配。
但是有一个问题,如果有两个模式串$\{abcd\}\{bcd\}$时,当找到$\{abcd\}$匹配时,\{bcd\}肯定能够匹配。然而Trie树是前缀树,这两个模式串将会被分开建。那怎么办?因此找到一个串匹配时,还应当往回找所有该单词的后缀(Suffix)。一般的做法就是沿着fail不停往上走即可。(fail指针的另一个意义)
到目前为止,AC自动机就可以告一段落了。
Trie图
对AC自动机进行一些改进,原来的Trie树就变成了Trie图。事实上在实践中,人们常常会使用的是Trie图,它的效率更高,实现也更简单。Trie图的思想是可以补全Trie树中那些不存在的节点。
当一个真实存在的节点ch[u][i]在构建fail时,不再需要使用while语句,而是直接将fail指针直接连接到其父亲fail指针的相应儿子上,即使那个相应儿子根本不存在。
fail[ch[u][i]] = ch[fail[u]][i]
而对于一个节点所有不存在的儿子,把这些儿子直接作为失配的一个传送门,传送到应当到达的地点。
ch[u][i] = ch[fail[u]][i]
为什么这样做会是正确的呢?
想一想,何为失配?对于Trie树来说,失配就是预期到达的那个子节点不存在。因此我们通过赋值,令一个不存在的节点实际上是一个传送门。对于一个不存在的子节点ch[u][i],我们直接将它传送到其父亲的失配指针的相应节点——如果该相应节点存在那么最好;如果不存在,那么我们将会传送到一个传送门那里,因此继续被传送。注意这里的传送是连续的,因此其实它所指向的是另一个点,而那个点有可能指向另一个点,所以实际上指的有可能是一个很遥远的点。所以实际如果一直不存在,走一步也许就会被一直传送直到那个点存在。如果一路不存在,那也就指回了根节点。
那么对于一个存在的节点,我们也类似的,很草率的直接把它的fail设置成为它父亲相应的子节点,如果不存在,那么一样,是个传送门。特别注意,一个不存在的节点是没有fail的,因为它本身就是一个fail。
事实上我认为,如果仅仅只是为了判断是否匹配,那么根本不需要让真实存在的点有$fail$指针。恰恰,真实存在的节点的$fail$在这里存在的意义是为了统计匹配。当一个节点匹配一个模式串时,其所有后缀必定匹配。而此时我们需要通过这一步$fail$往上走。
Trie图实现的getFail:
inline void getFail(){ ; ; i < ; ++i){ ][i]){ fail[ch[][i]] = ; q.push(ch[][i]);//这些单词的起始点的fail直接指向根节点 } } while(!q.empty()){ u = q.front(); q.pop(); ; i < ; ++i){ if(ch[u][i]){ fail[ch[u][i]] = ch[fail[u]][i];//该节点存在,fail直接指向父节点的相应节点。 q.push(ch[u][i]);//BFS,节点存在就需要入队。注意叶子结点也是会入队的 } else ch[u][i] = ch[fail[u]][i];//如果不存在,就作为一个fail指针指向那个相应节点 } } }
那么用Trie图进行匹配就更简单了。事实上,如果不是要统计所有的后缀链接,都不需要调用fail指针,因为失配的情况可以一视同仁
inline int AC_automaton(char* s){ int len = strlen(s); ,v,ans=; ; i < len; ++i){ u = ch[u][s[i]-'a'];//一视同仁地走到下一步 ; v = fail[v]){ if(!End[v]) break; ans += End[v], End[v]=; } } return ans; }