AC 自动机
终于决定认真学一学 AC 自动机。
专门为 Ta 开一篇笔记大概是因为和 Ta 有着一段奇怪的感情吧。
思想 & 基本模型
AC 自动机实质上是 trie 上的 KMP。
回想 KMP 的 border,它通过计算模式串的最长相同真前后缀,实现失配时的较优复杂度。本质上我们正在处理的“当前匹配”串(以 cdcd 为例)是模式串的一个前缀,也是当前匹配串前缀 xxxcdcd 的一个后缀。从而我们跳 border 时实质上是切换了比 Ta 的更弱的后缀,如 xxxcdcd \(\to\) xxxxxcd。
AC 自动机的这个 trie 运行时的实际意义也是这样,上面的每个结点到根的路径表示某一模式串的前缀,也表示当前待匹配串前缀匹配上的一个后缀。
AC 自动机通过构建 fail 指针指向比 Ta 弱的最强后缀。考虑 fail 的构建,类比 border 的构建,从“延长”父结点的 fail 角度出发。
当父结点 fail 后恰好又能在某一模式串中匹到相同的字符,则可以成功延长,直接连 fail 边即可。否则,再次跳 fail 直到找到对应字符或跳回根。
因为我们需要得到所有深度小于 u 的结点的 fail,所以在实现上使用 bfs。
:::info[构建 trie 树和 fail 指针的代码]
const int N=1e6+5,M=26;//M: 字符集大小
int tot;
vector<int> str[N];
//其实应该写作 node: son[M],fa,fail,ch(int),str(vector<int>) 这就是在 trie 树的每个结点上加 fail 指针.
int tr[N][M],fail[N],fa[N],val[N];
void insert(const string &s,int id){
int p=0;
for(int i=0;i<s.size();i++){
if(tr[p][s[i]-'a']==0){
tr[p][s[i]-'a']=++tot;
val[tot]=s[i]-'a';
fa[tot]=p;
}
p=tr[p][s[i]-'a'];
}
str[p].emplace_back(id);
}
void bfs(){
// root=0;
queue<int> q;
for(int i=0;i<M;i++){
if(tr[0][i]) q.emplace(tr[0][i]);
}
while(!q.empty()){
int u=q.front();q.pop();
if(fa[u]!=0){
int t=fail[fa[u]];
while(t!=0){
if(tr[t][val[u]]!=0) break;
t=fail[t];
}
fail[u]=tr[t][val[u]];
}
for(int ch=0;ch<M;ch++){
if(tr[u][ch]) q.emplace(tr[u][ch]);
}
}
}
:::
查询时,注意我们自动机的返回点是最强的那个后缀,我们还需要通过跳 fail 以正确不漏地匹配上所有该匹上的模式串。
:::info[匹配部分代码]
int p=0;
for(int i=0;i<t.size();i++){
while(p!=0&&tr[p][t[i]-'a']==0) p=fail[p];
p=tr[p][t[i]-'a'];
if(p) for(int j=p;j;j=fail[j]){
for(int t:str[j]) res[t]=true;
}
}
:::
优化 & 复杂度分析
建树
AC 自动机的构建过程可以很简单地优化。我们可以充分利用 trie 树开了但是没用到的空间,压缩跳 fail 指针的过程。
void bfs(){
queue<int> q;
for(int i=0;i<M;i++){
if(tr[0][i]) q.emplace(tr[0][i]);
}
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<M;i++){
if(tr[u][i]) fail[tr[u][i]]=tr[fail[u]][i],q.emplace(tr[u][i]);
else tr[u][i]=tr[fail[u]][i];
}
}
}
从而建出 AC 自动机的复杂度为 \(O(|\sum|\times \sum L)\),其中 \(|\sum|\) 表示字符集大小,\(\sum L\) 表示模式串的长度和。
跳 fail
你很容易发现如果像上一版那样每次都滚一遍全部的 fail,复杂度假飞了。具体地,若你拿到了一个很长很长的所有字符都相等的模式串,跳完 Ta 的 fail 相当于全滚一遍,这是 \(O(L)\) 的,不可接受。
优化:
因为 fail 只会由深度大的指向深度小的,所以不可能形成环。从而我们可以在匹配点”打上标记“,最后通过拓扑排序获得最终的答案。
当然,并不一定要显式地写出拓扑排序,按照深度从大往小遍历一遍也是可行的。
如此,匹配过程的总复杂度为 \(O(|T|+\sum L)\).
应用 & 深层次理解
按照上面优化之后的过程建出 fail 指针后,整个自动机呈现出“两棵树的组合”图结构。
你可以把 Ta 当作图,利用 dfs 找环解决 P2444 病毒。
对了,时刻注意自动机的访问点是最长的可能匹配串,它的所有后缀都有可能是匹配串。 在大多数情况下,要么建出 fail 树的同时将该结点的贡献累加到整棵子树上,要么就打好标记最后遍历一遍。
trie 树和 fail 树都是树,如果你已经将问题转化为了 AC 自动机上的问题,那么树上的统计、DP 等问题也是可以在 AC 自动机上做的。
P2414 阿狸的打字机 就是一道简单的 AC 自动机转树上离线统计的问题。
终于解决一个历史遗留问题了。

浙公网安备 33010602011771号