重新整理了一下之前学习AC自动机的知识,顺便写一篇博客记录一下。突然想起上上学期电科ACM集训队的杨教练来给我们开讲座的时候提到电科很多年前有一个队伍靠着AC自动机算法A掉了一道全场几乎没有队伍过的题,那个时候AC自动机的paper才现世不久,是队员保持着经常看前沿算法paper的习惯才看到的,现场出题立马就联想到并且直接复现出来了(orz)~~~
AC自动机的前置知识为trie(字典树),这里不再赘述。
AC自动机是一个有限状态自动机,专为解决多模匹配问题而设计,最原始的问题情境就是“给定n个模式串和1个文本串,求有多少个模式串在文本串里出现过。”传送门P3808 【模板】AC自动机(简单版)
关于AC自动机和KMP算法的对比
我们知道KMP算法是解决单模式串匹配时十分高效的算法,时间复杂度可达
。KMP中的算法核心
数组的求解,其本质是:当我们从左到右扫描文本串和模式串进行模式匹配时(分别用指针
表示),在失配的时候不回溯
,而是去寻找下一个应该与
匹配的
。
根据模式串的最长相同前后缀求得,其实这个也不难理解,当模式串一个失配的时候,指针
之前的位置是匹配的,那么要想模式串滑动一定距离能够到达
位置,那么模式串新的指针
之前的内容必定要和此时
之前的内容一致,所以其实就是一个最长相同前后缀的问题。
而AC自动机中最重要的内容就是
指针的建立,这个跟KMP算法中的
数组是有异曲同工之妙的。当文本串顺着字典树向下走的时候,如果失配了就去找该节点对应字符串的最长后缀继续去匹配;如果匹配成功仍然需要沿着
指针指向的最长后缀字符串继续匹配,看
指针指向的节点是否是一个完整的模式串。(所以其实不论是否失配,在朴素的AC自动机算法中,都是需要不断地跳
指针的)。本质上就是在文本串匹配的一路中碰到的所有可能匹配的字符串都进行一次匹配,只不过由于借助了
指针省去了不必要的一些匹配过程。在求解
指针时,根据DP的思想,字典树下面一层的状态总可以由上面一层的状态转移过来,因此直接BFS一下就OK了。(具体实现见代码)
关于AC自动机的优化( 树)
众所周知,朴素的AC自动机需要不断地跳 指针,由于每一次 指针最多向上跳一层,所以复杂度可达 。我们可以将 指针逆向从而得到 树,然后逆着求状态,从而进行优化。
朴素版AC自动机模板代码(含详细注释)
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6 + 100;
typedef long long ll;
int tot; //trie树节点总数
int trie[maxn][27]; //字典树
int fail[maxn]; //fail指针
int vis[maxn];
int flag[maxn]; //存储该节点的字符串数量
queue<int> Q;
char str[maxn];
int n;
void init(){
int i;
while(!Q.empty()) Q.pop();
for(i=0;i<=tot;++i){
memset(trie[i], 0, sizeof(trie[i]));
flag[i] = 0;
fail[i] = 0;
vis[i] = 0;
}
}
void insert(char *str){ //字典树构建
int i, len = strlen(str), root = 0;
for(i=0;i<len;++i){
int e = str[i] - 'a';
if(!trie[root][e]) trie[root][e] = ++tot;
root = trie[root][e];
}
++flag[root]; //累加出现次数
}
void setFail(){
int i, root = 0;
for(i=0;i<26;++i){
if(trie[root][i]){ //根节点有这个儿子才入队,root没有这个儿子直接移动文本串
Q.push(trie[root][i]);
fail[trie[root][i]] = root; //初始化第二层节点的fail指针为root
}
}
//构建第三层及之后的fail指针
while(!Q.empty()){
int cur = Q.front();Q.pop();
for(i=0;i<26;++i){
if(trie[cur][i]){ //如果存在这个儿子,就将儿子的fail指针指向当前节点的fail指针指向节点的对应儿子
//其实是为了保证当前节点的所有后缀都参与匹配
fail[trie[cur][i]] = trie[fail[cur]][i];
Q.push(trie[cur][i]);
}else{
//否则,直接让这个儿子指向当前节点的fail指针的儿子。这是失配的情形,真正意义的fail。
//这样做是为了当文本串失配时(没有可以与之匹配的情形)可以直接跟当前最长后缀的儿子匹配
//可以理解为强行把别人的儿子当成自己家的
trie[cur][i] = trie[fail[cur]][i];
}
}
}
}
int Query(char *str){ //查询文本串str中存在多少模式串
int len = strlen(str), i, j, root = 0, ans = 0;
for(i=0;i<len;++i){
root = trie[root][str[i] - 'a']; //从文本串中第一个被匹配的字符之后开始找
for(j=root;j && !vis[j];j=fail[j]){ //把其后缀字符串全部找一遍
ans += flag[j]; //累加答案
vis[j] = 1;
}
}
return ans;
}
int main(){
int t, i, j;
ios::sync_with_stdio(false);
init();
cin>>n;
for(i=1;i<=n;++i){
cin>>str;
insert(str);
}
cin>>str;
setFail();
cout<<Query(str);
return 0;
}