【字符串】AC自动机/AC算法 - 多模式串匹配
AC自动机
性质
AC自动机/AC算法(Aho-Corasick automaton),是著名的多模式串匹配算法。
前置知识
- 字典树(重要)
- KMP算法(了解Next数组的作用)
典例与算法复杂度分析
典型例题是:给定一个主串 S,给定多个模式串 T,问主串 S 中存在多少个给定的模式串
在KMP算法中,一个长度为n的主串一个长度为m的模式串的复杂度为 O(n+m)
而如果直接照搬KMP算法到这种题型下,模式串处理一次就需要匹配一次
如果有t个模式串,则复杂度为 O((n+m)t)
假如主串的长度很大,或给定的模式串很多,即使是KMP算法复杂度也会很高
所以诞生了AC自动机,它能够在 O(n+mt) 的复杂度中求出答案
其中 O(mt) 花费在建立字典树上,O(n) 花费在遍历一遍主串上
所以它的时间复杂度就可以控制在一个小范围内
(缺点继承了字典树的空间复杂度……)
关于fail指针
AC自动机最大的特点就是为每个节点加了一个叫fail的指针
这个指针的作用与KMP算法中Next数组的作用极其相似
只不过KMP算法中是根据模式串匹配的位置 j 来引用Next[ j ] 作为下一次匹配的位置
而AC自动机则是对于每个节点,都有一个fail指针来指向下一个要进行匹配的位置
这也就是自动机能够O(n)完成主串匹配的原因
对于fail指针的构造:
如果当前在字典树上匹配到了某个节点node,对应主串的第i个位置,发现节点node的子节点中不存在主串第i+1个位置的字符,说明在节点nd发生了失配
此时我们需要寻找与 从根节点root到此时的node构成的字符串的后缀 相同的最长的模式串前缀
则node的fail指针就应该指向这个最长前缀的最后一个字符对应的节点
可以发现,除特殊情况外,某个节点代表的字符与它的fail指针指向的节点代表的字符是相同的
为什么要找的是与其后缀相同的最长前缀——
这里是KMP算法的精髓,此时的后缀是目前处理的模式串的后缀,而前缀是另一个模式串的前缀。只有转移到最长的前缀的位置,才能保证能够把所有的答案都找出来。从KMP算法的角度来看,也就是尽量小幅度地右移模式串的位置以保证不会落下某些正确答案。
以abcabcabcabc为主串,以abcabcab为模式串为例,对应匹配的位置(也就是答案)有2个:
对于模式串而言,前缀与后缀相同的情况有2种:ab
与abcab
此时选择的应该是较长的情况,移动的幅度是 8-5=3,也就是上方表1移动到表2
而如果选择了较短的前后缀,移动幅度为8-2=6,则会变成
因为模式串超出边界,结束匹配,则结果只会记录表1这一种情况
这也就是KMP的Next数组表示最长的相同前后缀长度的原因
也是AC自动机要找的是与其后缀相同的最长前缀的原因
详细建立过程与细节见下文“建树流程”
关于下文
下文的代码以 HDU 2222 Keywords Search 为例
原题题意为:
给定一个数T,表示T组样例,每组先给出一个数字N (N<=10000),表示有N个单词作为模式串
接下来N行每行一个单词,单词长度不超过50
最后一行为主串,主串长度不超过1000000
对于每组样例,输出主串中存在多少种模式串
(给定的单词可能存在重复,所以重复的单词看成不同种模式串,如果主串出现了一遍这种单词,答案就要加上重复的次数。当然,如果主串中出现同种单词次数大于一次,其后的则不计入答案)
(也就当作是一个个模式串都单独进行一遍KMP会得到的答案)
fail指针建立流程
假如现在共有下述的四个单词
abs
abi
wasabi
binary
建立字典树如下图所示
建完树就需要开始构建fail指针
首先考虑特殊情况:
规定——根节点的fail指针指向NULL(作为后面迭代的结束标识)
根节点的所有存在的子节点的fail指针全部指向根节点(就一个字母,没有什么前缀后缀相同的)
然后考虑后面迭代的情况:
假设现在处理到了节点node
如果此时后缀前缀相同的长度为0,说明没有任何后缀存在对应的相同前缀,则node的fail指针应该指向root
如果此时后缀前缀相同的长度等于1,说明只有node代表的字符存在相同的前缀,则node的fail指针指向的应该是与root相邻的子节点,此时node的父节点的fail指针指向root
如果此时后缀前缀相同的长度大于1,只要node的父节点的fail指针已经指向了它应该指向的位置,令node代表的字符为c,那么node的fail就可以直接指向 node的父节点的fail指向的节点的子节点c(如果存在)。如果这个子节点c为NULL(说明这个节点不存在),则node的fail应该继续迭代下去,去寻找fail路径上是否存在一个节点拥有子节点c。如果最后迭代到了root的fail指针,即NULL时,直接结束迭代,但此时要手动更改node的fail指向的是root而不是保持NULL
为了保证能够迭代
也就是深度更大的节点能够依据深度小的节点的fail指针来构建自己的fail
且能够发现所有的fail指针指向的节点深度都比原节点深度要小(都往比树根更近的方向指)
所以我们可以使用广度优先搜索来构建fail,从根节点开始扩散出去
首先考虑root和第一层的所有节点,构建fail后如下
在宽度优先搜索每个节点的子节点时,还是会按照字典序从a到z全部搜索一遍是否存在某个字符对应的子节点
手动处理完第一层所有节点后,把第一层所有节点推入队列
先看root->a->b这一条路线
当我们循环'a'这个节点的子节点发现存在一个对应字符是'b'的子节点时,就对其进行处理
'b'的父节点就是这个'a',而'a'的fail指针指向root
所以我们就以root来作为b的fail的父节点,观察看看root是否存在一个对应字符是'b'的子节点
显然“binary”路径上第一个就是'b',所以是存在的,那么root->a->b的'b'的fail就可以指向"binary"的第一个'b'了
fail构建完毕后,就把'b'推入队列,继续处理下一个,即root->w->a的'a'
直到队列中没有元素时,说明整个图的fail指针就全部构建完了
匹配功能的实现
建完树和fail之后就开始匹配主串模式串了
我们让每个单词结束的位置对应的节点上的标记flag加1(flag初始为0)
则对于每个节点而言,flag值情况为
匹配以整棵树作为模式串,从root开始,主串从第一个字符开始
以主串为 "wasabinaryabi" 为例
主串指向第一个字符'w',对应的,发现root节点中存在一个节点代表字符为'w',则树上指针从root移动至'w'
一直到第六个字符'i'过,一条线下来,发现'i'节点的flag值为1,说明主串匹配到了这一个单词,加入答案中后将flag置0以防止重复计数
因为'i'节点没有子节点,所以接下来处理的节点是'i'的fail指向的节点,如图,即root->a->b->i的最后一个节点'i'
发现flag值为1,说明此时又匹配到了一个单词,加入答案后flag置0
然后继续处理这个'i'的fail节点,跟着主串中间的 "inary" 最后会处理到图右下角的'y'过,加入答案后置0继续
此时'y'的fail是直接指向root的,所以直接回到root继续匹配主串
最后"abi"走完后,原本'i'的位置flag已经被置0了,所以没有对答案做出贡献
主串匹配完后,输出答案3,指过程中有匹配到 wasabi
,abi
,binary
这三个单词
需要注意的是,在每一次匹配某一节点时,都需要从这个节点开始走一次fail路径,将路径上的flag全部加入答案,否则会出现下面这种情况
下图中包含两个单词:abcde
和bcd
,这种特殊情况就是,其中一个单词完全包含于另一个单词内,且不包括首尾
假设此时主串为"abcde",如果在每一次指向某个节点时就走一遍fail路径,那么最后匹配完只能遇到'e'这个节点,答案只有1
但是很明显,bcd也是包含于"abcde"的,所以必须每次都处理一遍fail路径
只要每次都走一遍,在处理到'd'节点时,'d'的fail指向"bcd"的'd',此时才能把"bcd"的这个flag加入答案
具体匹配方式见代码
代码实现
首先,对于节点的结构体变量定义如下
struct node
{
int flag;
node *next[26],*fail;
};
node *root;
char str[1000050];
其中 flag 记录以当前节点为结尾的单词的个数
flag 与 next 数组跟普通的字典树含义相同
建树 addNode
void addNode()
{
node *nd=root;
int i,id;
for(i=0;str[i]!='\0';i++)
{
id=str[i]-'a';
if(nd->next[id]==NULL)
{
nd->next[id]=new node;
nd->next[id]->flag=0;
for(int j=0;j<26;j++)
nd->next[id]->next[j]=NULL;
}
nd=nd->next[id];
}
nd->flag++;
}
与trie树的建树方式相同,此时对fail指针可以不进行初始化
构造fail指针 buildFailPointer
void buildFailPointer()
{
queue<node*> q; //bfs容器
root->fail=NULL; //根节点fail置空
for(int i=0;i<26;i++)
{
if(root->next[i]!=NULL) //第一层所有出现过的节点的fail全部指向root,并加入队列准备搜索
{
root->next[i]->fail=root;
q.push(root->next[i]);
}
}
while(!q.empty())
{
node *nd=q.front();
q.pop();
for(int i=0;i<26;i++)
{
if(nd->next[i]!=NULL) //如果这个子节点存在
{
node *tmp=nd->fail; //tmp储存当前处理的nd->next[i]的父节点的fail指针
while(tmp!=NULL) //重复迭代
{
if(tmp->next[i]!=NULL) //直到出现某次迭代的节点存在一个子节点,代表的字符与当前处理的nd->next[i]代表的字符相同时,停止迭代
{
nd->next[i]->fail=tmp->next[i]; //那么当前处理的节点的fail就可以指向迭代到的这个节点的对应子节点
break;
}
tmp=tmp->fail; //如果上述子节点不存在,继续迭代fail指针
}
if(tmp==NULL) //如果最后tmp指向NULL,说明最后一次迭代到了root节点且没有找到答案,说明不存在任何前缀与当前的后缀相同,此时让fail指向root节点即可
nd->next[i]->fail=root;
q.push(nd->next[i]); //推入队列
}
}
}
}
因为搜索时处理的节点是nd->next[i],所以nd就是nd->next[i]的父节点
匹配树与主串,询问单词种类数 query
int query()
{
node *nd=root,*tmp;
int ans=0,i,id;
for(i=0;str[i]!='\0';i++)
{
id=str[i]-'a';
while(nd->next[id]==NULL&&nd!=root) //如果nd没有字符为id的子节点的话,说明在这里失配,需要迭代指向fail,如果遇到根节点的话则无法继续迭代直接退出
nd=nd->fail;
if(nd->next[id]!=NULL) //针对于nd为根节点的情况,只有存在字符为id的子节点才改变nd的指向,否则nd继续保持指向根节点
nd=nd->next[id];
tmp=nd; //从nd开始走一遍fail路径,把所有完全包含于当前字符串的单词情况都考虑进来
while(tmp!=root)
{
if(tmp->flag!=0)
{
ans+=tmp->flag;
tmp->flag=0; //一定要置0
}
else
break;
tmp=tmp->fail;
}
}
return ans;
}
nd为当前在字典树上指向的节点,即KMP算法中模式串的光标 j
走fail路径时,如果遇到某个节点的flag为0,说明这条路径之前已经被走过(或者不是单词的结尾),此时就不需要继续走下去了,节省时间
完整代码 HDU-2222
#include<bits/stdc++.h>
using namespace std;
struct node
{
int flag;
node *next[26],*fail;
};
node *root;
char str[1000050];
void addNode()
{
node *nd=root;
int i,id;
for(i=0;str[i]!='\0';i++)
{
id=str[i]-'a';
if(nd->next[id]==NULL)
{
nd->next[id]=new node;
nd->next[id]->flag=0;
for(int j=0;j<26;j++)
nd->next[id]->next[j]=NULL;
}
nd=nd->next[id];
}
nd->flag++;
}
void buildFailPointer()
{
queue<node*> q;
root->fail=NULL;
for(int i=0;i<26;i++)
{
if(root->next[i]!=NULL)
{
root->next[i]->fail=root;
q.push(root->next[i]);
}
}
while(!q.empty())
{
node *nd=q.front();
q.pop();
for(int i=0;i<26;i++)
{
if(nd->next[i]!=NULL)
{
node *tmp=nd->fail;
while(tmp!=NULL)
{
if(tmp->next[i]!=NULL)
{
nd->next[i]->fail=tmp->next[i];
break;
}
tmp=tmp->fail;
}
if(tmp==NULL)
nd->next[i]->fail=root;
q.push(nd->next[i]);
}
}
}
}
int query()
{
node *nd=root,*tmp;
int ans=0,i,id;
for(i=0;str[i]!='\0';i++)
{
id=str[i]-'a';
while(nd->next[id]==NULL&&nd!=root)
nd=nd->fail;
if(nd->next[id]!=NULL)
nd=nd->next[id];
tmp=nd;
while(tmp!=root)
{
if(tmp->flag!=0)
{
ans+=tmp->flag;
tmp->flag=0;
}
else
break;
tmp=tmp->fail;
}
}
return ans;
}
void solve()
{
root=new node;
root->flag=0;
root->fail=NULL;
for(int i=0;i<26;i++)
root->next[i]=NULL;
int n;
scanf("%d",&n);
while(n--)
{
scanf("%s",str);
addNode();
}
buildFailPointer();
scanf("%s",str);
printf("%d\n",query());
}
int main()
{
int T;
scanf("%d",&T);
while(T--)
solve();
return 0;
}