AC 自动机学习笔记
preface
第一次写 ACAM 模版是 2023.7.02,现在重新回顾了一下,还是有不少新的理解的,或者说一些概念更加清晰了。
1.引入
思考这样一个问题:
给若干模式串,求询问串中出现了多少个模式串。
暴力肯定是一一比对,复杂度是 \(O(n^2)\) 以上的,可以哈希一下,那复杂度就是 \(O(n^2)\)。
回想一下 kmp 算法解决的题目,暴力匹配也是 \(O(n^2)\),但 kmp 算法利用了询问串匹配时的最长相等前后缀来加快匹配,充分利用了询问串上的信息,做到优秀的 \(O(n)\) 复杂度。那上面这题可不可以用上面的思想呢。
首先可以把模式串都搬到 trie 树上,这样各个模式串相互的前缀信息就清楚了。显然 trie 树上每个节点都对应了一条到根节点的前缀。我们在 trie 树上也维护每个节点的最长相等前后缀,但这里的最长相等前后缀就不局限于这一条到根节点的前缀了,而是整棵 trie 树。那么现在可以简单理解为把 kmp 算法搬到 trie 树就有了 ACAM 算法。
2.实现
插入和 trie 树一样,不讲。
与 kmp 算法相同,ACAM 也有失配数组 \(fail_u\),方便我们在失配后找到一个最长的可以匹配的前缀。得到 \(fail\) 数组可以在 trie 树上 bfs。
void getfail() {
std::queue<int> q;
for(int i = 0; i < 26; i++) if(tr[0][i]) q.push(tr[0][i]);
while(!q.empty()) {
int u = q.front(); q.pop();
for(int i = 0; i < 26; i++) {
if(tr[u][i]) fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
else tr[u][i] = tr[fail[u]][i];
}
}
}
做到这一步就算建出了 ACAM 了,那么前面的题怎么做呢。
3.作用
尝试在 ACAM 上走询问串,假如走到节点 \(u\),它当然对应着一个前缀,并且意味着你成功匹配到了当前位置,即模式串中存在这么一段前缀。如果有一个模式串到这里就是终点,那么此时增加贡献。否则往上跳到 \(fail_u\),也对应一个前缀,并且它与 \(u\) 的后缀相同且最长,加上他的贡献,继续往上跳 \(fail_{fail_u}\)。
这样做是不重不漏的。因为这个过程相当于你固定了询问串上的右端点,查询从右端点开始有没有这样一段后缀是模式串。
更进一步,用 \((fail_u,u)\) 的边建出一张图,那么一定仍是一棵树。每个节点对应一段前缀,当前节点代表的字符串一定包含于子节点代表的字符串且是其一段后缀。
上面的暴力跳祖先复杂度太高,怎么优化?这个过程不就是求根到节点的链和嘛,求一下 \(fail\) 树上前缀和即可。
4.延伸
假如题目变成这样:
给若干模式串,求模式串中出现了多少个询问串。(保证询问串在模式串中出现过)
可以在 fail 树上考虑这个问题。因为题目的限制,他一定对应一个树上一个节点,那么它子树中的所有节点一定包含它(换句话说,如果子树中节点失配的话,一定会跳到它),所以就转化为子树求和做了。
5.思考
其实仔细想想,kmp 不就是一个模式串的 ACAM 吗?它的 trie 树是一条链,本质是相同的。
fail 树的包含关系非常重要。
如果加入一些修改操作,都有 fail 树了,就用数据结构维护树上问题呗。通常可以用 dfs 序转化为序列问题。
比如引入的问题中,如果加删字符串(前提是 ACAM 建出来了),就是一个单点加,求链和的问题,然后这个问题可以用树上差分转化为更简单的子树加,求单点,用树状数组维护。
延伸的问题里,同样的操作,只是修改变成了若干个单点修改,询问变成了求子树和。
ACAM 又提供了一个很好的状态表示,可以和 DP 结合。套路的设 \(f_{i,j}\) 表示考虑前 \(i\) 个字符,目前在 ACAM 上的 \(j\) 节点的答案。本质上是利用 ACAM 统计的模式串的某些信息来进行转移。
6.习题
P3041 [USACO12JAN] Video Game G
经典 ACAM + DP
设 \(f_{i,j}\) 表示考虑前 \(i\) 个字符,目前在 ACAM 上的 \(j\) 节点的最高分。转移就需要知道下一个字符能够匹配多少模式串,可以预处理出来。
复杂度 \(O(k\times tot)\)。
经典的数据结构维护 ACAM 的题目,用上引出中的经典转化,需要实现子树加,单点求值。
ACAM + DP
需要发现 \(x-prime\) 字符串是很少的,因此将所有 \(x-prime\) 字符串一起建 ACAM,然后考虑 dp。设 \(f_{i,j}\) 表示考虑了前 \(i\) 个位置,在 ACAM 上状态为 \(j\),最少的删除次数使得原字符串不存在 \(x-prime\) 区间。转移不更新 \(x-prime\) 字符串的状态即可。
CF1202E You Are Given Some Strings...
考虑枚举断点 \(x\),前面给 \(s_i\),后面给 \(s_j\)。前者需要求出字符串 \(t\) 的每个前缀有多少 \(s\) 是其后缀,后者需要求出字符串 \(t\) 的每个后缀有多少 \(s\) 是其前缀。
前者是经典的 fail 树问题,后者翻转做一遍同样的事即可。
考虑离线,然后拆贡献,询问 \(s_k\) 在 \(s_{1...r}\) 中出现次数,询问 \(s_k\) 在 \(s_{1...l-1}\) 中出现次数,两者相减即可。
一边插入(若干单点加),一边计算答案即可(子树求和)。
上一题反过来,一下子不好做了。
如果是暴力的话,每次都要跑一遍 \(s_k\) 询问每个位置的价值,而 \(k\) 是可以重复的,于是 \(T\) 了。然后你想这个暴力一定能通过 \(s_k\) 长度小一点的数据,又观察到 \(\sum|s_i|\le 10^5\),所以考虑根号分治。
长度小跑暴力即可,长度大需要换一种角度。
如果考虑现在变成枚举每一个长度大的 \(s_k\),求它在区间 \([l,r]\) 的匹配情况。经典做法是从 \(s_k\) 的每个位置往上跳,看它可以跳到多少模式串,它可以转变为求所有模式串被询问串的所有位置跳到的次数和。如果长度记为 \(B\),那么这样的字符串总共不超过 \(\frac{m}{B}\),于是你标记 \(s_k\) 的所有位置,对整个 ACAM 求子树和,单次 \(O(m)\),询问前缀和后可以 \(O(1)\) 解决,于是就有了 \(O(\frac{m^2}{B})\) 的做法。
前面的做法复杂度是 \(O(qB\log m)\)。两者尽量均匀,解得 \(B=\frac{m}{\sqrt{q\log m}}\)。
总复杂度 \(\mathcal{O}(m\sqrt{q\log m})\)。
二进制分组在线 ACAM
应该是无法做到在线 ACAM,只能考虑每次重构 fail 树。考虑二进制分组,将当前的 \(n\) 个询问拆成 \(\log n\) 个区间,分别建一个 ACAM。修改就是加一个新的只有一个串的 ACAM,然后向前合并,保证任意时刻的 ACAM 个数都是 \(\log\) 个。
合并操作和线段树合并的方法类似。需要注意的是插入时要在 trie 树上插入,与 ACAM 的数组要分为两个,因为建 ACAM 的时候改变了 trie 树的结构。
每个串最多合并 \(\log m\) 次,经过 \(\log m\) 次重构 fail 树,询问把所有 ACAM 累加即可,所以复杂度是 \(O(n\log m)\)。