AC 自动机

概述

  • 坐,都坐。我知道各位都很激动,但这个缩写的全称是 Aho-Corasick automaton,AC 是两位发明者的姓氏,automaton:自动机。

  • AC 自动机通过将 trie 的结构和 KMP 的思想结合起来,利用 trie 的结构存储多模式串(或者说构建对应的图论模型),然后将 KMP 自动机的后缀链接推广到多串情况,利用 trie 的结构将状态从匹配位置推广到 trie 上的点,建立对应的后缀链接树并与 trie 的正边合并得到 trie 图,从而高效解决多个模式串相对文本串的各种出现问题。

  • \(\Sigma\) 为字符集,则 AC 自动机的空间复杂度和建立时间复杂度均为 \(O(\sum |P|\times |\Sigma|)\)。这里认为预先输入的是模式串,询问相关的是文本串,因一般是求输入的串在询问串中的出现。

实现原理

  • trie 部分参见“字典树”。

  • 朴素实现:

    • \(fail\) 数组表示当前节点代表的状态的最大有效后缀对应的节点。

      • 我们需要谈谈什么是有效。考虑一个只插入了 \(abca\) 的 AC 自动机:

      • \(abca\) 的最大真后缀显然是 \(bca\)\(bca\) 的是 \(ca\)\(ca\) 的是 \(a\)。但 \(bca,ca\) 对我们来说都是无效的:它不是任何一个模式串的前缀

      • 事实上,我们不妨把 \(bca,ca\) 都建出来:我们会发现,这里 \(abca\) 是“关键点”,而我们只要保留“关键点”相关的点——这是个虚树!而这里的相关,或者说“lca”,是前缀。

      • 这也是 AC 自动机必须全部插完之后才构建的理由:如果在线,那么旧有的 fail 边可能是错误的,并没有指向最大有效后缀。

    • 当查询某个文本串时,如果发现不存在对应的 \(e_{now,c}\),那么 \(now=fail_{now}\) 并再次尝试,直到匹配成功或 \(now=0\) 且仍然匹配失败,此时放弃前面所有的字符从头开始。

    • 实际形态的话,我们看一张图。黄点为字符串结束点,黑边为 trie 边,红边为 fail 边。

    • 称黑边即 trie 边构成的树为 trie 树,红边即 fail 边构成的树为 fail 树。两者是 AC 自动机的灵魂;trie 图也只是由这两者共同递推出的罢了。

  • trie 图实现:

    • 容易看出,上面这个算法的主要弊病在于可能会连续多次失配,于是回跳次数太多。

    • 考虑路径压缩!

    • 显而易见 \(dep_{fail_{now}}<dep_{now}\),从而我们使用 bfs 实现,并设法让子节点的 \(fail\) 利用上父节点的 \(fail\)

    • 我们站在父亲处理儿子。假如我有这个儿子,那么 \(fail_{e_{now,c}}=e_{fail_{now},c}\),即我儿子的最大有效后缀 \(=\) 我的最大有效后缀 \(+c\)

    • 咦?万一我的最大有效后缀没有对应 \(c\) 的边怎么办?

    • 这就不得不谈及非常妙的第二种操作了。假如我没有这个儿子,那么 \(e_{now,c}=e_{fail_{now},c}\)!让虚边来承担 \(fail\),或者说,把 \(fail\) 的效果等效到 \(e\) 里。

    • 考虑 \(fail_{now}\) 没有对应 \(c\) 的转移的情况,此时我们有 \(e_{fail_{now},c}=e_{fail_{fail_{now}},c}\)

    • 即,这实质上不是 \(fail\) 了一次,而是不断 \(fail\) 直到找到匹配。

    • 如果一直找不到匹配,则最终会达到根节点的某个子节点,从而有 \(e_{fail_{now},c}=e_{0,c}=0=rt\),抛弃了所有前缀。

    • 相当于这是一种自动的路径压缩。相较于并查集的由儿子递归地压缩父亲,这里父亲已经压好了,儿子只要连上去就相当于把自己也压了(妙啊)。

    • 为了避免把每个 \(dep=1\) 的节点的 \(fail\) 以及无匹配的 \(e_{rt,c}\) 都设为 \(1\) 带来的繁琐特判,我们选择令 \(rt=0\)

  • 给出 trie 图实现的示范代码(年久失修):

struct AC_automaton{
	int e[maxn][26],tot;
	int cnt[maxn],fail[maxn];
	il void ins(string &s){
		int now=0,to=s.size()-1;
		For(i,0,to){
			if(!e[now][s[i]-'a'])
				e[now][s[i]-'a']=++tot;
			now=e[now][s[i]-'a'];
		}
		++cnt[now]; return;
	}
	int q[maxn],hd,tl;
	il void build(){
		hd=1,tl=0;
		For(i,0,25)
			if(e[0][i])
				q[++tl]=e[0][i];
		while(tl>=hd){
			int now=q[hd++];
			For(i,0,25)
				if(e[now][i]){
					fail[e[now][i]]=e[fail[now]][i];
					q[++tl]=e[now][i];
				}
				else e[now][i]=e[fail[now]][i];
		}
	}
};

例题

P5357 【模板】AC 自动机(二次加强版)

  • 题意:求文本串 \(T\)\(n\) 个模式串 \(P_{1\sim n}\) 分别的出现次数。

  • 数据范围:\(n\leqslant 2\times 10^5,\sum |P|\leqslant 2\times 10^5,|T|\leqslant 2\times 10^6\)

  • 直觉上我们可以在 trie 图上,即按 \(e\),把 \(T\) 跑一遍。访问到的点标上 \(vis\)。但这不对。

  • 我们知道这里的 \(e_{now,c}\) 很多本质上都是 \(fail\) 边的等效,是舍弃了一部分前缀之后再接一个的结果。

  • 通过这些边走到的点,其实相当于从根走到了那个点,这条链上的所有点都是文本串的一部分!

  • 考虑一种暴力做法:每到一个点,暴力回跳 \(fail\),每个点都打 \(vis\)

    • 啊...复杂度很不乐观啊,极限情况(虽然不可能)为 \(\sum |P|\times AC.dep\)

    • 考虑把指向没有打 \(vis\) 必要的点(不是字符串结尾)的 \(fail\) 压缩成另一个 \(FAIL\) 用于回跳。

    • 假优化。这上界是一样的。

  • 铛铛!正解:树形 DP(??)!

    • 考虑把 \(fail\) 数组对应的 \(fail\) 树建出来(不是 \(fail\) 边这种反边构成的内向树,是正边,正常的可以 dfs 的外向树)。

    • 而我们假做法中打的 \(vis\) 的含义其实是从这里到根的路径都 \(vis\) 了,就是说任意的直接/间接 fail...任意的有效后缀,即可能是某个模式串前缀的后缀,都 \(vis\) 了...咦?

    • 树形 DP。\(rvis_{now}=\sum rvis_{sons}+vis_{now}\)

    • 从而我们有一个稳定的单次询问复杂度为 \(O(|T|+|AC|)\)

模板题的对偶问题

  • 求文本串中所有模式串的总出现次数,多组询问,\(n=Q=\sum |P|=\sum |T|\)

  • 发现瓶颈在于 \(O(|AC|)\) 部分太大了。但不要求对每个模式串分别求出现次数,故考虑反其道而行之,预先做树形 DP!将 \(w\) 推下去(\(w\) 表示有多少个串在该点结尾,准确地说是从根到该点在 trie 上的链对应的串的插入次数),算出 \(rw\) 表示从该点到根在 fail 树上的链的 \(\sum w\),其相当于“有多少个串恰右对齐地和该点对应的串匹配”,于是只要单次 \(O(|T|)\) 即可求出。

P2292 [HNOI2004] L 语言

  • 题意:给出 \(n\) 个模式串 \(P_{1\sim n}\),给出 \(Q\) 组询问,每组询问形如求 \(T\) 的最大可理解前缀长度。所谓“可理解”,指的是可以划分成若干个可重的模式串。

  • 数据范围:\(n,|P|\leqslant 20,Q\leqslant 50,|T|\leqslant 2\times 10^6\)。实际上似乎并没有 \(10^8\) 的输入,不然不管怎么挣扎都 T 了。

  • 首先我们肯定不能上去跑,这就退化成暴搜了,多种并行决策没法决策。

  • 故考虑设计 dp:

    • 状态设计:\(dp_i\) 表示长为 \(i\) 的前缀是否可理解。

    • 初始化:\(dp_0=1\)

    • 状态转移:...我们下面慢慢说。

  • 首先考虑一个朴素转移:\(dp_i=\bigvee_{j<i} dp_j\land [T_{j+1\sim i}\in P]\)

    • 我们可以暴力枚举是 \(P\) 中那个,然后用字符串 hash 来比较。

    • 然而,端下去罢。\(\sum |T|\sim 10^8\),你怎么敢转移不 \(O(1)\) 的?你怎么敢?

    • 考虑利用 AC 自动机的性质。既然 \(|P|\leqslant 20\),就把 \(T_{i-19,i}\) 扔上去跑一遍,利用如上题中的 \(rw\) 般预处理出的
      \(rsta\)(表示有哪些串恰右对齐地和该点对应的串匹配),可以 \(O(20)\) 地检查出来每个串是否合法。

    • 不行,还是不够快。考虑暴力转递推,既然可以跑 \(T_{i-19,i}\),那岂不是也可以 \(O(1)\) 递推到 \(T_{i-18,i+1}\)?毕竟过程中经过的点其实是啥用都没有,我们关心的是它最后停在了哪里。

    • 它是个 \(20\) 位二进制状态。那么,我们把 \(dp_{i-20\sim i-1}\) 也压成 \(20\) 位二进制状态!显然它也可以 \(O(1)\) 递推,每次把高位挤掉加个低位就行了,而 \(dp\)\(rsta\) 取交的结果就是 \(dp_i\)(这是未压的,压后应该是 \(dp_{i,0}\))。

    • 好了,解决了。\(O(\sum |T|)\)

P2414 阿狸的打字机

  • 见树形 DP-欧拉环游序式。
posted @ 2023-02-02 17:32  未欣  阅读(147)  评论(0编辑  收藏  举报