AC自动机讲解
AC自动机是处理多模式串匹配等一系列问题的工具,可以将它当做一种数据结构。
首先我们考虑一个非常简单的问题,给定两个字符串,询问其中一个字符串在另一字符串中出现的次数,这个问题我们用KMP就可以非常容易的解决了,但是如果给定若干模式串,询问这组串在这个长串中的各个出现次数,这时KMP的时间复杂度就显然比较暴力了,对于这类的问题,我们可以考虑用AC自动机来解决(假设字符串中字符为大写字母)。
定义指针。
struct node{ int cnt; node *fail,*child[30]; node(){ cnt=0; fail=NULL; memset(child,0,sizeof child); } };
首先我们用一颗trie树将这若干个字符串存下来,这样方便我们处理多字符串匹配问题。
void build_trie(){ totnode=nodepool; root=totnode++; for (int i=1;i<=n;i++){ char c[maxm]; scanf("%s",&c); int len=strlen(c); node *t=root; for (int j=0;j<len;j++){ if (!t->child[c[j]-'A']) t->child[c[j]-'A']=totnode++; t=t->child[c[j]-'A']; } t->cnt=1; } }
这时候我们定义fail指针,他的作用类似与KMP中的next数组,代表在trie树上的某一节点如果失陪的话,需要向fail节点来转移,这样最优,即在模式串组中,假设当前节点代表的字符串为s,存在ss为s的后缀且ss最长,这时候s的fail指针指向ss的末节点。
那么现在我们需要快速求出每一个点的fail指针,首先比较显然的是每一个点的fail指针指向的节点的深度都会小于i节点的深度,假设我们当前需要求第i个点的fail指针,且深度在i之前的点的fail指针我们都已经求出,那么对于i的父亲节点j,i为j的第k个儿子,如果j的fail指针有第k个儿子,那么i的fail指针就为j的fail指针的第k个儿子,否则向上找j的fail指针的fail指针的第k个儿子,因为j的fail指针指向的点代表的字符串为j点代表的字符串的最长后缀,那么肯定先考虑这个是最优的,j的fail指针的fail指针是j的次长后缀,这样我们从最长的依次考虑下去,所得到的i的fail就是最长解。
因为我们需要保证在求第i个点之前,深度小于i的点都需要已经被处理,所以我们用一个队列来维护,首先将root入队,然后依次解决。
root的fail指针为自己。
void build_ac(){
h=0;t=1; root->fail=root; que[1]=root; //root入队 while (h<t){ node *u=que[++h]; for (int i=0;i<26;i++) if (u->child[i]){ //如果该节点有儿子,我们才求这个点的fail指针 que[++t]=u->child[i]; node *v=u->fail; for (;v!=root&&!v->child[i];v=v->fail); //找到que[t]的fail指针。 que[t]->fail=v->child[i]&&v->child[i]!=que[t]?v->child[i]:root; //因为root先入队,所以root的儿子的父亲的fail指针为root,这样root儿子的fail指针就指向了自己,防止这种情况我们特判下 } } }
但是首先我们明确自动机的定义,在每一个状态的每一个转移,我们都应该有转移方法,即每个点的所有儿子都不应该为空,虽然上一种构建ac自动机的方法对于某些问题也正确,但第一不满足自动机的定义,第二每次求每个点的fail指针时,我们向上寻找了好多次,这样就加大了ac自动机的常数,所以我们通常使用另一种ac自动机的构建方式。
void build_ac(){ int h=0,t=1; que[1]=root; root->fail=root; for (int i=0;i<26;i++) if (!root->child[i]) root->child[i]=root; //root的每一个空儿子都应该指向root while (h<t){ node *v=que[++h]; for (int i=0;i<26;i++) if (v->child[i]&&v->child[i]!=root){ //因为root的空儿子定义为root,所以我们需要特判 que[++t]=v->child[i]; node *u=v->fail; que[t]->fail=u->child[i]!=que[t]?u->child[i]:root; //上一种因为v->fail的每一个儿子都非空,所以我们直接赋值就行了。 } else v->child[i]=v->fail->child[i]; //对于这种本来是空儿子的节点,我们将他的空儿子连到该节点fail指针的儿子,这样就保证了fail指针直接赋值的正确性。 } }
而且对于fail指针,每个节点都有一个fail指针,这类似与树的定义,所以我们将fail指针反向,可以建立failtree。
现在我们拥有了ac自动机,那么对于询问一个字符串组在一个字符串中出现的次数的问题,我们可以在ac自动机中模拟建立trie树的方式,跑这个字符串,将这个字符串经过的所有点的cnt++,建立failtree,每个节点的子树权值和就是该串在长串中出现次数。
那么对于单词(tjoi)这个题,我们需要考虑一个字符串组中,每个字符串在字符串组中出现的次数,这时假设当前字符串的结尾节点为i,所有指向i的fail指针所对应的节点都会使i的出现次数增加该节点的cnt次,那么建立failtree求和即可,其实简单的,我们可以将每一个字符串经过的节点的cnt++,这样我们从最深层开始
t->fail-cnt+=t->cnt,这样可以达到相等的效果。
关于ac自动机上的dp问题,最经典的就是给定字符串组,求一个长度问m的穿,(不)包含字符串组中的字符串的方案数,这样我们只需要定义状态w[0..1][i][j]代表当前在ac自动机上为第i个点,构建的字符长度为j的时候,包括(1),不包括字符串组中的字符串的方案数,直接累加就行了。