AC自动机
AC自动机
引入:
我们之前对于字符串,学过了\(kmp\)匹配和\(trie\)树的写法,但是我们知道,这样的写法只能适用于两个字符串相互比较,并不能多个。
因此,我们引入了AC自动机这个概念。
AC自动机的意思并不是让你自动AC,而是一种匹配字符串的方法。
实例:
给定 \(n\) 个模式串和 \(1\) 个字符串,问有多少个模式串在字符串中出现过。
首先肯定是先建 \(trie\) 树。
如果我们直接进行匹配,那么就会出现一个很尴尬情况:到达最底部或者无法匹配。
按照原本的想法,我们只能从根开始继续匹配。
但是跟这种虫豸一样的暴力呆在一起,怎么能学好 \(OI\)?
我们根据 \(KMP\) 的思想,更好的算法是从另一棵树上的相同最后一位匹配的字符开始匹配。
怎么确定从哪个点开始匹配,这就用到了 \(AC\) 自动机中最重要的——\(Fail\)指针。
求 \(Fail\)
\(Fail\) 指针的含义:最长的当前字符串的后缀在 \(Trie\) 上可以查找到的末尾编号。
首先我们可以确定:每一个点 \(i\) 的 \(fail\) 指针指向的点的深度一定是比 \(i\) 小的。
第一层 \(fail\) 就是根节点。
接下来的解决过程:
我们先把这一层所有的存在的节点加入队列,然后枚举队列里每一个元素:
首先搜索这个节点对应的子 \(trie\) 树:
- 存在子节点,让这个节点的失败指针指向(((他父亲节点)的失败指针所指向的那个节点)的下一个节点
if(c[x][i]){
fail[c[x][i]]=c[fail[x]][i];
q.push(c[x][i]);//存在子树,就压入队列
}
- 不存在这个子节点,就让这个子节点指向当前节点 \(fail\) 指针的子节点。
c[x][i]=c[fail[x]][i];
这样枚举完,我们就成功建好了一个 \(AC\) 自动机!
void ins(char *s){
int len=strlen(s);int now=0;//计算字符串长度
for(int i=0;i<len;i++){//字符串一位一位去搜索
int x=s[i]-'a';//赋值
if(!c[now][x]) c[now][x]=++cnt;//如果本身不存在,把它弄到一个新的地址,可以说是再建一棵树,同时记录拓扑序
now=c[now][x];//指向地址(旧/新)
}
val[now]=1;//标记单词结尾
}
void build(){
for(int i=0;i<26;i++)//第一层是根节点0
if(c[0][i])//遍历第二层,搜索存在的子树
fail[c[0][i]]=0,q.push(c[0][i]);//fail指向根节点
while(q.size()){
int x=q.front();q.pop();
for(int i=0;i<26;i++){//搜索这一棵树
if(c[x][i]){
fail[c[x][i]]=c[fail[x]][i];//让这个节点的失败指针指向(((他父亲节点)的失败指针所指向的那个节点)的下一个节点)
q.push(c[x][i]);//存在子树,就压入队列
}
else
c[x][i]=c[fail[x]][i];//否则就让这个子节点指向当前节点fail指针的子节点
}
}
}
剩下的\(query\)查询操作因题而异,但基本上就是在\(trie\)树的基础上去寻找字符串。
int query(char *s){
int len=strlen(s);int now=0,ans=0;
for(int i=0;i<len;i++){//遍历文本串
now=c[now][s[i]-'a'];//从s[i]点开始寻找
for(int t=now;t&&~val[t];t=fail[t])//一直向下寻找,直到匹配失败
ans+=val[t],val[t]=-1;//将遍历过的节点标记,防止重复
}
return ans;
}
例题:
洛谷P3796
题意:
给你\(n\)个字符串,再给你一个字符串,要求求出这 \(n\) 个字符串中在后面给的那个字符串中出现最多次的次数,以及输出对应的字符串。
解题方法:
我们可以将这 \(n\) 个字符串经行建造 \(ac\) 自动机。
然后让这个字符串在这个 \(ac\) 自动机中去跑,用 \(val\) 记录一下每个字符串的末端。如果跑到一次末端,就在对应末端的 \(ans[i]++\)。
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=50005;
int n,ans[N];
queue<int> q;
char p[152][1005];
struct AC{
int fail[N*10],val[N],cnt,c[N][26];
//......模板部分,直接Ctrl+C前面构造部分即可
void query(char *s){
int now=0,len=strlen(s);
for(int i=0;i<len;i++){
now=c[now][s[i]-'a'];
for(int j=now;j;j=fail[j])
ans[val[j]]++;//有单词结尾就++;
}
}
}AC;
void init(){
while(q.size()) q.pop();AC.cnt=0;memset(ans,0,sizeof(ans));
memset(AC.fail,0,sizeof(AC.fail));memset(AC.c,0,sizeof(AC.c));memset(AC.val,0,sizeof(AC.val));
}
char ch[1000005];
int maxx=0;
int main()
{
while(scanf("%d",&n)){
init();
if(n==0) break;
for(int i=1;i<=n;i++) scanf("%s",p[i]),AC.ins(p[i],i);
AC.build();
scanf("%s",ch);
AC.query(ch);
for(int i=1;i<=n;i++) maxx=max(maxx,ans[i]);
cout<<maxx<<endl;
for(int i=1;i<=n;i++)
if(ans[i]==maxx)
printf("%s\n",p[i]);
}
system("pause");
return 0;
}
优化:
分析时间复杂度,暴力跳 \(fail\) 指针最坏时间复杂度为 \(O(len(原串)*len(现串))\)
如果我们每一个 \(trie\) 上的点不止经过一次(即重复算),那么时间复杂度会炸。
考虑优化:
拓扑排序:
我们把指针看成有向边,如果按照之前的方式,例如有一个 \(fail\) 指针为:
我们操作 \(3\),同时也要更新 \(5,7\) 。 操作 \(5\) ,也要更新 \(7\).
当所有标记一次性传递上来,更新其他点的答案,会减少很多时间。
还是刚才那个例子,在 \(3\) 打一个 \(ans=1\) ,在 \(5\) 打一个 \(ans=1\), 最后直接跳\(fail\) 赋值,得到三个节点答案:\(1,2,2\).
上面构造时,我们记录了每个节点的 \(dfs\) 序:
if(!c[now][x]) c[now][x]=++cnt;
打了标记后,肯定是从深度大的点开始更新上去的,考虑拓扑排序,将每一个点向其 \(fail\) 指针连一条边,一定是个 \(DAG\) 。
直接遍历图,即可更新答案。
例题:
P5357 【模板】AC自动机(二次加强版)
前面没什么不同,运用这个优化:
建出 \(fail\) 树,对当前匹配串进行打标记,也就是标记匹配的情况,然后进行标记的推广(如果在同一条路径上,说明都能进行匹配,只是省略了直接算的过程,类似于前缀和)
记录自动机上的每个状态被匹配了几次,最后求出每个模式串在 \(Trie\) 上的终止节点在 \(fail\) 树上的子树总匹配次数就可以了。
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n;char p[N*10],s[N*10];
int c[N][26],cnt=1,fail[N],val[N];
queue<int> q;
struct AC{
void ins(char *s,int v){
int len=strlen(s);int now=1;
for(int i=0;i<len;i++){
int x=s[i]-'a';
if(!c[now][x]) c[now][x]=++cnt;
now=c[now][x];
}
val[v]=now;//标记单词结尾
}
void build(){
for(int i=0;i<26;i++) c[0][i]=1;
q.push(1);
while(q.size()){
int x=q.front();q.pop();
for(int i=0;i<26;i++){
if(c[x][i])
fail[c[x][i]]=c[fail[x]][i],q.push(c[x][i]);
else
c[x][i]=c[fail[x]][i];
}
}
}
}AC;
int times[N];
int nxt[N],head[N],ver[N],tot;
void add(int x,int y){
ver[++tot]=y;
nxt[tot]=head[x];
head[x]=tot;
}
void dfs(int x){
for(int i=head[x];i;i=nxt[i]){
int y=ver[i];
dfs(y);
times[x]+=times[y];//记录自动机上的每个状态被匹配了几次
}
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
scanf("%s",p),AC.ins(p,i);
AC.build();
scanf("%s",s);
for(int now=1,i=0;s[i];i++){
now=c[now][s[i]-'a'];
++times[now];//记录匹配的次数
}
for(int i=2;i<=cnt;i++) add(fail[i],i);
dfs(1);
for(int i=1;i<=n;i++) printf("%d\n",times[val[i]]);//每个模式串在 Trie 上的终止节点在 fail 树上的子树总匹配次数
system("pause");
return 0;
}

浙公网安备 33010602011771号