KMP & AC 自动机
好久没写学习笔记了,反正今天讲了顺手写一下。
0. 字符串匹配
求文本串 中有多少个子串和模式串 相等。
很明显,我们有个 的解法,更明显的,这个算法面对 的数据会 T 掉。
这个时候就该用我们的 KMP 啦。
1. 基本定义
Border:一个字符串的相等的真前后缀长度称为这个字符串的 Border,如 的 Border 就为 。
然后就没了。
2. KMP算法
我们发现,暴力算法慢的原因是我们会对重复的部分进行重复判定,而这个重复判定的代价高达 。后面判定的代价是不能减少的,所以我们只能考虑减少判定次数。
我们发现,如果发生失配,前缀和后缀相等的时候,就可以直接把前缀移到后缀匹配,减少了很多的匹配次数。
我们要匹配的次数尽量少,则移动的距离要尽量大,不难发现我们可以直接移动最长的相等前后缀长度。
KMP 就是这个思想,我们对 的每个前缀求出最长 Border 长度,然后用这个失配指针来直接往后跳。
3.实现
由上面我们可以发现,我们需要做的就是预处理出失配后应该去哪(其实也是最长 Border 长度),记为 。
我们可以用 DP 的方式来求这个 。 显然是 。
我们设正在求解的前缀为 ,维护一个指针 ,则:
- 如果新加入的 与 相等,则 。
- 否则,这个就相当于发生失配,直接一直跳 直到 归 或 ,然后在这里开始判断是否 ,接着跑就行。
不难写出预处理部分的代码:
12345678910111213141516
string S,T;
int kmp[MAXN],s,t;
inline void initKMP()
{
s = S.length(),t = T.length();
int j = 0;
S = ' ' + S,T = ' ' + T;
for(int i = 2;i <= t;i++)
{
while(j && T[j + 1] != T[i])
j = kmp[j];
if(T[j + 1] == T[i])
++j;
kmp[i] = j;
}
}
查询就很简单了,失配了就跳 ,指针指向末尾的就相当于找到了。
1234567891011121314151617
inline int Match()
{
int cnt = 0;
for(int i = 1;i <= s;i++)
{
while(j && T[j + 1] != S[i])
j = kmp[j];
if(T[j + 1] == S[i])
++j;
if(j == t)
{
++cnt;
j = kmp[j];
}
}
return cnt;
}
其实求 数组的过程我觉得就是在和自己匹配。
3. AC 自动机
自动机理论太长了,贴个链接自己看看吧
我们先说 AC 自动机的定义,就是以 Trie 的结构 为基础,结合了 KMP 思想建立的自动机。KMP 是拿来做单模式串匹配的,AC 自动机就是拿来做多模式串匹配的。
我们需要利用 Trie 的结构,所以我们把所有模式串扔进一棵 Trie 里面。直接插入就行,同时记录一下每个字符串的结尾的节点,后面有用。
既然结合了 KMP 的思想,那 AC 自动机也应该有个失配指针。因为 Trie 树,我们已经可以维护前缀了,所以,这个失配指针指向有着最长真后缀的节点,我们之后把它叫做 指针。
指针的构建
我们可以再次参考 KMP 构建 的思想,也就是算过了就不用再算一次,来构建 , 指向的深度一定比自己低,所以得用 BFS 来构建。
设当前节点为 , 是 经过边 的子节点,即 。还是分两种情况:
- 存在,则 ,相当于直接加一个字符,原来的最长真后缀还是最长真后缀。
- 否则,我们就一直跳 ,直到找到一个存在的,找不到就连到根节点上。
建自动机
还没完呢,现在还只是个字典树,可能会走到底就匹配不了了,我们还需要解决这种情况。
我们可以单独开个数组 ,表示自动机的转移函数,一般可以不单独开,但是一旦要用到原先字典树的结构就必须单独开。之后这个转移函数用数学符号,我觉得好看些。
一开始,我们初始化 ,然后可以和构建 同时进行。我们关注 中不存在 的情况,这个时候,我们可以为了构建 的方便,也为了之后转移的方便,把 设置为 ,然后就没有然后了。
其实就相当于可以加字符就加字符,否则就认为失配,跳 。
好了构建就完成了,下面就是更加恶心的应用了。
(1) 多模匹配
前面说了,Trie 树,或者 AC 自动机上的 Trie 图,是维护前缀的,而 指针是维护后缀的,所以,我们可以对文本串的每个前缀,都去跳 ,直到跳到 Trie 的根节点上,看经过了几个模式串的结尾。
但是,这么暴力的算法是包可以卡掉的,所以我们需要优化。
我们可以观察到,所有 指针是构成一棵内向树的,这个很好证,因为最长真后缀的深度一定小于自己,而每个节点都有最长真后缀(根节点就是空串,空串是任何非空串的最长真后缀),并且不会成环,所以一定是一棵树,之后叫做 Fail 树。
然后我们再回去看这个问题,实际上就是Trie 图上路径修改,Fail 树上子树求和,Trie 图上修改不好搞,但是我们可以优化 Fail 树上求和,子树问题可以直接上 DFS 序,然后就可以用树状数组、线段树之类的数据结构来维护了。
(2) ACAM + DP
其实很套路,一般都是设 表示已经放了 个字符,在 AC 自动机的节点 上的答案,转移就是枚举下一个字符,然后走到 Trie 图上的下一个节点。
但是,有的题可以给你把这个 开到 ,并且 AC 自动机节点数不大,这个时候就可以使出传统艺能,用矩阵快速幂优化了。
(3) 合并/重构
AC 自动机本质上是个离线算法,不支持修改模式串集,但是就有这么一道毒瘤题,让你支持插入删除字符串,并且强制在线。
因为出现次数这个东西是有可减性的,所以我们可以维护两个 ACAM,一个只负责加字符串,一个只负责减字符串。这样就不用处理删除了(不然套个 DFS 序 + 树状数组恶心死你)。
然后我们可以采用两种办法:根号重构和二进制分组来重构 ACAM(似乎优化只能重构的一般都是这两个,KDT 也是)。
二进制分组需要合并 ACAM,就写一下合并吧。像上面讲的那样,我们字典图用 数组存,然后原本的 Trie 的结构就可以保存下来了,直接 Trie 树合并就行,反正你还得重构。
(4) 处理大字符集
我们发现,求 的过程,有很多时候都只是把不存在的儿子补完,而真正需要处理 的很少。
所以,我们可以用哈希表把存在的儿子记下来,然后用主席树维护出边,这样就能做到 的复杂度。
具体的,我们在 BFS 中取出一个节点的时候,把它在主席树上的根赋为它的 的根,然后直接单点更新已经存在的点的儿子就行了。
贴个代码,省略一个可持久化区间树模板。
12345678910111213141516171819202122
inline void buildFail()
{
queue<int> q;
for(auto [i,v] : tree[0].son)
{
modify(root[0],1,V,i,v);
q.emplace(v);
}
int u,v,Fail;
while(!q.empty())
{
u = q.front(),Fail = tree[u].fail;
q.pop();
root[u] = root[Fail];
for(auto [i,v] : tree[u].son)
{
tree[v].fail = query(root[Fail],1,V,i);
modify(root[u],1,V,i,v);
q.emplace(v);
}
}
}
4. 总结
某位学长说过:ACAM 分三种题:板子题,硬套题,神仙题。前两个一眼过,后面那个一眼就跳了。
AC 自动机算字符串的入门算法了,能做的还是比较有限的。
所以,萨菲克斯·阿瑞 & 萨菲克斯·奥托玛滕,启动!