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 我也没办法),可以减少时间复杂度。
                    
                
                
            
        
浙公网安备 33010602011771号