一、算法概述
AC自动机是一种高级数据结构算法,用来解决部分字符串问题:给定若干个模式字符串和一个总字符串,AC自动机可以在O(n)的时间复杂度下计算出每个模式字符串在总字符串中出现的次数、出现的位置。
二、算法结构分析
AC自动机本质上是使用Tire树+BFS对KMP算法的优化,在学习AC自动机之前,需要了解基础的KMP算法、Tire树知识;
接下来将以:①KMP算法、②Tire树、③AC自动树的顺序进行分析:
(一)前置数据结构——KMP算法
附上一道题目作为背景:
1.KMP算法简介
在O(n)的时间复杂度内计算出一个模式字符串在总字符串中出现的次数、出现的位置。
2.朴素做法:O()级别
如图所示:暴力做法为不断将匹配的起点向后移动,直到模式串能够成功匹配,时间复杂度为O()级别。
3.KMP优化做法:O(n)级别
next数组是对模式串进行处理得到的:
①从构造过程的角度看:next[i]是满足p[1~(i-1)]的前缀等于其后缀的最大字符数;
②从匹配过程的角度看:next[i]是当p[i]和S[j]不匹配时,下一次匹配可以直接匹配S[j+1]和p[next[i]+1];
从构造next数组的过程中,我将会分析为什么可以提前知道p[1-2]和S[2-3]相等,而p[1]和S[3]不相等:
(二)前置数据结构——Tire树算法
附上一道题目作为背景:
1.Trie树算法简介
使用多叉树的结构存储多个字符串,用空间换时间,主要用途为进行字符串的词频统计(维护一个字符串集合(可重复),支持以O(n)时间复杂度查询每一种字符串在集合中的数量以及支持插入新的字符串)。
2.Trie树构造过程
3.具体功能
(核心代码:p=tr[p][t]) t为字符串当前字符-’a’的值
①存入字符串
按照字符串的顺序走树,如果没有符合条件的子节点,直接创建一个编号唯一的子节点,到达的最后一个子节点的cnt+1;
②查询词频
按照字符串的顺序走树,走到最后的子节点的cnt值为改字符串的出现次数。
(三)AC自动机
附上两道题目作为背景:
1.与前两个算法的关系
上文提到:KMP算法可以在O(n)的时间复杂度内计算出一个模式字符串在总字符串中出现的次数、出现的位置;但是当模式串有多个时,KMP算法对每个模式串都要先求出next数组,再遍历一次总串,时间复杂度再次退化成O()级别;所以AC自动树是在KMP思想的基础上,将KMP处理多模式串问题时的时间复杂度优化到O(n)级别的算法:核心的方法就是将KMP的一维next数组以Tire树的形式扩展到二维,再进行配对;
2.AC自动机具体流程
①用模式串构造一棵Tire树;
②构造fail指针,使当前字符失配时跳转到具有最长公共前后缀的字符继续匹配。和 KMP算法一样, AC自动机在匹配时如果当前字符匹配失败,那么利用fail指针进行跳转。由此可知如果跳转,跳转后的串的部分前缀和跳转前的模式串的部分后缀相同,并且跳转的新位置的深度(匹配字符个数)一定小于跳转之前的节点。因此,我们可以利用 bfs在 Trie上面进行每一层节点 fail指针的求解;
③扫描总串进行匹配。
3.进一步优化
在使用fail指针跳转的时候,如果模式串过长,跳转次数过多会浪费时间复杂度,再因为我们使用bfs构造fail指针,当正在构造第n层的fail指针时,1~n-1层的fail指针已经构造完毕,我们可以使用类似并查集的路径压缩的方法,将每一层的fail指针指向最近的匹配成功的位置。
三、附录:具体例题源代码实现
例题来源:算法基础课、算法提高课
(一)KMP算法例题(图2.1.1)
1.题意
在总串中找到出现模式串的初始字母下标
2.注释AC代码
#include<iostream>
using namespace std;
const int v=1e6+10;
char s[v],p[v];//总串和模式串
int ne[v];//next数组(全局变量中不要用next作为变量名,会引起冲突)
int main()
{
int x,y;
cin>>x>>p+1>>y>>s+1;//字符串下标统一从0开始
/*
求模式串next数组
默认:
next[0]=0
next[1]=0
*/
for(int i=2,j=0;i<=x;i++)
{
while(j&&p[i]!=p[j+1])j=ne[j];//如果失配,使用ne[0~i-1]的fail指针跳转;
if(p[i]==p[j+1])j++;//匹配成功,当前的next值加1
ne[i]=j;//存下fail指针,这里暗示了当求ne[i]时,ne[0~i-1]已经求出
}
/*用next数组对总串进行匹配*/
for(int i=1,j=0;i<=y;i++)
{
while(j&&s[i]!=p[j+1])j=ne[j];//如果失配,使用fail指针跳转;
if(s[i]==p[j+1])j++;
if(j==x)//匹配长度等于模式串长度时,说明匹配成功
{
printf("%d ",i-x);
j=ne[j];//继续匹配
}
}
return 0;
}
3.测试样例
输入 | 输出 |
3 aba 5 ababa | 0 2 |
6 ababba 11 aabababbaba | 3 |
7 abacacd 29 aadaddabacaccabacacdababacacd | 13 22 |
(二)Tire树算法例题:(图2.2.1)
1.题意
维护一个字符串集合,支持两种操作:
①插入一个字符串;
②查询一个字符串在集合中出现次数;
2.注释AC代码
#include <iostream>
using namespace std;
const int N=1e5+10;
int cnt[N];
int idx=0;
int mapp[N][26];//trie树
int n;
char op[2];//指令数组,用来过滤掉指令中的回车
char words[N];//字符串输入辅助数组
void insert(char words[])//在集合中插入一个字符串
{
int p=0;//从根节点开始
for(int i=0;words[i];i++)
{
int t=words[i]-'a';//计算字符偏移量,(把小写字母映射为0~25的数字)
if(!mapp[p][t])mapp[p][t]=++idx;//如果不存在当前节点,给当前节点一个唯一编号进行创建
p=mapp[p][t];//下一个节点编号
}
cnt[p]++;//以节点p结尾的字符串数量加1
}
void find(char words[])//查询集合中某个字符串出现次数
{
int p=0;//从根节点开始
for(int i=0;words[i];i++)
{
int t=words[i]-'a';//计算字符偏移量,(把小写字母映射为0~25的数字)
if(!mapp[p][t])//如果不存在当前节点,说明不存在该字符串
{
puts("0");
return;
}
p=mapp[p][t];//下一个节点编号
}
cout<<cnt[p]<<endl;//直接输出字符串次数(以节点p结尾的字符串个数)
}
int main()
{
cin>>n;
while(n--)//输入输出
{
scanf("%s%s",op,words);
if(op[0]=='I')insert(words);
else find(words);
}
return 0;
}
3.测试样例
输入 | 输出 |
5 I abc Q abc Q ab I ab Q ab | 1 0 1 |
8 I abc Q abcd I ab I ab Q ab I abcd Q acd Q abcd | 0 2 0 1 |
8 I abc I abcd I abd Q abd I abd I ecd Q acde Q abd | 1 0 2 |
(三)AC自动机算法例题1(匹配字符串)(图2.3.1)
1.题意
给定一个总串和多个模式串,求在总串中出现过的模式串种数;
2.注释AC代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N=1e4+10,S=55,M=1e7+10;
int n;
int tr[N*S][26],cnt[N*S],idx;//需要开N*S层,最坏情况下需要匹配的字符有N*S个
char str[M];//字符串输入辅助数组
int q[N*S],ne[N*S];//bfs队列和next数组
void insert()//构造Tire树(和Tire树例题一模一样的模板)
{
int p=0;
for(int i=0;str[i];i++)
{
int t=str[i]-'a';
if(!tr[p][t])tr[p][t]=++idx;
p=tr[p][t];
}
cnt[p]++;
}
void build()//构造AC自动机(求next数组)
{
int hh=0,tt=-1;//bfs队头和队尾初始化
for(int i=0;i<26;i++)//从根节点开始将第一层存在的节点入队
{
if(tr[0][i])q[++tt]=tr[0][i];
}
while(hh<=tt)//bfs
{
int t=q[hh++];//节点出队
for(int i=0;i<26;i++)//遍历该节点的子节点
{
int p=tr[t][i];
/*
(fail指针优化:使用父节点的fail节点)为什么这里可以使用p节点的父节点t的fail指针呢?
因为我们使用的是bfs,bfs的特性是:一层一层进行遍历;
所以在求p节点的fail指针时,根节点到p的父节点的fail指针已经求出,可以直接使用;
用数学归纳法的思路可以解释为:求第i层fail指针时,0~(i-1)层的fail指针已求出且最简。
*/
if(!p)tr[t][i]=tr[ne[t]][i];//如果失配,使用fail指针跳转
else
{
ne[p]=tr[ne[t]][i];//匹配成功,存下p节点的fail指针
q[++tt]=p;//p节点入队
}
}
}
}
void solve()
{
/*多样例初始化*/
memset(cnt,0,sizeof cnt);
memset(tr,0,sizeof tr);
memset(ne,0,sizeof ne);
idx=0;
cin>>n;
/*输入模式串,构造Tire树*/
for(int i=0;i<n;i++)
{
scanf("%s",str);
insert();//插入Tire树
}
int res=0;
build();//构造AC自动机(求next数组)
scanf("%s",str);//总串
for(int i=0,j=0;str[i];i++)//使用Tire树对总串进行匹配(与KMP原理类似)
{
int t=str[i]-'a';//计算偏移量
j=tr[j][t];
int p=j;
while(p)
{
res+=cnt[p];//满足每个字符串只计算一次,因为在构造Tire树时每个字符串只出现一次,cnt[p]最大为1
cnt[p]=0;//如果匹配成功,将匹配成功的计数完后从Tire树中删除
p=ne[p];
}
}
cout<<res<<endl;//输出答案
}
int main()
{
int t;
cin>>t;
while(t--)
{
solve();
}
return 0;
}
3.测试样例
输入 | 输出 |
1 5 she he say shr her yasherhs | 3 |
2 5 she he se fe her yasherhsfdsfsfefdsd 4 fffw wo ou er fwooureffer | 4 3 |
1 5 aa ab bc cd er aabsccdaabbddac | 3 |
(四)AC自动机算法例题2(词频统计)(图2.3.2)
1.题意
给定多个模式串,用所有模式串组成一个总串,统计每个模式串在总串中出现的次数;
2.注释AC代码
#include <iostream>
#include <cstring>
using namespace std;
const int N=1e6+10;
char str[N];
int ne[N],f[N],tr[N][26],idx=0,id[N],q[N];//所有单词长度的总和不超过 1e6
int n;
void insert(int x)构造Tire树(x为模式串编号)
{
int p=0;
for(int i=0;str[i];i++)
{
int t=str[i]-'a';
if(!tr[p][t])tr[p][t]=++idx;
p=tr[p][t];
f[p]++;//与普通Tire树不同,这里需要统计每一个字符结尾的字符串个数,因为有可能出现模式串套模式串的情况
}
id[x]=p;//记录下每个模式串结尾节点的位置,方便统计数量
}
void build()//构造AC自动机(求next数组)
{
int hh=0,tt=-1;//bfs队头和队尾初始化
for(int i=0;i<26;i++)//从根节点开始将第一层存在的节点入队
{
if(tr[0][i])q[++tt]=tr[0][i];
}
while(hh<=tt)//bfs
{
int t=q[hh++];//节点出队
for(int i=0;i<26;i++)//遍历该节点的子节点
{
int p=tr[t][i];
/*
(fail指针优化:使用父节点的fail节点)为什么这里可以使用p节点的父节点t的fail指针呢?
因为我们使用的是bfs,bfs的特性是:从上往下一层一层进行遍历;
所以在求p节点的fail指针时,根节点到p的父节点的fail指针已经求出,可以直接使用;
用数学归纳法的思路可以解释为:求第i层fail指针时,0~(i-1)层的fail指针已求出且最简。
*/
if(!p)tr[t][i]=tr[ne[t]][i];//如果失配,使用fail指针跳转
else
{
ne[p]=tr[ne[t]][i];//匹配成功,存下p节点的fail指针
q[++tt]=p;//p节点入队
}
}
}
}
void solve()
{
cin>>n;
/*构造Tire树*/
for(int i=0;i<n;i++)
{
scanf("%s",str);
insert(i);
}
build();//构造AC自动机(求next数组)
for(int i=idx-1;i>=0;i--)
{
f[ne[q[i]]]+=f[q[i]];
/*
对于一个前缀串来说,只有trie内所有比它长的串都统计过,这个前缀串的数量才是确定的
从后往前遍历队列,这里偷懒使用了bfs的特性:bfs序小的拓扑序也会比较小
*/
}
for(int i=0;i<n;i++)
{
cout<<f[id[i]]<<endl;
}
}
int main()
{
solve();
return 0;
}
3.测试样例
输入 | 输出 |
3 a aa aaa | 6 3 1 |
3 abc aabbbb aaabcccbabc | 3 1 1 |
4 a abbbbbc acbc bcbcbbbbbccbbbbbc | 3 1 1 1 |