AC自动机感性瞎扯
大家好,今天我们来扯自动AC机AC自动机了。
I.前置知识
trie树。(那些说需要kmp的,不会也没事,不过还是会方便理解一点)。
II.用途
AC自动机可以在的时间内预处理,并在内求出一组模式串集在一个文本串中的出现次数。
换句话说,给你个模式串至,和一个文本串,我们可以在时间将每个对做一次kmp。而用暴力的kmp是的。
III.操作
首先,我们建出一颗trie树。
如果我们暴力匹配,失配就暴力回溯的话,复杂度甚至还不如暴力kmp。
借鉴kmp的思想,如果失配,我们是不是可以略过重复部分,只做有用的匹配呢?
我们引出数组。这个神奇的数组的意思是:如果在节点处失配了,我们可以直接跳到点,避免回溯。
IV.求
显然,如果失配,我们希望这个的位置尽量更深一点,以加快匹配。
并且,与必定要有相同的前缀,不然这次跳跃就是不合法的。
我们现在采取暴力bfs的方法求。
IV.i.根的子节点
显然,对于根的子节点,它们不可能有一个合法的。而由于深度足够浅,暴力回溯也没有问题。因此,我们将它们的全都指向根。之后,将它们入队。
另外,对于没有出现在trie树中的子节点,将它们全部设成指向根,即默认失配。
IV.ii.普通节点
我们取出队首节点,设为。
枚举它的所有子节点:
IV.ii.1.已有节点(设为)
因为与有共同的前缀,所以当前缀由延伸到时,也可以向相同方向延伸。
关键代码(:trie数组,:子节点编号,:数组):
t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
非常易记,因为刚好是内外互换。
IV.ii.2.空节点
默认直接失配,直接指向原本的。
关键代码:
t[x].ch[i]=t[t[x].fail].ch[i];
IV.iii.总代码
void build(){
for(int i=0;i<26;i++){
if(t[1].ch[i])q.push(t[1].ch[i]),t[t[1].ch[i]].fail=1;
else t[1].ch[i]=1;
}
while(!q.empty()){
int x=q.front();q.pop();
for(int i=0;i<26;i++){
if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
else t[x].ch[i]=t[t[x].fail].ch[i];
}
}
}
IV.iv.感性理解
第一步,根的子节点(未出现节点未标出)入队。
现在,我们到了第层(根为层)的第一个节点。
考虑它的儿子,第层的第一个节点。
它指向的(根)的儿子。
即有:
第层的第一个节点并无儿子,忽略它的操作。
考虑第层的第二个节点。
它指向根的儿子。但是,这个并不存在,它被暴力连到了根。
即有:
第层的第一个节点并无儿子,忽略它的操作。
考虑第层的第二个节点。
它唯一的儿子,第层的第三个节点。这个节点应指向根的儿子——它的父亲,第层的第二个节点。
考虑第层的第一个节点。
它父亲的(第层的第二个节点)并无儿子。但是这个儿子已经被赋成第层的第一个节点。可是是,这个第层的第一个节点也没有儿子,则它被赋成了根(这个节点的)的儿子——第层的第一个节点。(也就是说,第层的第一个节点的儿子是它自己!)
则第层的第一个节点的连向了第层的第一个节点。(好好理解一下!)
最后有:
V.匹配
不知道大家还记不记得我们一开始的结论:
我们引出数组。这个神奇的数组的意思是:如果在节点处失配了,我们可以直接跳到点,避免回溯。
然后我们就可以暴力跳了。
见代码:
void merge(){
int x=1;
for(int i=0;i<S;i++){
x=t[x].ch[s[i]-'a'];
for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
}
}
(:该节点是哪条字符串的结尾;:统计答案的计数器)
为什么跳到一个节点,要对它所有的祖先也增加答案呢?
我也不知道。
其实是因为它所有的祖先,当到达该节点时,也又出现了一次(因为是最长相同前缀)。
那为什么暴力跳就可以,不用担心失配呢?
因为所有未出现的节点,都在求时被我们重连了!
所以暴力跳就行。
VI.大礼包
(代码:【模板】AC自动机(加强版)
#include<bits/stdc++.h>
using namespace std;
int n,cnt,occ[210],S,mx;
char dict[210][100],s[1001000];
struct node{
int ch[26],fin,fail;
}t[20100];
void ins(int id){
int x=1;
for(int i=0;i<S;i++){
if(!t[x].ch[dict[id][i]-'a'])t[x].ch[dict[id][i]-'a']=++cnt;
x=t[x].ch[dict[id][i]-'a'];
}
t[x].fin=id;
}
queue<int>q;
void build(){
for(int i=0;i<26;i++){
if(t[1].ch[i])q.push(t[1].ch[i]),t[t[1].ch[i]].fail=1;
else t[1].ch[i]=1;
}
while(!q.empty()){
int x=q.front();q.pop();
for(int i=0;i<26;i++){
if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
else t[x].ch[i]=t[t[x].fail].ch[i];
}
}
}
void merge(){
int x=1;
for(int i=0;i<S;i++){
x=t[x].ch[s[i]-'a'];
for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
}
}
int main(){
while(scanf("%d",&n)){
if(!n)return 0;
memset(t,0,sizeof(t)),memset(occ,0,sizeof(occ)),mx=0,cnt=1;
for(int i=1;i<=n;i++)scanf("%s",dict[i]),S=strlen(dict[i]),ins(i);
build();
scanf("%s",s),S=strlen(s);
merge();
for(int i=1;i<=n;i++)mx=max(mx,occ[i]);
printf("%d\n",mx);
for(int i=1;i<=n;i++)if(mx==occ[i])printf("%s\n",dict[i]);
}
return 0;
}
VII.复杂度
函数单次复杂度为。
函数复杂度为,其中为trie树的大小(可看作)。
函数最大单次复杂度为。
什么?不是说复杂度为吗?那个是从哪里冒出来的?
看这段代码。
for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
它更新了节点的所有祖先。
明显,单次跳,最少令层次上升一层。则暴力跳的话,复杂度为,其中为深度。
有没有什么办法优化它呢?
VIII.拓扑优化
因为的层次必定比的层次要低,则依靠关系建成的图必定是一张。
提到,我们一下就能想到拓扑排序。
因此我们可以在更新时,不暴力跳,转而在处打上标记。
最终用拓扑排序统计子树和。
IX.具体实现
完全相同。
函数:
void build(){
for(int i=0;i<26;i++){
if(t[1].ch[i])t[t[1].ch[i]].fail=1,q.push(t[1].ch[i]),t[1].in++;
else t[1].ch[i]=1;
}
while(!q.empty()){
int x=q.front();q.pop();
for(int i=0;i<26;i++){
if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]),t[t[t[x].fail].ch[i]].in++;
else t[x].ch[i]=t[t[x].fail].ch[i];
}
}
}
唯一的区别就是在建时统计了每个节点的入度,便于拓扑排序。
函数:
void merge(){
int x=1;
for(int i=0;i<S;i++){
x=t[x].ch[s[i]-'a'];
t[x].tms++;
}
}
可以看到,暴力跳的操作已经被修改(代码中的)取代。
最后是拓扑排序的函数:
void topo(){
for(int i=1;i<=cnt;i++)if(!t[i].in)q.push(i);
while(!q.empty()){
int x=q.front();q.pop();
t[t[x].fail].tms+=t[x].tms;
t[t[x].fail].in--;
if(!t[t[x].fail].in)q.push(t[x].fail);
for(int i=0;i<t[x].fin.size();i++)occ[t[x].fin[i]]=t[x].tms;
}
}
总体就是个大拓扑。
关键就是可能有相同的字符串,因此用来存编号。另外在拓扑中顺便维护子树和
即关键代码
t[t[x].fail].tms+=t[x].tms;
X.大礼包II
(代码:【模板】AC自动机(二次加强版)
#include<bits/stdc++.h>
using namespace std;
int n,cnt=1,S,occ[200100];
char s[2001000];
struct AC_Automaton{
int ch[26],fail,tms,in;
vector<int>fin;
}t[200100];
void ins(int id){
int x=1;
for(int i=0;i<S;i++){
if(!t[x].ch[s[i]-'a'])t[x].ch[s[i]-'a']=++cnt;
x=t[x].ch[s[i]-'a'];
}
t[x].fin.push_back(id);
}
queue<int>q;
void build(){
for(int i=0;i<26;i++){
if(t[1].ch[i])t[t[1].ch[i]].fail=1,q.push(t[1].ch[i]),t[1].in++;
else t[1].ch[i]=1;
}
while(!q.empty()){
int x=q.front();q.pop();
for(int i=0;i<26;i++){
if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]),t[t[t[x].fail].ch[i]].in++;
else t[x].ch[i]=t[t[x].fail].ch[i];
}
}
}
void merge(){
int x=1;
for(int i=0;i<S;i++){
x=t[x].ch[s[i]-'a'];
t[x].tms++;
}
}
void topo(){
for(int i=1;i<=cnt;i++)if(!t[i].in)q.push(i);
while(!q.empty()){
int x=q.front();q.pop();
t[t[x].fail].tms+=t[x].tms;
t[t[x].fail].in--;
if(!t[t[x].fail].in)q.push(t[x].fail);
for(int i=0;i<t[x].fin.size();i++)occ[t[x].fin[i]]=t[x].tms;
}
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++)scanf("%s",s),S=strlen(s),ins(i);
build();
scanf("%s",s),S=strlen(s);
merge();
topo();
for(int i=1;i<=n;i++)printf("%d\n",occ[i]);
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?