一、AC自动机详细图解
-
首先根据模式串建立\(Trie\)树。
-
什么是失配指针?
\(AC\)自动机就是在\(Trie\)树的基础上,增加一个失配时候用的ne指针,如果当前点匹配失败,则将指针转移到ne指针指向的地方,这样就可以不用重头再来,尽可能的利用已经知道的信息,能少退就少退。一般,ne 指针的构建都是用 bfs 实现的。
我们需要只遍历一遍文本串(母串或长串)就找出所有单词表中存在的单词(只遍历一遍的想法和KMP算法有异曲同工之妙)。
我们先根据字符集合{she,he,say,shr,her}建立字典树如上图所示,然后我们拿着yasherhs去匹配,发现前两个字符无法匹配,跳过,第三个字符开始,she可以匹配,记录下来,继续往后走发现没有匹配了,结果就是该文本串只存在一个单词,很明显,答案是错的,因为存在she、he、her三个单词!!
可以发现的是使用文本串在字典树上进行匹配的时候,找到了一个单词结点后还应该看看有没有以该单词结点的后缀为前缀的其他单词,比如she的后缀he是单词he和her的前缀。因此就需要一个失配指针在发现失配的时候指向其他存在e的结点,来“安排”之后应该怎么办。
总的来说,AC自动机中失配指针和\(KMP\)中ne数组的作用是一致的,就是要想在只遍历一遍文本串的前提下,找到全部匹配模板,就必须安排好匹配过程中失配后怎么办。具体如何安排就是怎么在字典树上加失配边的问题了(也即如何构造一个\(AC\)自动机)。
- 如何创建失配指针?
规则如下:
-
根结点的
ne指针为空(或者它自己); -
直接和根结点相连的结点,如果这些结点失配,就只能重新开始匹配,故它们的
ne指针指向根结点; -
其他结点,设当前结点为
father,其孩子结点为child。要寻找child的ne指针,需要看father的'ne'指针指向的结点,假设是tmp,要看tmp的孩子中有没有和child所代表的字符相同的,有则child的ne指针指向tmp的这个孩子结点,没有则继续沿着tmp的ne指针往上走,如果找到相同,就指向,如果一直找到了根结点的ne也就是空的时候,child的ne指针就指向root,表示重新从根结点开始匹配。
其中考察father的ne指针指向的结点有没有和child相同的结点,包括继续往上,就保证了前缀是相同的,比如刚才寻找右侧h的孩子结点e的ne指针时,找到右侧h的ne指针指向左侧的h结点,他的孩子中有e,就将右侧h的孩子e的fail指针指向它就保证了前缀h是相同的。
这样,就用ne指针来安排好每次失配后应该跳到哪里,而ne指针跳到哪里,说明从根结点到这个结点之前的字符串已经匹配过了,从而避免了重复匹配,也就完美的解决了只遍历一次文本串就找出所有单词的问题。
其实,这是一个不断回溯的过程,在代码实现时,我们一般采用的是\(while\)循环或者递归来不断的向上进行查找,但这样效率上会差一些。有没有更好的办法呢?这得益于我们采用的\(bfs\)策略,我们通过自顶向下的覆盖方式,将上面的信息继承到下面去,换句话说,就是不用儿子去找父亲问,父亲再找爷爷问,这太麻烦了,而且爷爷将答案准备好,告诉父亲,父亲将答案保存好,儿子可以直接获取到。这时就不再是一个\(Trie\)树了,而是一个\(Trie\)图了。有点类似于并查集的路径压缩~
四、完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 10010 * 55; //模式串最长长度,短串
const int M = 1e6 + 10; //长度为m的文章,长串
int n;
//tr:trie树,每个结点最多26个儿子
//cnt:trie的每个结点存在的以此结点结尾的字符串个数
//idx:游标变量,结点号
int tr[N][26], cnt[N], idx;
string str;//字符串变量,前面用做输入模式串,最后一个是文章串
//AC自动机自己的数据结构 q:bfs用的队列 ne:失配指针
int q[N], ne[N];
bool st[N]; //是不是访问过
//构建Trie树
void insert() {
int p = 0; //游标变量
for (int i = 0; i < str.size(); i++) { //遍历模式字符串
int t = str[i] - 'a'; //计算对应的索引号
if (!tr[p][t]) tr[p][t] = ++idx; //如果不存在这个结点,则创建之
p = tr[p][t]; //走进去
}
cnt[p]++; //打上字符串完结标识
}
//构建AC自动机(填充失配指针)
void build() {
int hh = 0, tt = -1;//清空队列,所以前面不需要每次都memset清空q
for (int i = 0; i < 26; i++) //查找root的所有26个可能存在的儿子
if (tr[0][i]) //如果该儿子存在
q[++tt] = tr[0][i]; //把这个儿子放入队列中
while (hh <= tt) { //bfs框架
int p = q[hh++]; //取出队列头,父结点
for (int i = 0; i < 26; i++) { //尝试找出该结点的所有儿子
int c = tr[p][i]; //子结点
//如果不存在,这个的失配指针指向,父节点的失配指针对应的相同字节点下
if (!c) tr[p][i] = tr[ne[p]][i];
else {
//存在依旧指向父节点的失配指针下的同子节点
ne[c] = tr[ne[p]][i];
q[++tt] = c;//放入队列
}
}
}
}
int main() {
int T;
cin >> T;
while (T--) {
//多组数据,每次清空
memset(st, 0, sizeof st);
memset(tr, 0, sizeof tr);
memset(cnt, 0, sizeof cnt);
memset(ne, 0, sizeof ne);
idx = 0;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> str;
//构建Trie树
insert();
}
//构建AC自动机
build();
//读入文章长串
cin >> str;
int res = 0;
//j记录当前树节点的指针,初始是根节点
for (int i = 0, j = 0; i < str.size(); i++) { //枚举总串str的每一个字母
int u = str[i] - 'a';
j = tr[j][u]; //跳到下一个树节点
int p = j; //每次从当前树节点开始
while (!st[p] && p) {
res += cnt[p]; //累加命中的模式串个数
cnt[p] = 0; //去除标记
st[p] = true; //设置已统计过
p = ne[p]; //继续查询
}
}
printf("%d\n", res);
}
return 0;
}