AC自动机

前言。

接下来我们使用 \(s\) 表示目标串,\(T\) 来表示模式串集合,\(T_i\) 代表 \(T\) 中第 i 个字符串。\(t_{i,j}\) 代表 \(t\) 的第 \(i\) 到第 \(j\) 个字符形成的字符串,用 \(|s|\) 来表示字符串 \(s\) 的大小,\(\Sigma\) 为字符集。

在 trie 树中,\(fa_u\) 代表节点 \(u\) 的父亲,\(son_{u,c}\) 代表节点 \(u\) 根据转移函数 \(\delta(u,c)\) 转移到的节点。若 \(\delta(u,c)\) 不存在,则为 \(0\)

AC 自动机显而易见是一个自动机,类似于 trie + KMP,主要结构为 trie 树,在其之上使用 KMP 思想添加了失配指针 \(fail_i\)

他接受且仅接受字符串 \(t \in T\) 的后缀字符串。

AC 自动机多用于多模匹配,即在一个目标串 \(s\) 中寻找多个模式串 \(t \in T\)。(当然你要在上面跑 dp 也没人会拦你)

算法复杂度

时间复杂度 \(O(\sum\limits_{t \in T}|t|+ n|\Sigma| + |T|)\)\(n\) 为 AC 自动机中节点数量)

空间复杂度 \(O(n|\Sigma|)\)

算法思想

模式串匹配,若直接用暴力算法则时间复杂度为 \(O(|s|\sum\limits_{t \in T} |t|)\)直接爆炸

为了提升速度,我们尝试使用 KMP 的思想。

假设当前匹配到串 \(t_1 \in T\) 的位置 \(i\) 时失配了,我们则寻找 \(t_{1,0,i-1}\) 的最长的后缀 \(pre\),需要满足 \(pre\) 为串 \(t_2 \in T\) 的前缀。

如何快速在模式集 \(T\) 中寻找是否有一个字符串的前缀等于 \(pre\)?trie 树。

构建一个包含且仅包含模式集 \(T\) 的 trie 树。

我们在这颗 trie 树上面按照目标串 \(s\) 的字符来遍历,若能够遍历到一个串 \(t \in T\) 的结束节点,则代表 \(s\) 中出现了 \(t\)

那如果失配呢?根据之前的决定,我们需要按照 KMP 思想寻找最长的与前缀相等的后缀 \(pre\),然后跳转到 trie 树中 \(pre\) 所对应节点上。

所以要对每个节点 \(u\) 预处理出失配指针 \(fail_u\),代表在 \(u\) 处失配后要跳到哪里(类似于 KMP 的 \(nxt_i\)

对于每个节点 \(u\),如果没有任何前缀与后缀匹配(指非空前后缀),则 \(fail_u = 0\)\(0\) 为根节点),因为最长前后缀为长度为 \(0\) 的字符串。

那么我们可以得到一个结论 \(fail_{son_{u,c}} = son_{fail_{u},c}\)

假设节点 \(u\) 到根节点路径组成的字符串为 \(s_1\),那么 \(son_{u,c}\) 到根节点路径组成的字符串为 \(s_1+c\)

\(fail_{u}\) 到根节点路径组成的字符串为 \(s_2\),那么 \(son_{fail_{u},c}\) 到根节点路径组成的字符串为 \(s_2+c\)

已知,\(s_2\)\(s_1\) 的最长的,存在于 trie 树中的后缀,所以显然,\(s_2 + c\)\(s_1 + c\) 的最长的,存在于 trie 树中的后缀。

算法流程

构建 trie 树

\(T\) 中所有字符串依次插入即可。

详细过程参照 trie 树(字典树)

构建失配指针

从根节点开始,按照深度来构建指针。

假设当前节点为 \(u\)\(v = son_{u,c}\)

\(son_{u,c}\) 存在,则 \(fail_v = son_{fail_u,c}\)

否则,\(son_{u,c} = son_{fail_u, c}\)。(由于节点 \(u\) 不存在 \(c\) 的转移,则无需考虑,直接跳到 \(son_{fail_u, c}\)

多模匹配

从根节点开始,每次走 \(s_i\) 的转移。(在构建失配指针时已经考虑到没有转移的情况,已经将 \(son_{u,c}\) 替换成 \(son_{fail_u,c}\) 了)

对于当前节点 \(u\),多次使用 \(u = fail_u\) 可以遍历其所有在 trie 树上出现的后缀。

算法伪代码

get_fail():
p = 0
queue que
for i in [0, 25]:
	if son[0][i] exists:
		insert ch[0][i] into que
		fail[ch[0][i]] = 0
while !que.empty():
	u = first of que
	delete first of que
	for i in [0, 25]:
		v = son[u][i]
		if son[u][i] exists:
			fail[v] = son[fail[u]][i]
			que.push(v);
		else:
			son[u][i] = son[fail[u]][i]
end

query(s):
n = size s
p = 0
for i in [0, n - 1]:
	p = son[p][s[i]]
	j = p
	while j != 0:
		do something
		j = fail[j]
end

算法优化

拓扑优化

可以发现,部分时间复杂度使用在最后匹配函数中的遍历所有后缀上了,代码中,就是这段:

	while j != 0:
		do something
		j = fail[j]

这段的功能是遍历所有存在于 trie 树中的后缀,并更新答案(例如统计出现次数的 ans = ans + sum[j]

我们能否找到一个方法加速?

答案是可以的,我们只在节点处打上记号,到结束后在统一统计答案。

统计答案时,我们只考虑 \(fail\) 指针,不考虑 trie 树上的边。我们将每个节点连向其 \(fail\) 指针指向的节点。

可以发现此时构成一个 DAG,有向无环图。而 j = fail[j] 可以联想到拓扑。

于是使用拓扑和递推(你偏要叫他 DP 我也没办法),可以减少时间复杂度。

posted on 2023-03-11 11:20  Evan_song  阅读(45)  评论(0)    收藏  举报