学习博客:https://www.cnblogs.com/cmmdc/p/7337611.html
学习博客:https://blog.csdn.net/bestsort/article/details/82947639
思路看的是第一篇博客,代码用的第二篇博客,本人不喜欢用指针。。。
首先简要介绍一下AC自动机:Aho-Corasick automation,该算法在1975年产生于贝尔实验室,是著名的多模匹配算法之一。一个常见的例子就是给出n个单词,再给出一段包含m个字符的文章,让你找出有多少个单词在文章里出现过。要搞懂AC自动机,先得有模式树(字典树)Trie和KMP模式匹配算法的基础知识。KMP算法是单模式串的字符匹配算法,AC自动机是多模式串的字符匹配算法。
AC自动机和字典树的关系比较大,所以先来简单的了解下字典树Trie。
字典树又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
简而言之:字典树就是像平时使用的字典一样的,我们把所有的单词编排入一个字典里面,当我们查找单词的时候,我们首先看单词首字母,进入首字母所再的树枝,然后看第二个字母,再进入相应的树枝,假如该单词再字典树中存在,那么我们只用花费单词长度的时间查询到这个单词。
AC自动机关键点一:字典树的构建过程:
字典树的构建过程是这样的,当要插入许多单词的时候,我们要从前往后遍历整个字符串,当我们发现当前要插入的字符其节点再先前已经建成,我们直接去考虑下一个字符即可,当我们发现当前要插入的字符没有再其前一个字符所形成的树下没有自己的节点,我们就要创建一个新节点来表示这个字符,接下往下遍历其他的字符。然后重复上述操作。
假设我们有下面的单词,she , he ,say, her, shr ,我们要构建一棵字典树
AC自动机关键点二:找Fail指针
在KMP算法中,当我们比较到一个字符发现失配的时候我们会通过next数组,找到下一个开始匹配的位置,然后进行字符串匹配,当然KMP算法试用与单模式匹配,所谓单模式匹配,就是给出一个模式串,给出一个文本串,然后看模式串在文本串中是否存在。
在AC自动机中,我们也有类似next数组的东西就是fail指针,当发现失配的字符失配的时候,跳转到fail指针指向的位置,然后再次进行匹配操作,AC自动机之所以能实现多模式匹配,就归功于Fail指针的建立。
当前节点t有fail指针,其fail指针所指向的节点和t所代表的字符是相同的。因为t匹配成功后,我们需要去匹配t->child,发现失配,那么就从t->fail这个节点开始再次去进行匹配。
Fail指针的求法:
Fail指针用BFS来求得,对于直接与根节点相连的节点来说,如果这些节点失配,他们的Fail指针直接指向root即可,其他节点其Fail指针求法如下:
假设当前节点为father,其孩子节点记为child。求child的Fail指针时,首先我们要找到其father的Fail指针所指向的节点,假如是t的话,我们就要看t的孩子中有没有和child节点所表示的字母相同的节点,如果有的话,这个节点就是child的fail指针,如果发现没有,则需要找father->fail->fail这个节点,然后重复上面过程,如果一直找都找不到,则child的Fail指针就要指向root。
如图所示,首先root最初会进队,然后root,出队,我们把root的孩子的失败指针都指向root。因此图中h,s的失败指针都指向root,如红色线条所示,同时h,s进队。
接下来该h出队,我们就找h的孩子的fail指针,首先我们发现h这个节点其fail指针指向root,而root又没有字符为e的孩子,则e的fail指针是空的,如果为空,则也要指向root,如图中蓝色线所示。并且e进队,此时s要出队,我们再找s的孩子a,h的fail指针,
我们发现s的fail指针指向root,而root没有字符为a的孩子,故a的
fail指针指向root,a入队,然后找h的fail指针,同样的先看s的fail指针是root,发现root又字符为h的孩子,所以h的fail指针就指向了第二层的h节点。e,a , h 的fail指针的指向如图蓝色线所示。
此时队列中有e,a,h,e先出队,找e的孩子r的失败指针,我们先看e的失败指针,发现找到了root,root没有字符为r
的孩子,则r的失败指针指向了root,并且r进队,然后a出队,我们也是先看a的失败指针,发现是root,则y的fail指针就会指向root.并且y进队。然后h出队,考虑h的孩子e,则我们看h的失败指针,指向第二层的h节点,看这个节点发现有字符值为e的节点,最后一行的节点e的失败指针就指向第三层的e。最后找r的指针,同样看第二层的h节点,其孩子节点不含有字符r,则会继续往前找h的失败指针找到了根,根下面的孩子节点也不存在有字符r,则最后r就指向根节点,最后一行节点的fail指针如绿色虚线所示。
AC自动机关键点三:文本串的匹配
匹配过程分两种情况:
(1)当前字符匹配,表示从当前节点沿着树边有一条路径可以到达目标字符,如果当前匹配的字符是一个单词的结尾,我们可以沿着当前字符的fail指针,一直遍历到根,如果这些节点末尾有标记(此处标记代表,节点是一个单词末尾的标记),这些节点全都是可以匹配上的节点。我们统计完毕后,并将那些节点标记。此时只需沿该路径走向下一个节点继续匹配即可,目标字符串指针移向下个字符继续匹配;
(2)当前字符不匹配,则去当前节点失败指针所指向的字符继续匹配,匹配过程随着指针指向root结束。重复这2个过程中的任意一个,直到模式串走到结尾为止。
对照上图,看一下模式匹配这个详细的流程,其中模式串为yasherhs。对于i=0,1。Trie中没有对应的路径,故不做任何操作;i=2,3,4时,指针p走到左下节点e。因为节点e的count信息为1,所以cnt+1,并且讲节点e的count值设置为-1,表示改单词已经出现过了,防止重复计数,最后temp指向e节点的失败指针所指向的节点继续查找,以此类推,最后temp指向root,退出while循环,这个过程中count增加了2。表示找到了2个单词she和he。当i=5时,程序进入第5行,p指向其失败指针的节点,也就是右边那个e节点,随后在第6行指向r节点,r节点的count值为1,从而count+1,循环直到temp指向root为止。最后i=6,7时,找不到任何匹配,匹配过程结束。
AC自动机算法分为3步:构造一棵Trie树,构造失败指针和模式匹配过程。
如果你对KMP算法和了解的话,应该知道KMP算法中的next函数(shift函数或者fail函数)是干什么用的。KMP中我们用两个指针i和j分别表示,A[i-j+ 1..i]与B[1..j]完全相等。也就是说,i是不断增加的,随着i的增加j相应地变化,且j满足以A[i]结尾的长度为j的字符串正好匹配B串的前 j个字符,当A[i+1]≠B[j+1],KMP的策略是调整j的位置(减小j值)使得A[i-j+1..i]与B[1..j]保持匹配且新的B[j+1]恰好与A[i+1]匹配,而next函数恰恰记录了这个j应该调整到的位置。同样AC自动机的失败指针具有同样的功能,也就是说当我们的模式串在Tire上进行匹配时,如果与当前节点的关键字不能继续匹配的时候,就应该去当前节点的失败指针所指向的节点继续进行匹配。
看代码:
#include<iostream> #include<cstdio> #include<cstring> #include<algorithm> #include<cmath> #include<vector> #include<map> #include<queue> using namespace std; typedef long long LL; const LL mod=1e9+7; const LL INF=1e9+7; const int maxn=2e3+50; int trie[maxn][30];//字典树 int cntword[maxn];//记录该单词出现的次数 int fail[maxn];//失败时的回溯指针 int cnt=0; void insertWords(string s) { int root=0; for(int i=0;i<s.size();i++)//以这些单词建树 { int nex=s[i]-'a';// if(!trie[root][nex]) trie[root][nex]=++cnt;//这个字母在这一层没有出现过 存起来 值为它对应的编号 root=trie[root][nex];//出现过 不需要存 } cntword[root]++;//根就是当前单词的最末尾 也就代表当前单词 } void getFail()//存fail数组 { queue<int>q; for(int i=0;i<26;i++) { if(trie[0][i])//讲第二层所有字母扔进队列 也就是所有单词的首个单词 { fail[trie[0][i]]=0;//失败时自然回溯为0 q.push(trie[0][i]); } } while(!q.empty()) { int now=q.front(); q.pop(); for(int i=0;i<26;i++)//遍历以该节点为根的所有子节点 { if(trie[now][i])//子节点存在 { fail[trie[now][i]]=trie[fail[now]][i];//失败节点指向 它父亲的失败指针 的和本身相同的点 q.push(trie[now][i]); } //不存在 编号赋值为它父亲失败时 和本身相同的结点(也就方便了下面查找的时候的跳跃) else trie[now][i]=trie[fail[now]][i];//当前节点的子节点 指向 当前节点失败失败时 的和本身相同的点 } } } int query(string s) { int now=0,ans=0;//now 当前在哪 for(int i=0;i<s.size();i++)//遍历文本串 { now=trie[now][s[i]-'a'];//从s[i]点开始寻找 for(int j=now;j&&cntword[j]!=-1;j=fail[j]) { //一直向下寻找 直到匹配失败(失败指针指向根 或者 当前节点已经找过) ans+=cntword[j]; cntword[j]=-1; } } return ans; } int main() { int N; string s; cin>>N; for(int i=0;i<N;i++) { cin>>s; insertWords(s); } fail[0]=0; getFail(); cin>>s; cout<<query(s)<<endl; return 0; }