AC自动机
背景:给出一个字典,和若干询问,有多少个字典在询问串中出现(单串和多串的匹配问题)
解法1: K M P KMP KMP
进行多次单串与询问串的匹配,时间复杂度
O
(
n
∗
∣
S
∣
+
∑
∣
T
i
∣
)
O(n*|S|+\sum{|Ti|})
O(n∗∣S∣+∑∣Ti∣)
但是:字符串形成孤岛,没有打通链路,形成闭环,时间复杂度高
解法2: T r i e 树 Trie树 Trie树
利用字典树进行多模式匹配
但是:不能够在失配时进行合理的跳转,盲目尝试,导致时间复杂度高
A C 自动机 AC自动机 AC自动机= T r i e Trie Trie+ K M P KMP KMP
AC自动机基于 T r i e Trie Trie,将 K M P KMP KMP的 b o r d e r border border推广到多模式串上
AC自动机是一种离线型数据结构(即不支持增添新的字符串)
AC自动机常用于将字符串询问类的问题进行离线处理,会用数据结构维护(像树状树组,主席树之类),也经常与各种DP结合,或是补全成 T r i e Trie Trie图
border广义
推广两个串:S和T,相等的p长度的S的后缀和T的前缀称为一个 b o r d e r border border。
推广字典:对于串S和一个字典D,相等的p长度的S的后缀,和任意一个字典串T的前缀匹配上,称为一个 b o r d e r border border。
失配(Fail)指针:对于 T r i e Trie Trie中的每一个节点(即每个字典串的前缀),它与 T r i e Trie Trie中所有串的最大 b o r d e r border border即为失配指针。
失配(Fail)指针
类似与 K M P KMP KMP求 B o r d e r Border Border,任意节点的Border长度减一,一定是父节点的 B o r d e r Border Border。因此可以通过遍历父节点的失配指针链来求解。因此在求失配指针的时候,一定要按长度从小到大来求,即bfs。
时间、空间复杂度
时间复杂度:类似于
K
M
P
KMP
KMP的势能分析方法,势能总量等于Trie的节点总数,因此复杂度为线性的
空间复杂度:和
t
r
i
e
trie
trie树差不多
Trie图
其实 T r i e 图 Trie图 Trie图和 A C 自动机 AC自动机 AC自动机其实是两个东西,但是基本上写 A C 自动机 AC自动机 AC自动机,会把 T r i e 图 Trie图 Trie图也一起补全了,说白了,就是把边给补全(使得每次跳fail链O(1))
板子
给你一个文本串 S和 n 个模式串 T 1 ∼ n T_{1∼n} T1∼n,请你分别求出每个模式串$ T_i $在 S中出现的次数。
Trie是前缀数据结构,Fail是维护后缀信息
我们在一个节点跳fail链,相当于在字典树匹配的区间跳广义的border,就是找当前的匹配区间的长度最大的可以匹配字典树前缀的一段后缀 ,所以当前匹配节点的答案贡献(包含最后一个点的区间后缀匹配),就是该点的fail链上所有节点中的模式串的终止标记的个数。
但是我们暴力跳fail链的话,像 a … … a a……a a……a这种全部相等的串,可以把AC自动机卡成跑若干次 K M P KMP KMP( O ( ∣ S ∣ ∣ T ∣ ) O(|S||T|) O(∣S∣∣T∣))
因为不能暴力跳fail指针所以两种写法:
说白了,其实AC自动机由两棵树组成的: F a i l 树、 T r i e 树 Fail树、Trie树 Fail树、Trie树,我们在暴力跳Fail时,会多次访问同一个节点,优化就是尽可能让每个节点只访问一次,对于这个Fail树上的问题有两种解决方案:
1、拓扑建图(保证每个点只会被访问一次)
#include<bits/stdc++.h>
using namespace std;
const int M=2e6+100;
const int N=2e5+100;
int now,m,cnt,n;
int vis[M][30],jsq[M],fail[M],in[M];
string s,a[N];
void insert(int x){
now=0;m=a[x].size();
for (int i=0;i<m;i++){
if (!vis[now][a[x][i]-'a'])vis[now][a[x][i]-'a']=++cnt;
now=vis[now][a[x][i]-'a'];
}
return ;
}
queue<int> q;
void Fail(){
for (int i=0;i<26;i++){
if (vis[0][i]){
fail[vis[0][i]]=0;
q.push(vis[0][i]);
}
}
while (!q.empty()){
now=q.front();
q.pop();
for (int i=0;i<26;i++){
if (vis[now][i]){
fail[vis[now][i]]=vis[fail[now]][i];
in[fail[vis[now][i]]]++;
q.push(vis[now][i]);
}else vis[now][i]=vis[fail[now]][i];//这个就是补全Trie图
}
}
return ;
}
void AC(){
m=s.size();now=0;
for (int i=0;i<m;i++){
now=vis[now][s[i]-'a'];
jsq[now]++;
}
return ;
}
void tuopu(){
for (int i=1;i<=cnt;i++)if (!in[i])q.push(i);
while (!q.empty()){
now=q.front();
q.pop();
jsq[fail[now]]+=jsq[now];
in[fail[now]]--;
if (!in[fail[now]])q.push(fail[now]);
}
return ;
}
void query(int x){
m=a[x].size();now=0;
for (int i=0;i<m;i++){
now=vis[now][a[x][i]-'a'];
}
printf("%d\n",jsq[now]);
return ;
}
int main(){
scanf("%d",&n);
cnt=0;
for (int i=1;i<=n;i++){
cin>>a[i];
insert(i);
}
Fail();
cin>>s;
AC();
tuopu();
for (int i=1;i<=n;i++){
query(i);
}
return 0;
}
2、求子树大小(按照fail指针的队列从后往前跳)
其实,如果一个点有模式串的结束标记,那么它对答案的贡献,就是
t
a
g
[
n
o
w
]
∗
s
z
[
n
o
w
]
tag[now]*sz[now]
tag[now]∗sz[now](就是该位置的标记数×AC自动机运行时会经过它的
f
a
i
l
链
fail链
fail链个数),
t
a
g
[
n
o
w
]
tag[now]
tag[now]就是字典树跑一遍,标记模式串的结束符,而
s
z
[
n
o
w
]
sz[now]
sz[now]需要我们用树上DP求
s
z
[
f
a
]
=
∑
s
z
[
s
o
n
]
所以我们从低端开始累加答案就是
:
(
按照
f
a
i
l
指针的队列从后往前跳
)
s
z
[
f
a
i
l
[
n
o
w
]
]
+
=
s
z
[
n
o
w
]
sz[fa]=\sum sz[son]\\ 所以我们从低端开始累加答案就是:(按照fail指针的队列从后往前跳)\\ sz[fail[now]]+=sz[now]
sz[fa]=∑sz[son]所以我们从低端开始累加答案就是:(按照fail指针的队列从后往前跳)sz[fail[now]]+=sz[now]
这个Trick的灵感来源P3966 TJOI2013 单词!
这个Trick其实也挺常见的,很多时候会把字符串变成了树上问题,进而有时会用数据结构来维护!
#include<bits/stdc++.h>
using namespace std;
const int M=2e6+100;
const int N=2e5+100;
int now,m,cnt,n;
int vis[M][30],jsq,fail[M],k[M],id[N],sz[M];
string s,a[N];
void insert(int x){
now=0;m=a[x].size();
for (int i=0;i<m;i++){
if (!vis[now][a[x][i]-'a'])vis[now][a[x][i]-'a']=++cnt;
now=vis[now][a[x][i]-'a'];
}
id[x]=now;
return ;
}
queue<int> q;
void Fail(){
for (int i=0;i<26;i++){
if (vis[0][i]){
fail[vis[0][i]]=0;
q.push(vis[0][i]);
}
}
while (!q.empty()){
now=q.front();
k[++jsq]=now;
q.pop();
for (int i=0;i<26;i++){
if (vis[now][i]){
fail[vis[now][i]]=vis[fail[now]][i];
q.push(vis[now][i]);
}else vis[now][i]=vis[fail[now]][i];
}
}
return ;
}
void AC(){
m=s.size();now=0;
for (int i=0;i<m;i++){
now=vis[now][s[i]-'a'];
sz[now]++;
}
return ;
}
void query(){
for (int i=jsq;i>=1;i--){
sz[fail[k[i]]]+=sz[k[i]];
}
for (int i=1;i<=n;i++){
printf("%d\n",sz[id[i]]);
}
return ;
}
int main(){
scanf("%d",&n);
cnt=0;
for (int i=1;i<=n;i++){
cin>>a[i];
insert(i);
}
Fail();
cin>>s;
AC();
query();
return 0;
}
进阶AC自动机
子树问题
P3966 TJOI2013 单词
其实就是板子,但是给了我们一个新的思考方向
详细做法参考前面的板子
模拟删除
- P3121 USACO15FEB Censoring G
就是跑一边自动机,遇到匹配的上就就删除匹配的子串。
简单DP
该软件可以随机生成一些文章——总是生成一篇长度固定且完全随机的文章。 也就是说,生成的文章中每个字符都是完全随机的。如果一篇文章中至少包含使用者们了解的一个单词,那么我们说这篇文章是可读的(我们称文章 ss 包含单词 t,当且仅当单词 t是文章 s 的子串)。但是,即使按照这样的标准,使用者现在使用的 GW 文本生成器 v6 版所生成的文章也是几乎完全不可读的。ZYX 需要指出 GW 文本生成器 v6 生成的所有文本中,可读文本的数量,以便能够成功获得 v7 更新版。你能帮助他吗?
题解都说这题就是套路题,但是第一次做还是很难想到的。
正难则反,我们求补集: 可读 = 所有(即
2
6
n
26^n
26n)-不可读
现在我们变成了如何求不可读的数目,我们可以在AC自动机上跳fail链时预处理哪些状态时非法(可以匹配得上)或者合法
然后,进行以下的DP:
我们设
f
[
i
]
[
j
]
:
我们字符串处于第
i
个位置,匹配到
A
C
自动机
j
节点所需的最少次数。
v
i
s
[
j
]
[
k
]
表示
T
i
r
e
树上
j
节点加字符
k
的走向节点编号
f
[
i
+
1
]
[
v
i
s
[
j
]
[
k
]
]
=
m
i
n
(
f
[
i
+
1
]
[
v
i
s
[
j
]
[
k
]
]
,
f
[
i
]
[
j
]
+
1
)
我们设f[i][j]:我们字符串处于第i个位置,匹配到AC自动机j节点所需的最少次数。\\ vis[j][k]表示Tire树上j节点加字符k的走向节点编号\\ f[i+1][vis[j][k]]=min(f[i+1][vis[j][k]],f[i][j]+1)
我们设f[i][j]:我们字符串处于第i个位置,匹配到AC自动机j节点所需的最少次数。vis[j][k]表示Tire树上j节点加字符k的走向节点编号f[i+1][vis[j][k]]=min(f[i+1][vis[j][k]],f[i][j]+1)
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
int mod=1e4+7;
string a;
int k[N],fail[N],vis[N][55],now,n,m,ms,cnt,f[150][N],ans;
int ksm(int x,int y){
int ansh=1ll;
while (y){
if (y&1)ansh=(ansh*x)%mod;
y>>=1;
x=(x*x)%mod;
}
return ansh;
}
void insert(){
now=0;ms=a.size();
for (int i=0;i<ms;i++){
if (!vis[now][a[i]-'A'])vis[now][a[i]-'A']=++cnt;
now=vis[now][a[i]-'A'];
}
k[now]=true;
}
queue<int> q;
void Fail(){
for (int i=0;i<26;i++){
if (vis[0][i]){
fail[vis[0][i]]=0;
q.push(vis[0][i]);
}
}
while (!q.empty()){
now=q.front();
q.pop();
for (int i=0;i<26;i++){
if (vis[now][i]){
fail[vis[now][i]]=vis[fail[now]][i];
k[vis[now][i]]=k[vis[now][i]]|k[vis[fail[now]][i]];//判断状态是否合法
q.push(vis[now][i]);
}else vis[now][i]=vis[fail[now]][i];
}
}
return ;
}
signed main(){
scanf("%lld%lld",&n,&m);
for (int i=1;i<=n;i++){
cin>>a;
insert();
}
Fail();
f[0][0]=1;
for (int i=0;i<m;i++){
for (int j=0;j<=cnt;j++){
for (int z=0;z<26;z++){
if (f[i][j]&&!k[vis[j][z]])f[i+1][vis[j][z]]=(f[i+1][vis[j][z]]+f[i][j])%mod;
}
}
}
ans=ksm(1ll*26,m);
for (int i=0;i<=cnt;i++){
ans=(ans+mod-f[m][i])%mod;
}
printf("%lld",ans);
return 0;
}
类似的一道题:P3041 USACO12JAN Video Game G
Oscar 特别喜欢看犯罪电影。他钦佩那些罪犯,因为他们富有创造力。他也想展示他的创造力。但很可惜的是,他没什么经验,也想不出来什么原创伎俩。所以他想从已有的招数中寻找灵感。他一直喜欢看罪犯从报纸上剪下字母,然后用这些字母拼勒索信的桥段。然而 Oscar 根本不想抄袭,所以他自己想了一个这种方法的变体。他觉得把字母一个一个拼成文本既无聊又费时间。所以他决定通过剪下一整个单词的方式拼出自己的勒索信。
Oscar 买来一些主流报纸,这样他几乎就有了无限的单词库。他可以多次剪出任意特定的单词。然而,他还是被报纸中出现的的单词集限制。问题是一些单词根本没在报纸中出现。为了让这项工作更简单,他决定去除勒索信中所有的标点符号和空格并且忽略字母的大小写。他同时允许剪出的单词互相重叠,只需要重叠部分相同。现在 Oscar 想知道他至少要剪下多少次单词才能拼成他想要的勒索信。
f [ i ] 表示匹配到第 i 位所需的最少次数 当前匹配位置最大的匹配长度为 k f [ i ] = m i n ( f [ j ] + 1 , f [ i ] ) ( 1 ≤ j ≤ k ) 允许重叠! f[i]表示匹配到第i位所需的最少次数\\ 当前匹配位置最大的匹配长度为k\\ f[i]=min(f[j]+1,f[i])(1 \leq j \leq k)允许重叠!\\ f[i]表示匹配到第i位所需的最少次数当前匹配位置最大的匹配长度为kf[i]=min(f[j]+1,f[i])(1≤j≤k)允许重叠!
于是我们要用一个数据结构维护 f [ i ] f[i] f[i]的区间最值优化转移,有人用ST表,我用了线段树
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=3e5+10;
const int M=4e6+10;
int tree[M],cnt;
int n,now,m,vis[N][37],fail[N],ok[N],ans;
string a,s;
void insert(){
now=0;
m=a.size();
for (int i=0;i<m;i++){
if (!vis[now][a[i]-'a'])vis[now][a[i]-'a']=++cnt;
now=vis[now][a[i]-'a'];
}
ok[now]=m;
return ;
}
queue<int> q;
void Fail(){
for (int i=0;i<26;i++){
if (vis[0][i]){
fail[vis[0][i]]=0;
q.push(vis[0][i]);
}
}
while (!q.empty()){
now=q.front();
q.pop();
for (int i=0;i<26;i++){
if (vis[now][i]){
fail[vis[now][i]]=vis[fail[now]][i];
ok[vis[now][i]]=max(ok[vis[now][i]],ok[vis[fail[now]][i]]);
q.push(vis[now][i]);
}else vis[now][i]=vis[fail[now]][i];
}
}
}
void build(int now,int l,int r){
if (l==r){
tree[now]=1e9;
return ;
}
int mid=(l+r)>>1;
build(now<<1,l,mid);
build(now<<1|1,mid+1,r);
tree[now]=min(tree[now<<1],tree[now<<1|1]);
return ;
}
void update(int now,int l,int r,int x,int y){
if (l==r&&l==x){
tree[now]=y;
return ;
}
int mid=(l+r)>>1;
if (x<=mid)update(now<<1,l,mid,x,y);
else update(now<<1|1,mid+1,r,x,y);
tree[now]=min(tree[now<<1],tree[now<<1|1]);
return ;
}
void query(int now,int l,int r,int x,int y){
if (x<=l&&y>=r){
ans=min(ans,tree[now]);
return ;
}
int mid=(l+r)>>1;
if (x<=mid)query(now<<1,l,mid,x,y);
if (y>mid)query(now<<1|1,mid+1,r,x,y);
tree[now]=min(tree[now<<1],tree[now<<1|1]);
return ;
}
void AC(){
build(1,1,m);
now=0;m=s.size();
for (int i=1;i<=m;i++){
now=vis[now][s[i-1]-'a'];
if (i-ok[now]==0){
update(1,1,m,i,1);
continue;
}
ans=1e9;
if (ok[now]){
query(1,1,m,i-ok[now],i-1);
}
update(1,1,m,i,ans+1);
}
ans=1e9;
query(1,1,m,m,m);
if (ans==1e9){
printf("-1\n");
}else printf("%lld",ans);
}
signed main(){
scanf("%lld",&n);
cin>>s;
for (int i=1;i<=n;i++){
cin>>a;
insert();
}
Fail();
AC();
return 0;
}
在线询问?
B-string(待)
我们可以离线建trie树,然后没加一个字典串,dfs序+树状数组
别人汇总的题单:AC自动机 - 题单