AC 自动机学习笔记
AC 自动机是用来解决多模式串的字符串匹配问题的,他结合了 KMP 与 Trie,能够实现在 \(O(N+M+Z)\) 的时间内解决多模式串匹配问题。其中 \(N\) 是文本串长度,\(M\) 是模式串长度之和,\(Z\) 是匹配次数。
回忆一下,假如只有一个文本串 \(s\) 和一个模式串 \(t\),我们可以使用 KMP 算法解决匹配问题。我们考虑把 KMP 的思想放到 Trie 上。
基本结构
假设我们的文本串是 \(s\),模式串是 \(t_i\)。
我们把所有模式串 \(t_i\) 插到一个 Trie 里,那么现在每个 Trie 的节点都代表某个模式串的一个前缀。不妨把 Trie 树上从 \(u\) 节点出发,字符为 \(ch\) 的边看做 \(u\) 到某个节点的转移 \(trans(u,ch)\)。
一个在 Trie 树上暴力匹配的做法是,我们枚举一个文本串的起点 \(j\),接下来从根节点开始,暴力走这个 Trie 树。如果遇到一个不存在的节点就放弃,继续检查下一个 \(j\)。
这样的时间复杂度太高,主要是因为我们没有利用上失配的信息。于是我们可以引入一个失配指针 \(fail(u)\),代表 \(u\) 这个节点的最长的在 Trie 树上出现的后缀对应的节点。相比于 KMP,这里不要求 \(fail(u)\) 指向的是自己的一个前缀,任意一个模板串的前缀都可以。
假如我们已经求出了 \(fail(u)\),我们就可以从根节点 \(u=0\) 开始,每次走一个文本串的字符 \(u=trans(u,s_i)\),如果没有对应的节点,我们就跳到失配指针所指向的节点 \(u=fail(u)\)。这个做法的时间复杂度是 \(O(N)\) 的,可以用势能分析得到。
建树
接下来考虑怎么求出 \(fail(u)\)。首先,根节点以及根节点的儿子的 \(fail\) 肯定是根节点。接下来,我们用一个 BFS 来求出 \(fail\)。这是因为我们需要保证在求 \(u\) 的 \(fail\) 时,所有深度比 \(u\) 小的节点的 \(fail\) 已经被求出来了。
接下来考虑对于一个已知 \(fail(u)\) 的节点 \(u\),怎么求出他的儿子的 \(fail\)。
我们枚举每一个字符 \(ch\),分为两种情况:
-
如果 \(trans(u,ch)\) 不存在,那么我们可以令 \(trans(u,ch)=trans(fail(u),ch)\),相当于对于每个不存在的转移,在 Trie 树上新建了一条非树边用于转移。
-
\(tran(u,ch)\) 存在,那么 \(fail(trans(u,ch))\) 就应该等于 \(trans(fail(u),ch)\),由于我们上述新建边的操作,这里 \(trans(fail(u),ch)\) 一定存在。
struct _node {
int son[26], fail, cnt;
} tr[MAXN];
int tot;
void insert(const string &s) {
int n = s.size();
int u = 0;
for (int i = 0; i < n; ++i) {
int ch = s[i] - 'a';
if (!tr[u].son[ch]) tr[u].son[ch] = ++tot;
u = tr[u].son[ch];
}
tr[u].cnt++;
}
void build() {
for (int i = 1; i <= N; ++i) insert(T[i]); // 这里的 T[i] 是模式串
queue<int> q;
for (int i = 0; i < 26; ++i) {
if (tr[0].son[i]) q.push(tr[0].son[i]);
}
while (!q.empty()) {
int x = q.front(); q.pop();
for (int i = 0; i < 26; ++i) {
if (tr[x].son[i]) {
tr[tr[x].son[i]].fail = tr[tr[x].fail].son[i];
q.push(tr[x].son[i]);
} else {
tr[x].son[i] = tr[tr[x].fail].son[i];
}
}
}
}

浙公网安备 33010602011771号