AC 自动机学习笔记
AC 自动机学习笔记
AC 自动机可以用于解决字符串上的出现次数,出现位置问题。结合了 Trie 树和 KMP 的思想,在 \(O(n)\) 的时间内完成查询 。相较于 KMP 的好处在于,AC 自动机不仅速度快,而且支持多个模式串同时在一个文本串内查询。
算法
前置知识:Trie 树,KMP,自动机基本概念。
构建 AC 自动机有两个步骤:
1.基础 Trie 结构:将所有模式串构建成一颗 Trie 树。
2.KMP 思想:对 Trie 树上的节点构造失配指针。
然后进行多模式匹配。
构建 Trie 树
直接把所有模式串构建普通的 Trie 树,同时强调,Trie 树中的某个节点表示了某个模式串的前缀。
后文将前缀也成为状态。
失配指针
AC 自动机利用 fail 指针来优化多模式串的匹配。
状态 \(u\) 的 fail 指针指向状态 \(v\),这里状态 \(v\) 是状态 \(u\) 的最长后缀。
如果不存在最长后缀,那么 fail 会指向 Trie 树的根节点。
匹配时,文本串会从根开始沿着和当前字符一样的边向下,如果在某一个节点无法向下,我们称为失配。
在文本串失配时,将会沿着当前节点的 fail 指针前往下一个点继续匹配。
(而且你会发现,文本串到达了某个状态,那么沿着这个状态的 fail 指针遍历,这些遍历的状态也都在文本串中出现过,这个性质后面会用到。)
证明求出 fail 指针呢?
设 \(p\) 为点 \(u\) 的父亲,且深度小于 \(u\) 的点的 fail 指针均已经求出,节点 \(p\) 通过字符 \(c\) 指向 \(u\)。这条边为 \(trie[p,c]\)。
1.如果 \(trie[fail[p],c]\) 存在,u 的 fail 指针直接指向 \(trie[fail[p],c]\)。
2.否则重复使 \(p=fail[p]\)(这里会改变 \(p,u\) 的关系,但是不重要),直到寻找的一个存在的 \(trie[fail[p],c]\),能够让 u 的 fail 指针指向 \(trie[fail[p],c]\)。
3.真的不存在,让 fail 指针指向根。
如此完成 fail 的构建。
字典树与字典图
1.字典树:
void insert(char *s,int num)
{
int u=1,len=strlen(s+1);
for(int i=1;i<=len;i++)
{
int v=s[i]-'a';
if(!trie[u].son[v]) trie[u].son[v]=++cnt;
u=trie[u].son[v];
}
if(!trie[u].flg) trie[u].flg=num;
rev[num]=trie[u].flg;
}
这里的 flg 指向了到达这个节点的第一个模式串,如果有别的模式串相同,那么我们让这个模式串指向第一个相同的即可。
2.字典图
我们用文本串遍历字典树时,如果失配要沿着 fail 指针找到一个可以匹配的节点,但这个过程往往会耗费很多时间。
考虑能不能优化这个过程,我们设 \(tr[u,c]\) 为 Trie 树上的节点 \(u\) 在末尾添加一个字符 \(c\) 到达的节点,这里 \(tr[u,c]\) 优先指向自己的儿子。
特别的,如果根节点的 \(tr[rt,c]\) 不存在,那么会指向根节点自己。
不难发现,\(fail[u]\) 的深度肯定小于 \(u\),在处理 \(u\) 节点时,所有深度小于 \(u\) 节点的 \(tr\) 已经求出。
如果 \(u\) 节点有通过字符 \(c\) 到达的儿子,那么 \(tr[u,c]\) 指向自己的儿子;如果没有,那么 \(tr[u,c]\) 指向 \(tr[fail[u],c]\)。
这样子我们就省去了遍历 fail 链的时间,直接通过 \(tr[u,c]\) 跳节点即可。
void getfail()
{
for(int i=0;i<26;i++) trie[0].son[i]=1;//tr[0,c] 均指向根
que.push(1);
trie[1].fail=0;//根的 fail 指针指向 0,方便后面求 tr[u,c]
while(!que.empty())
{
int u=que.front();
que.pop();
int fail=trie[u].fail;
for(int i=0;i<26;i++)
{
int v=trie[u].son[i];
if(!v)
{
trie[u].son[i]=trie[fail].son[i];
continue;
}
trie[v].fail=trie[fail].son[i];
que.push(v);
}
}
}
\(trie[u].son[i]\) 就是 \(tr[u,i]\)。
多模式匹配
朴实无华的做法:
设当前的字文本串为 \(s\),是第 \(i\) 个字符,所在节点为 \(u\)。
一开始 \(u\) 节点为根,\(i=1\)。
沿着 \(tr[u,s_i]\) 遍历字典树,遍历一个节点,就沿着这个节点的 fail 链走一次,fail 链上的节点(包括自己)的出现次数 \(+1\)。
这里之前分析过,如果该状态出现过,那么该状态的最长后缀也出现过。为了避免漏掉状态,这也是我们 fail 指针指向最长后缀。
最后输出结束位置的出现次数即可。
华丽的做法:
式证明 fail 链将会形成树。
显然,由于 fail 指针指向比自己深度小的节点,而且不可能有环。得证。
由于每个节点都可以通过 fail 链回到根,那我们可以基于朴实无华的做法分出两种做法。
1.拓扑排序优化
遍历字典树时,只在当前节点出现次数 \(+1\)。
最后,通过在 fail 树上拓扑排序求出答案。
建图这么写(记录入度即可):
void getfail()
{
for(int i=0;i<26;i++) trie[0].son[i]=1;
que.push(1);
trie[1].fail=0;
while(!que.empty())
{
int u=que.front();
que.pop();
int fail=trie[u].fail;
for(int i=0;i<26;i++)
{
int v=trie[u].son[i];
if(!v)
{
trie[u].son[i]=trie[fail].son[i];
continue;
}
trie[v].fail=trie[fail].son[i];
indeg[trie[fail].son[i]]++;//记录入度
que.push(v);
}
}
}
拓扑排序加查询这么写:
void query(char *s)
{
int u=1,len=strlen(s+1);
for(int i=1;i<=len;i++)
u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
void topu()
{
for(int i=1;i<=cnt;i++) if(!indeg[i]) que.push(i);
while(!que.empty())
{
int u=que.front();
que.pop();
vis[trie[u].flg]=trie[u].ans;
int v=trie[u].fail;
trie[v].ans+=trie[u].ans;
if(!(--indeg[v])) que.push(v);
}
}
然后合起来:
#include<bits/stdc++.h>
using namespace std;
const int maxn=8e5+5,maxm=2e6+5;
int n,cnt=1,ans;
int vis[maxn],rev[maxn],indeg[maxn];
char s[maxm];
struct node
{
int son[27];
int fail,ans,flg;
void clr(){memset(son,0,sizeof(son));fail=flg=0;}
}trie[maxn];
void insert(char *s,int num)
{
int u=1,len=strlen(s+1);
for(int i=1;i<=len;i++)
{
int v=s[i]-'a';
if(!trie[u].son[v]) trie[u].son[v]=++cnt;
u=trie[u].son[v];
}
if(!trie[u].flg) trie[u].flg=num;
rev[num]=trie[u].flg;
}
queue<int>que;
void getfail()
{
for(int i=0;i<26;i++) trie[0].son[i]=1;
que.push(1);
trie[1].fail=0;
while(!que.empty())
{
int u=que.front();
que.pop();
int fail=trie[u].fail;
for(int i=0;i<26;i++)
{
int v=trie[u].son[i];
if(!v)
{
trie[u].son[i]=trie[fail].son[i];
continue;
}
trie[v].fail=trie[fail].son[i];
indeg[trie[fail].son[i]]++;
que.push(v);
}
}
}
void query(char *s)
{
int u=1,len=strlen(s+1);
for(int i=1;i<=len;i++)
u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
void topu()
{
for(int i=1;i<=cnt;i++) if(!indeg[i]) que.push(i);
while(!que.empty())
{
int u=que.front();
que.pop();
vis[trie[u].flg]=trie[u].ans;
int v=trie[u].fail;
trie[v].ans+=trie[u].ans;
if(!(--indeg[v])) que.push(v);
}
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%s",s+1),insert(s,i);
getfail();
scanf("%s",s+1);
query(s);
topu();
for(int i=1;i<=n;i++) printf("%d\n",vis[rev[i]]);
}
子树求和
通过上述查询方法,在最后每个节点用 fail 树上信息求和即可。
例题
挖个坑。
推荐阅读
通过 gif 的方式,写的很好。