2.5AC自动机
是什么
- AC自动机,著名的多模式串匹配算法之一
- 简单来说,AC自动机就是在Trie树上进行KMP
- 通常用于解决在m给字符中查询n个单词出现的次数
实现(以ybtoj模板题为例)
- 借助Trie树进行建树
void add(char *s){
int u=1,len=strlen(s);
for(int i=0;i<len;i++){
int c=s[i]-'a';
if(!ch[u][c]){
ch[u][c]=++cnt;
memset(ch[cnt],0,sizeof(ch[cnt]));//点cnt的c指针指向节点在上一次已经被更新,需要先清零
}
u=ch[u][c];
}
++bo[u];
}
- 预处理fail指针
- 定义nxt指针:记root到i为字符串A,root到j为字符串T,nxt[i]=j表示T是Trie树上A的最长后缀
- 如何构建nxt数组:
- 一个显然的性质:nxt[u]的深度一定小于u
- 因此,可以用BFS保证nxt[u]在u之前被遍历,并用u更新子节点的nxt
- 记u的字节点为v,u->v为C指针
- 对u节点不断跳nxt,直到nxt指向子节点w的指针也为C指针时停止,将nxt[v]=w
- 特殊处理:建立0号节点,使nxt[root]=1,并将0号节点的所以指针指向root,这样就可以在不加特判的情况下使没有后缀的节点都指向root
void bfs(){//失配指针
for(int i=0;i<26;i++) ch[0][i]=1;//建立0号节点
queue<int>q;
q.push(1);
nxt[1]=0;
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=0;i<26;i++){
if(!ch[u][i])//将指向空的节点指向失配节点,方便else中操作
ch[u][i]=ch[nxt[u]][i];//相当于并查集中的路径压缩的思想对nxt进行缩短
else{
nxt[ch[u][i]]=ch[nxt[u]][i];//用u节点更新子节点ch[u][i]的nxt
q.push(ch[u][i]);
}
}
}
}
- 仿照KMP对文章进行查询
- 由于我们在预处理Fail指针时就已经处理好每个节点该往哪里跳了(
if(!ch[u][i])ch[u][i]=ch[nxt[u]][i];//相当于并查集中的路径压缩的思想对nxt进行缩短
),所以不需要考虑失配情况,直接在Trie树上遍历nxt节点,统计遇到的结束标记的数量即可
void find(char *s){
int u=1,len=strlen(s),k;
for(int i=0;i<len;i++){
int c=s[i]-'a';
k=ch[u][c];
while(k>1&&bo[k]!=-1){//不断跳nxt到头,统计经过的结束标记数
ans+=bo[k];
bo[k]=-1;//!!!清空,进行标记,放置重复统计
k=nxt[k];
}
u=ch[u][c];
}
}
注意应对多组测试数据的清零操作
例题
例题1:单词统计(ybtoj模板)
接上,代码如下
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6+10;
const int M = 1e6+10;
const int C = 30;
int cnt=1,ch[N][C],bo[N],n,nxt[N],t,ans=0,que[N];//nxt:失配指针 bo:结束节点
char s[M];
void add(char *s){
int u=1,len=strlen(s);
for(int i=0;i<len;i++){
int c=s[i]-'a';
if(!ch[u][c]){
ch[u][c]=++cnt;
memset(ch[cnt],0,sizeof(ch[cnt]));//点cnt的c指针指向节点在上一次已经被更新,需要先清零
}
u=ch[u][c];
}
++bo[u];
}
void bfs(){//失配指针
for(int i=0;i<26;i++) ch[0][i]=1;//建立0号节点
queue<int>q;
q.push(1);
nxt[1]=0;
while(!q.empty()){
int u=q.front();
q.pop();
for(int i=0;i<26;i++){
if(!ch[u][i])ch[u][i]=ch[nxt[u]][i];//相当于并查集中的路径压缩的思想对nxt进行缩短
else{
nxt[ch[u][i]]=ch[nxt[u]][i];//用u节点更新子节点ch[u][i]的nxt
q.push(ch[u][i]);
}
}
}
}
void find(char *s){
int u=1,len=strlen(s),k;
for(int i=0;i<len;i++){
int c=s[i]-'a';
k=ch[u][c];
while(k>1&&bo[k]!=-1){//不断跳nxt到头,统计经过的结束标记数
ans+=bo[k];
bo[k]=-1;//!!!清空,进行标记,放置重复统计
k=nxt[k];
}
u=ch[u][c];
}
}
int main(){
scanf("%d",&t);
while(t--){
memset(nxt,0,sizeof(nxt));
memset(bo,0,sizeof(bo));
cnt=1;
ans=0;
for(int i=0;i<26;i++){//初始化0号与1号节点
ch[0][i]=1;
ch[1][i]=0;
}
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%s",s);
add(s);
}
bfs();
scanf("%s",s);
find(s);
printf("%d\n",ans);
}
return 0;
}
洛谷模板P3808 【模板】AC 自动机(简单版)
删去多组数据的特殊处理即可#include<bits/stdc++.h> using namespace std; const int N = 1e6+10; const int M = 1e6+10; const int C = 30; int cnt=1,ch[N][C],bo[N],n,nxt[N],t,ans=0,que[N];//nxt:失配指针 char s[M]; void add(char *s){ int u=1,len=strlen(s); for(int i=0;i<len;i++){ int c=s[i]-'a'; if(!ch[u][c]){ ch[u][c]=++cnt; memset(ch[cnt],0,sizeof(ch[cnt]));//点cnt的c指针指向节点在上一次已经被更新,需要先清零 } u=ch[u][c]; } ++bo[u]; } void bfs(){//失配指针 for(int i=0;i<26;i++) ch[0][i]=1;//建立0号节点 queue< int >q; q.push(1); nxt[1]=0; while(!q.empty()){ int u=q.front(); q.pop(); for(int i=0;i<26;i++){ if(!ch[u][i])ch[u][i]=ch[nxt[u]][i]; else{ nxt[ch[u][i]]=ch[nxt[u]][i];//用u节点更新子节点ch[u][i]的nxt q.push(ch[u][i]); } } } } void find(char *s){ int u=1,len=strlen(s),k; for(int i=0;i<len;i++){ int c=s[i]-'a'; k=ch[u][c]; while(k>1&&bo[k]!=-1){//不断跳nxt到头,统计经过的结束标记数 ans+=bo[k]; bo[k]=-1;//!!!清空 k=nxt[k]; } u=ch[u][c]; } } int main(){ scanf("%d",&n); for(int i=1;i<=n;i++){ scanf("%s",s); add(s); } bfs(); scanf("%s",s); find(s); printf("%d\n",ans); cnt=1; return 0; }
例题2:单词频率
- 思路:
- 首先,将n个单词存入Trie树,方便统计
- 考虑如何统计每个单词在文章中出现的次数:
- 首先分析一个简单的例子:
3 aaa aa a
- 对于a,它只有a一个前缀,该前缀也只有一个后缀a,该前缀的后缀与第三个字符串相同,即第三个字符串出现一次
- 对于aa,分别分析a,aa两个前缀
- a:只有一个后缀该前缀的后缀与第三个字符串相同,即第三个字符串又出现一次
- aa:它的一个后缀a与第三个字符串相同,即第三个字符串又又出现一次;它的另一个后缀aa与第二个字符串相同,即第二个字符串出现一次
- 对于aaa,分别分析a,aa,aaa三个前缀
- 前两个前缀与上一种情况分别相同,故第三个字符串又又又又出现了且第二个字符串又出现一次
- aaa:这个前缀的一个后缀aaa与第一个字符串相同,故第一个字符串出现一次;另一个后缀aa与第二个字符串相同,故第二个字符串又又出现一次;第三个后缀a与第三个字符串相同,故第三个字符串又又又又又出现一次
综上所述,a出现6次,aa出现3次,aaa出现1次
- 我们发现:
- 上述过程可以推广到全部字符串求解
- 上述过程中,由于AC自动机的性质,巧了,求前缀恰好相当于Trie树从root dfs遍历的过程,而求后缀的过程恰好相当于在树上不断跳失配指针的过程
- 因此,我们可以先处理好AC自动机,在建树的过程中新建一个sum数组,记录每个点在文章中出现的次数(即在访问到时将该点sum++),在更新fail指针时记录经过节点,对这些节点进行后缀和统计即可
所以这也是个板子
void bfs(){
for(int i=0;i<=25;i++){
tr[0][i]=1;
}
que[1]=1;
nxt[1]=0;
for(int q1=1,q2=1;q1<=q2;q1++){
int u=que[q1];
for(int i=0;i<=25;i++){
if(!tr[u][i])tr[u][i]=tr[nxt[u]][i];//将指向空的节点指向失配节点,方便else中操作
else{
que[++q2]=tr[u][i];
nxt[tr[u][i]]=tr[nxt[u]][i];//失配指针指向u节点的nxt节点的i指针指向的节点
}
}
}
//从后向前遍历,更新子树中次数和
//操作方法为先将sum数组复制到sum1中,对sum1进行“后缀和”
for(int i=cnt;i>=0;i--)sum1[que[i]]=sum[que[i]];
for(int i=cnt;i>=0;i--)sum1[nxt[que[i]]]+=sum1[que[i]];
}
- 代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 5200005;
int n,tr[N][30],nxt[N],pos[N],cnt=1/*记录Trie树的节点数*/,sum[N]/*sum[i]记录以root->i为前缀的字符串的数量*/,sum1[N]/*sum的“后缀和”*/,tot/*记录字符串个数*/,que[N];
char ch[N];
void add(char *s){
int u=1,len=strlen(s);
for(int i=0;i<len;i++){
int C=s[i]-'a';
if(!tr[u][C]) tr[u][C]=++cnt;
u=tr[u][C];
sum[u]++;//沿途节点sum++
}
pos[++tot]=u;
}
void bfs(){
for(int i=0;i<=25;i++){
tr[0][i]=1;
}
que[1]=1;
nxt[1]=0;
for(int q1=1,q2=1;q1<=q2;q1++){
int u=que[q1];
for(int i=0;i<=25;i++){
if(!tr[u][i])tr[u][i]=tr[nxt[u]][i];//将指向空的节点指向失配节点,方便else中操作
else{
que[++q2]=tr[u][i];
nxt[tr[u][i]]=tr[nxt[u]][i];//失配指针指向u节点的nxt节点的i指针指向的节点
}
}
}
//从后向前遍历,更新子树中次数和
//操作方法为先将sum数组复制到sum1中,对sum1进行“后缀和”
for(int i=cnt;i>=0;i--)sum1[que[i]]=sum[que[i]];
for(int i=cnt;i>=0;i--)sum1[nxt[que[i]]]+=sum1[que[i]];
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%s",ch);
add(ch);
}
bfs();
for(int i=1;i<=n;i++){
printf("%d\n",sum1[pos[i]]);
}
return 0;
}