前言

四大玄学玩意:SPFA,动态规划,组合数学,AC自动机

前置知识:

【5】KMP学习笔记

【6】字典树学习笔记

AC 自动机


给定 \(n\) 个模式串 \(s_i\) 和一个文本串 \(t\),求有多少个不同的模式串在文本串里出现过。


这是一个多模字符串匹配问题,对于这类问题,我们一般用 AC 自动机解决。

AC 自动机,笼统的说就是在字典树上跑 KMP 算法。整个 AC 自动机算法分为三个部分:建立字典树,建立 fail 指针,多模字符串匹配。

建立字典树

参考 【6】字典树学习笔记,不多赘述。

void insert(char str[])
{
int l=strlen(str),root=0;
for(int i=0;i<l;i++)
{
int id=str[i]-'a';
if(!trie[root][id])trie[root][id]=++cnt;
root=trie[root][id];
}
ap[root]++;
}

建立 fail 指针

和 KMP 一样,失配时,我们可以利用字典树的根节点到这个节点之间的字符时匹配的这个信息。构建失配指针,使当前字符失配时跳转到另一段从根节点开始的每一个字符都与当前已匹配字符段某一个后缀完全相同长度最大的位置继续匹配。由此可知如果跳转,跳转后的串的前缀必须为跳转前的模式串的后缀,深度一定小于当前节点,所以可以通过 BFS 在字典树上求出 fail 指针。

说人话,就是因为我们已知主串中一段是匹配的,那么我们取出匹配的这一段的后缀,作为下一个匹配串的前缀,然后从此处继续匹配。

通过 BFS 求 fail 的过程,其在单个节点上求解的步骤也很简单:若这个节点的字符为 \(c\),则沿其父节点的 fail 指针走,一直走到一个位置,使其子节点中也有字符为 \(c\) 的节点,将待求节点的 fail 指针指向这个子节点就行了。

至于其正确性也不难得知,类似动态规划。沿这个字符为 \(c\) 的节点的父节点的 fail 指针走到的位置,由 fail 指针的定义,这些位置一定是到达这个字符为 \(c\) 的节点的父节点的字符串的某一个作为前缀的最长后缀。而这个字符为 \(c\) 的节点的父节点后有一个字符为 \(c\) 的节点,这些满足条件的位置后如果也有一个字符为 \(c\) 的节点,证明后缀可以更长,所以可以直接转移。

上面几段话是重点,可能会非常绕人,甚至有些“只可意会,不可言传”的味道。不过我觉得多画画图,多思考,应该能理解。理解其本质之后,才能更好的运用。

下面这段代码节点(包括根节点)编号从 \(1\) 开始,所以省去了根节点的特判。

void build_ac()
{
int head=0,tail=0;
fail[0]=1;
que[tail++]=1;
while(head<tail)
{
int now=que[head];
for(int i=0;i<26;i++)
{
int p=fail[now];
if(!trie[now][i])continue;
fail[trie[now][i]]=1;
que[tail++]=trie[now][i];
while(p)
{
if(trie[p][i])
{
fail[trie[now][i]]=trie[p][i];
break;
}
p=fail[p];
}
}
head++;
}
}

Trie 图优化

上面这种写法,看起来很丑,而且效率不高。我们可以在字典树上建图来进行类似并查集的路径压缩,优化算法。

Trie 图优化的核心就是这两行:

if(trie[now][i])fail[trie[now][i]]=trie[fail[now]][i],que[tail++]=trie[now][i];

这行的意思是,如果字符编号为 \(i\) 的子节点存在,那么直接将其指向为父节点的 fail 指针指向的节点的编号为 \(i\) 的子节点。如果父节点的 fail 指针指向的节点的编号为 \(i\) 的子节点存在,那么很好,否则请看下面这一行。

else trie[now][i]=trie[fail[now]][i];

这个 else 是接在上面那行的 if 下面的,也就是说,当父节点的 fail 指针指向的节点的编号为 \(i\) 的子节点不存在时,按照这一行,其值已经变为了父节点的 fail 指针指向的节点的 fail 指针指向的节点,相当于未优化的 while(p) 中的 p=fail[p]。由于每次递推只扩展一层,层层推进,所以记录的状态一定是最新的,可以保证正确性。

注意要直接将根节点的子节点入队,避免在根节点处出现一些异常情况。

void build_ac()
{
int head=0,tail=0;
fail[0]=0;
for(int i=0;i<26;i++)
if(trie[0][i])que[tail++]=trie[0][i];
while(head<tail)
{
int now=que[head];
for(int i=0;i<26;i++)
if(trie[now][i])fail[trie[now][i]]=trie[fail[now]][i],que[tail++]=trie[now][i];
else trie[now][i]=trie[fail[now]][i];
head++;
}
}

这样就做到了在严格 \(O(\sum|s_i|)\) 的复杂度下求出 fail 指针。

多模字符串匹配

由于求 fail 的时候已经让不存在的节点自动跳到了 fail 指针指向的节点(trie[now][i]=trie[fail[now]][i]),所以直接在字典树上走字符边就行了。到每一个节点时通过 fail 指针统计贡献(每一个 fail 指针都必须遍历到,因为如果匹配,作为某个后缀的 fail 肯定也是匹配的),注意计算与标记已经出现过即可。

int match_ac(char str[])
{
int i=0,l=strlen(str),root=0,ans=0;
while(i<l)
{
int id=str[i]-'a';
root=trie[root][id];
for(int j=root;j!=0&&ap[j]!=-1;j=fail[j])ans+=ap[j],ap[j]=-1;
i++;
}
return ans;
}

值得一提的是,这个过程的复杂度经常被卡爆,需要通过各种手段来优化,最常用的是预处理和记忆化,这些优化主要出现在例题中。一般来说,一道 AC 自动机的题目超时了,十有八九是这个位置。

例题

例题 \(1\) :

P3808 【模板】AC 自动机(简单版)

AC 自动机板子题,不多赘述。

#include <bits/stdc++.h>
using namespace std;
int n,trie[2000010][26],ap[2000010],fail[2000010],que[2000010],vis[2000010],cnt=0;
char str[2000010];
void insert(char str[])
{
int l=strlen(str),root=0;
for(int i=0;i<l;i++)
{
int id=str[i]-'a';
if(!trie[root][id])trie[root][id]=++cnt;
root=trie[root][id];
}
ap[root]++;
} void build_ac()
{
int head=0,tail=0;
fail[0]=0;
for(int i=0;i<26;i++)
if(trie[0][i])que[tail++]=trie[0][i];
while(head<tail)
{
int now=que[head];
for(int i=0;i<26;i++)
if(trie[now][i])fail[trie[now][i]]=trie[fail[now]][i],que[tail++]=trie[now][i];
else trie[now][i]=trie[fail[now]][i];
head++;
}
} int match_ac(char str[])
{
int i=0,l=strlen(str),root=0,ans=0;
while(i<l)
{
int id=str[i]-'a';
root=trie[root][id];
for(int j=root;j!=0&&ap[j]!=-1;j=fail[j])ans+=ap[j],ap[j]=-1;
i++;
}
return ans;
} int main()
{
scanf("%d",&n);
for(int i=0;i<n;i++)
{
scanf("%s",str);
insert(str);
}
scanf("%s",str);
build_ac();
printf("%d",match_ac(str));
return 0;
}

例题 \(2\) :

P3796 【模板】AC 自动机(加强版)

比较例题 \(1\) 确实有加强,必须使用 Trie 图优化。操作有些许不一样,但对比例题 \(1\) 只是不需要标记出现过以及多一个分类计数而已。

#include <bits/stdc++.h>
using namespace std;
int t,n,trie[20010][26],ap[20010],pl[20010],fail[20010],que[20010],vis[20010],sum[20010][200],to[20010],th[20010],cnt=0;
char str[2000010][200];
void clean(int i)
{
ap[i]=pl[i]=fail[i]=vis[i]=0;
for(int j=0;j<26;j++)trie[i][j]=0;
for(int j=1;j<=n;j++)sum[i][j]=0;
} void insert(char str[],int xu)
{
int l=strlen(str),root=0;
for(int i=0;i<l;i++)
{
int id=str[i]-'a';
if(!trie[root][id])
{
trie[root][id]=++cnt;
clean(cnt);
}
root=trie[root][id];
}
ap[root]++;
pl[root]=xu;
} void build_ac()
{
int head=0,tail=0;
fail[0]=0;
for(int i=0;i<26;i++)
if(trie[0][i])que[tail++]=trie[0][i];
while(head<tail)
{
int now=que[head];
for(int i=0;i<26;i++)
if(trie[now][i])fail[trie[now][i]]=trie[fail[now]][i],que[tail++]=trie[now][i];
else trie[now][i]=trie[fail[now]][i];
head++;
}
} void match_ac(char str[])
{
int i=0,l=strlen(str),root=0;
while(i<l)
{
int id=str[i]-'a';
root=trie[root][id];
for(int j=root;j!=0;j=fail[j])to[pl[j]]+=ap[j];
i++;
}
} int main()
{
while(scanf("%d",&n)!=-1)
{
int maxn=0;
if(n==0)return 0;
clean(0);
for(int i=0;i<n;i++)
{
scanf("%s",str[i]);
insert(str[i],i+1);
}
scanf("%s",str[n]);
build_ac();
match_ac(str[n]);
for(int i=1;i<=n;i++)maxn=max(to[i],maxn);
printf("%d\n",maxn);
for(int i=1;i<=n;i++)
if(to[i]==maxn)printf("%s\n",str[i-1]);
for(int i=1;i<=n;i++)to[i]=0;
cnt=0;
}
return 0;
}

例题 \(3\) :

P3121 [USACO15FEB]Censoring G

因为删除操作的复杂度是 \(O(n)\) 的,所以用栈来存储匹配过的字符。每次新匹配一个字符,就压到栈顶,这样删除时只需要多次弹出栈顶字符,然后将状态(AC 自动机上的位置)重置为弹完栈后栈顶元素的状态就行了。

为了方便弹栈进行删除操作,需要预处理出每个节点的深度,用这个来确定弹栈的字符数量。这个预处理可以就在求 fail 时递推完成,十分方便。

按照普通的 AC 自动机的处理,超时了。用一个小优化:预处理出每个位置跳 fail 后第一个可能匹配的位置,匹配时就可以 \(O(1)\) 查询。这个预处理同样可以就在求 fail 时递推完成,同样十分方便。

#include <bits/stdc++.h>
using namespace std;
int n,trie[100010][26],ap[100010],d[100010],fail[100010],fir[100010],que[100010],star[100010],top=0,cnt=0;
char chu[100010],str[100010],sta[100010];
void insert(char str[])
{
int l=strlen(str),root=0;
for(int i=0;i<l;i++)
{
int id=str[i]-'a';
if(!trie[root][id])trie[root][id]=++cnt;
root=trie[root][id];
}
ap[root]++;
} void build_ac()
{
int head=0,tail=0;
fail[0]=0;d[0]=0;
for(int i=0;i<26;i++)
if(trie[0][i])que[tail++]=trie[0][i],d[trie[0][i]]=1;
while(head<tail)
{
int now=que[head];
if(ap[now])fir[now]=now;
else fir[now]=fir[fail[now]];
for(int i=0;i<26;i++)
if(trie[now][i])d[trie[now][i]]=d[now]+1,fail[trie[now][i]]=trie[fail[now]][i],que[tail++]=trie[now][i];
else trie[now][i]=trie[fail[now]][i];
head++;
}
} void match_ac(char str[])
{
int l=strlen(str),root=0,i=0;
while(i<l)
{
int id=str[i]-'a';
root=trie[root][id];
sta[++top]=str[i];star[top]=root;
for(int k=0;k<d[fir[root]];k++)top--;
root=star[top];
i++;
}
} int main()
{
scanf("%s",chu);
scanf("%d",&n);
for(int i=0;i<n;i++)
{
scanf("%s",str);
insert(str);
}
build_ac();
match_ac(chu);
for(int i=1;i<=top;i++)
printf("%c",sta[i]);
return 0;
}

例题 \(4\) :

P3041 [USACO12JAN]Video Game G

AC 自动机上的动态规划。

由题目中这句话,不难想到 AC 自动机:

\(s_i\) 在 \(t\) 中出现一次指的是 \(s_i\) 是 \(t\) 从某个位置起的连续子串。如果 \(s_i\) 从 \(t\) 的多个位置起都是连续子串,那么算作 \(s_i\) 出现了多次。

考虑到每个位置有多种转移情况,且满足最优子结构性质,使用动态规划。观察后发现确定一个位置需要两个参数:目前是主串的的 \(i\) 个字符,目前在 AC 自动机中的位置 \(j\)。易得转移方程:

\[dp[i+1][trie[j][k]]=\max(dp[i+1][trie[j][k]],dp[i][j]+h[trie[j][k]])
\]

这里 \(k\) 是枚举 AC 自动机中的出边,\(h[i]\) 表示在 AC 自动机中的位置 \(i\) 匹配可以得到的分数。实质上,这是一个根据现在的状态推出后面状态的方程,所以 \(\max\) 里会有 \(dp[i+1][trie[j][k]]\) 这一项,而后面一项则是目前枚举的状态转移之后的得分。

这里把 \(h[i]\) 预处理出来了,实际上不预处理应该也是可以的。

注意状态需要初始化为负无穷,很明显是不能从不可能的状态转移的。

#include <bits/stdc++.h>
using namespace std;
int n,k,ans,trie[310][3],ap[310],fail[310],que[310],f[1010][310],h[310],cnt=0;
char str[310];
void insert(char str[])
{
int l=strlen(str),root=0;
for(int i=0;i<l;i++)
{
int id=str[i]-'A';
if(!trie[root][id])trie[root][id]=++cnt;
root=trie[root][id];
}
ap[root]++;
} void build_ac()
{
int head=0,tail=0;
fail[0]=0;
for(int i=0;i<3;i++)
if(trie[0][i])que[tail++]=trie[0][i];
while(head<tail)
{
int now=que[head];
for(int i=0;i<3;i++)
if(trie[now][i])fail[trie[now][i]]=trie[fail[now]][i],que[tail++]=trie[now][i];
else trie[now][i]=trie[fail[now]][i];
head++;
}
} int main()
{
scanf("%d%d",&n,&k);
for(int i=0;i<n;i++)
{
scanf("%s",str);
insert(str);
}
build_ac();
for(int i=0;i<=cnt;i++)
for(int j=i;j!=0;j=fail[j])h[i]+=ap[j];
for(int i=0;i<=k;i++)
for(int j=0;j<=cnt;j++)
f[i][j]=-99999999;
f[0][0]=0;
for(int i=0;i<k;i++)
for(int j=0;j<=cnt;j++)
for(int k=0;k<3;k++)
f[i+1][trie[j][k]]=max(f[i+1][trie[j][k]],f[i][j]+h[trie[j][k]]);
for(int i=0;i<=cnt;i++)
ans=max(ans,f[k][i]);
printf("%d",ans);
return 0;
}

例题 \(5\) :

P3966 [TJOI2013]单词

个人认为这三道紫例题中最难的一道。

关于这题的文本串:输入的每个单词,既是模式串,也是文本串。

首先,对所有单词建立一个 AC 自动机,类似例题 \(2\),注意重复的单词统一记录,只插入字典树一次,但匹配时需要插入多次。然后,因为文本串就是这些单词,直接进行匹配,可以拿到 \(90\) 分。

由于每次匹配都有可能退化为 \(O((\sum s_i)^2)\),且因为空间限制无法对每一个位置进行记忆化,只能考虑其他做法。

在正常统计时,每个节点的 fail 计数时有方向的,一定是从深度深的节点统计到深度浅都节点。又由于文本串已知,基于这两点,我们可以考虑统计出每一个节点被计算的次数,根据标号与结束标记,最后加入对应字符串的贡献。

我们发现,在匹配过程中,每个字符串匹配到的每一个位置都会向上进行一次 fail 计数,可以直接在插入的时候处理。对于重复访问的位置,这里只需要乘上次数即可,不会增加复杂度,大大优化了时间。

根据 fail 的方向性以及指向节点的唯一性,很容易想到树这种结构。可以以 fail 指针的指向为边,再建立一颗 Fail 树。注意,这里必须在未经过 Trie 图优化的字典树上建立 fail 树,否则 Trie 图会导致无限循环,干扰结果。如果使用了 Trie 图优化,请额外存储一颗原始的字典树,方便建立 Fail 树。

在 Fail 树上,如果一个节点被计算过一次,那么根据 AC 自动机的求法,其父节点一定也会被计算一次。所以,可以通过递归到叶子节点,通过回溯不断累加节点被计算的次数,乘以该节点的本身贡献就是这个节点做出的总贡献。

这个方法也可以用于优化 AC 自动机的其他题目,一般来说,这样优化后复杂度为较为严格的 \(O(\sum s_i+t)\)。

#include <bits/stdc++.h>
using namespace std;
int n,trie[2000010][26],bie[2000010][26],ap[2000010],pl[2000010],tol[2000010],fail[2000010],que[2000010],vis[20010],to[20010],y[20010],cnt=0,newc=0;
string str[201];
vector<int>ft[2000010];
int insert(string str)
{
int l=str.size(),root=0;
for(int i=0;i<l;i++)
{
int id=str[i]-'a';
if(!trie[root][id])trie[root][id]=++cnt,bie[root][id]=cnt;
root=trie[root][id];
tol[root]++;
}
if(ap[root]==0)newc++;
else return pl[root];
ap[root]++;
pl[root]=newc;
return newc;
} void build_ac()
{
int head=0,tail=0;
fail[0]=0;
for(int i=0;i<26;i++)
if(trie[0][i])que[tail++]=trie[0][i];
while(head<tail)
{
int now=que[head];
for(int i=0;i<26;i++)
if(trie[now][i])fail[trie[now][i]]=trie[fail[now]][i],que[tail++]=trie[now][i];
else trie[now][i]=trie[fail[now]][i];
head++;
}
} void build_ft()
{
for(int root=0;root<=cnt;root++)
for(int i=0;i<26;i++)
if(bie[root][i])ft[fail[bie[root][i]]].push_back(bie[root][i]);
} int match_ac(int root)
{
int cnt=0,l=ft[root].size();
for(int i=0;i<l;i++)
cnt+=match_ac(ft[root][i]);
cnt+=tol[root];
to[pl[root]]+=cnt*ap[root];
return cnt;
} int main()
{
scanf("%d",&n);
for(int i=0;i<n;i++)
{
cin>>str[i];
y[i]=insert(str[i]);
}
build_ac();
build_ft();
match_ac(0);
for(int i=0;i<n;i++)
printf("%d\n",to[y[i]]);
return 0;
}

后记

教练推荐的 AC 自动机博客:AC自动机算法详解 (转载)

自己找到的 AC 自动机博客:强势图解AC自动机

【8】AC自动机学习笔记的更多相关文章

  1. AC自动机学习笔记-2(Trie图&&last优化)

    我是连月更都做不到的蒟蒻博主QwQ 考虑到我太菜了,考完noip就要退役了,所以我决定还是把博客的倒数第二篇博客给写了,也算是填了一个坑吧.(最后一篇?当然是悲怆のnoip退役记啦QAQ) 所以我们今 ...

  2. AC自动机板子题/AC自动机学习笔记!

    想知道484每个萌新oier在最初知道AC自动机的时候都会理解为自动AC稽什么的,,,反正我记得我当初刚知道这个东西的时候,我以为是什么神仙东西,,,(好趴虽然确实是个对菜菜灵巧比较难理解的神仙知识点 ...

  3. [AC自动机][学习笔记]

    用途 AC自动机适用于一类用多个子串在模板串中匹配的字符串问题. 也就是说先给出一个模板串,然后给出一些子串.要求有多少个子串在这个模板串中出现过. KMP与trie树 其实AC自动机就是KMP与tr ...

  4. AC自动机学习笔记-1(怎么造一台AC自动机?)

    月更博主又来送温暖啦QwQ 今天我们学习的算法是AC自动机.AC自动机是解决字符串多模匹配问题的利器,而且代码也十分好打=w= 在这一篇博客里,我将讲解AC自动机是什么,以及怎么构建一个最朴素的AC自 ...

  5. AC 自动机学习笔记

    虽然 NOIp 原地爆炸了,目前进入 AFO 状态,但感觉省选还是要冲一把,所以现在又来开始颓字符串辣 首先先复习一个很早很早就学过但忘记的算法--自动 AC AC自动机. AC 自动机能够在 \(\ ...

  6. AC自动机学习笔记

    AC自动机 ----多个模板的字符串匹配 字典树Trie加上失配边构成 插入操作:ac.insert(p[i],i);构造失配函数:ac.getFail();计算文本串T中每个模板串的匹配数:ac.f ...

  7. 【AC自动机】【字符串】【字典树】AC自动机 学习笔记

    blog:www.wjyyy.top     AC自动机是一种毒瘤的方便的多模式串匹配算法.基于字典树,用到了类似KMP的思维.     AC自动机与KMP不同的是,AC自动机可以同时匹配多个模式串, ...

  8. AC自动机学习

    今天包括这一周开始学习AC自动机了,有点晚,但我感觉努努力还来得及.4月份还得认认真真攻图论,加油! 为2个月后的邀请赛及省赛.东北赛做准备. 推荐AC自动机学习地址:http://www.cppbl ...

  9. 后缀自动机&回文自动机学习笔记

    在学了一天其实是边学边摆之后我终于大概$get$后缀自动机了,,,就很感动,于是时隔多年我终于决定再写篇学习笔记辽$QwQ$ $umm$和$FFT$学习笔记一样,这是一篇单纯的$gql$的知识总结博, ...

  10. AC自动机学习小结

    AC自动机 简要说明 \(AC\) 自动机,全称 \(Aho-Corasick\ automaton\) ,是一种有限状态自动机,应用于多模式串匹配.在 \(OI\) 中通常搭配 \(dp\) 食用. ...

随机推荐

  1. 自动驾驶仿真全攻略:基于CARLA+YOLOv5的自主导航实战

    引言:自动驾驶仿真的战略价值 在自动驾驶技术落地的前夜,仿真测试正在成为连接算法研发与实际路测的关键桥梁.据统计,自动驾驶系统每1万公里的接管次数需从仿真测试的百万公里级数据中优化,这使得CARLA. ...

  2. wordpress插件开发时如何通过js调用图库/媒体选择器的问题

    效果: 原文地址: wordpress插件开发通过js调用图库/媒体选择器的问题 - 搜栈网 (seekstack.cn)

  3. 企业级分布式MCP方案

    飞书原文档链接地址:https://ik3te1knhq.feishu.cn/wiki/D8kSwC9tFi61CMkRdd8cMxNTnpg 企业级分布式 MCP 方案 背景:现阶段 MCP Cli ...

  4. 【工具】VS Code Counter|除了Gitstats之外的Github一键统计代码行数工具

    需求: 1)被要求统计代码行数: 2)不想打开Linux,懒得下载Windows版本GitStats: 3)打开了Linux但也不记得find命令行怎么用: 4)打开了Linux,装好了Gitstat ...

  5. 谷歌拥抱MCP标准:AI智能体连接数据的新篇章

    引人入胜的开头 在人工智能领域,每一次新技术的应用都意味着一次全新的突破.最近,Google DeepMind的CEO Demis Hassabis宣布了一个重要的消息--谷歌将采用Anthropic ...

  6. 操作系统:苹果的M1芯片因何而快?

    本届来看看操作系统下面的硬件层面,重点研究一下 CPU 的原理和它的加速套路. CPU的原理初探 从操作系统的位置来看,它除了能够向上封装,为软件调用提供 API(也就是系统调用),向下又对硬件资源进 ...

  7. 市盈率指标EP在A股市场的分析

    因子经济金融特性 EP因子即市盈率因子,常被投资者使用的几个估值因子之一.一般使用PE,即Price to Earning, 维基百科上的解释:市盈率指每股市价除以每股盈利(Earning Per S ...

  8. Dify实战案例《AI面试官》更新,支持语音交互+智能知识库+随机题库+敏感词过滤等...

    大模型应用课又更新了,除了之前已经完结的两门课(视频+图文): <Spring AI 从入门到精通> <LangChain4j 从入门到精通> 还有目前正在更新的 <Di ...

  9. 服务器操作SCP命令使用

    一.将本地代码上传服务器scp命令操作 命令是:scp dtcloud-master.zip root@10.14.22.141:/opt/dtcloud/ 将本地的scp dtcloud-maste ...

  10. 爬取西刺代理的IP与端口(一)

    0x01 简陋代码是,获取(.*?)的字符串 #coding:utf-8 from requests import * import re headers = { "accept" ...