[杂谈] 关于 ACAM

要讲一节 ACAM 的课 , 但是又不知道这东西咋讲 , 写个杂谈捋捋思路 .

这个也可以称为日报 D16 , 不过为了标题美观就不写进去了 .

警世名言 : 所有前缀的所有后缀就是所有子串 .

第一个自动机

原教旨主义地讲 , ACAM 其实并不是你第一个学到的自动机 .

一般而言 Trie 才应该占据这个位置 . 但是 Trie 过于易于理解了 , 导致到了 AC 自动机这里往往会被自动机概念搞晕掉 .

我们可以简单地把自动机理解成一张图 , 每个节点代表一个状态 , 每个边代表一个转移 . 我们用字符串里的字符一个个插入自动机 , 相当于每次从当前状态 , 走当前字符所对应的转移 , 到达下一个状态 .

通过在状态上维护信息 , 可以研究关于自动机本身以及读入的字符串的问题 , 这是 OI 中自动机的一般用法 .

自动机基本的用途是识别字符串 , 这需要定义接受状态集合 , 如果一个字符串读入完成后的最后一个状态时接受状态 , 就说明这个字符串被这个自动机识别了 .

这一点用处不大 , 但是可以帮助你理解自动机维护了什么 .

注意接受状态本身不是限定的 . 比如 , 如果认为 Trie 的接受状态集合是所有叶子节点 , 那么它接受 "一个字符串集合 \(S\) 的所有元素 " . 但是如果认为其接受状态集合是所有非根节点 , 那么它接受 " 这个集合内任意元素的任意前缀 " .

那么 , 在一般的定义下 , ACAM 接受 存在后缀与字符串集合内的某个元素匹配 的字符串 .

什么叫把 KMP 放到 Trie 上

这个表述非常差劲 , 真的非常差劲 . 如果你在第一次学 ACAM 时成功理解了这句话 , 大概率你对 ACAM 的理解是失败的 .

我们选择直接回到字符串匹配的视角去看问题 .

现在你试图一步步加入主串 \(S\) 中的字符 \(c\) , 每一步事实上代表了读取主串 \(S\) 的一个前缀 , 如果这个前缀恰好有一个后缀能与模式串 \(T\) 匹配 , 说明 \(S\) 存在一个子串与 \(T\) 匹配 .

因此我们希望维护能与已经读入的 \(S\) 的后缀匹配的 \(T\)最长前缀 , 在理想情况下 , 每增加一个字符 \(c\) , 恰好与 \(T\) 的当前前缀的下一位匹配 , 前缀长度增加 \(1\) .

然而 , 如果 \(c\) 不能与下一位匹配 , 当前维护的这个前缀相当于废掉了 ( fail 指针的得名由来 ) , 我们需要尽可能地利用这个前缀的残余 , 即找到它最长的后缀 , 使得这个后缀能够与 \(T\) 的某个前缀匹配 , 并且下一位可以匹配 \(c\) .

如果你绕过了这个弯 , 你就重新理解了字符串匹配的基本思路 .

那么这时候你要把主串 \(S\) 和一个模式串集合 \(\{T\}\) 做匹配 . 用字典树来维护模式串集合 , 在 \(c\) 与下一位不匹配的情况下 , 去找集合中最长的可用前缀去与 \(c\) 匹配 .

这就是 AC 自动机 .

简单强势的 ACAM 建法并不是背板子

先谈谈为什么用 Trie 结构来维护模式串集合 . Trie 的优秀特性之一就是用同一状态维护集合内所有相同的前缀 , 而匹配的过程就是不断地找可用前缀 .

然后是失配后如何找到最长的可用前缀 , 我们用 \(fail(x)\) 指针表示当前状态 \(x\) 失配后能找到的最长接受前缀 , 用 \(to(x,c)\) 表示当前状态 \(x\) 读入 \(c\) 转移的位置 .

在匹配情况下 , 有

\[fail(to(x,c))=to(fail(x),c) \]

在失配情况下 , 有

\[to(x,c)=to(fail(x),c) \]

我们发现按照字典树上 \(bfs\) 序处理 , 相当于按长度排序 , 即可始终保证用到的 \(fail(x)\) 已经被处理过 .

其实这里本来想文字解析一下思想的 , 后来发现直接形式化就很清晰了 . 理解之后再写 , 不要背板子 .

插一嘴 , 简单强势的 SAM 建法是背板子 .

补一个大字符集\(\log\) 建法 . 注意到只考虑失配时 , 相当于把 \(fail(x)\)\(to\) 数组整体复制给了 \(x\) , 只有在匹配时需要单独修改 .

因此用可持久化线段树维护 , 修改次数就是匹配转移数 .

注意这个做法仍然维护了整个字符集的转移 , 只不过是用数据结构优化了复制 .

我们终将回到树上

注意到 , \(fail\) 具有天生的树性质 , 因为所有对非根节点 \(x\) , \(len(fail(x) )<len(x)\) , 具有良好的拓扑序结构 , 因此 \(fail\) 可以视作一棵树 .

注意到我们在同时匹配多个字符串 , 因此我们不止需要考察当前的最长接受前缀是否恰好匹配集合中的一个元素 , 而是要考察所有接受前缀是否匹配 . 显然 , 从最长接受前缀不断跳 \(fail\) 指针就得到所有接受前缀 .

放到 \(fail\) 树上 , 每一次转移到的状态 , 事实上更新的是这个节点到根的一条链 , 在确定这一点后 , 就可以用维护树的手段来处理多模式串匹配的问题了 .

注意到 , \(fail\) 树只是 AC 自动机的辅助结构 , 其本身并不能称为一个自动机 , 甚至不是严格定义下的自动机的一部分 . 但是它的用处往往和自动机结构本身不相上下 .

插一嘴 , 对 SAM 而言 , 往往用的是辅助结构 , 自动机结构才是飞舞的那个 .

AC 自动机可能是 KMP , 但是 AC 自动机是 KMP 不太可能

这里的观点就见仁见智了 . 我更多倾向于 , AC 自动机和 KMP 并非前置关系 . AC 自动机更多应该说利用了字符串匹配的基本思路 , 其根本是所有前缀的所有后缀就是所有子串以及一个贪心思路 .

不要用 KMP 去理解 AC 自动机 , AC 自动机因为维护多模式串 , 需要把每个状态向字符集的所有转移显式建出来 , 否则无法传递 fail . 而 KMP 因为只面对一个模式串 , fail 的形式变得大大简单了 , 因此只需维护后缀数组 , 同时失配转移上使用了均摊复杂度 , 且与字符集无关 .

本质上来讲 , 单串求前缀函数多需要了一个性质 : border的border等价于border的fail , 即broder本身同时表示了最长匹配段和 fail , 而 ACAM 的 trie 结构上这一点并不满足 , 因此一定要显式建立出所有转移 .

另外存在所谓 KMP 自动机 , 本质上可以理解成对单串建 ACAM , 或者利用前缀函数显式建出了所有转移 .

多说两句 , " KMP " 对字符串相关的一些讨论造成了很严重的污染 .

首先 , 我们一般说的 "KMP" 人家本名叫前缀函数 , 符号是 \(\pi(x)\) . KMP 只是利用前缀函数做单模式串匹配的方法而已 .

其次 , 所谓 "扩展KMP" , 人家本名叫 \(z\) 函数 , 和 KMP 关系也不大 . 首先求法上和前缀函数的思路就不一致 , 相反它更类似 manacher 的信息复用和单调指针均摊 . 其次它的用途仅有找匹配子串位置 , 这一点和 KMP 方法的问题形式类似 . 不过我仍然不认同称 \(z\) 函数为 "exKMP" , 毕竟没人会把线段树叫 "exBIT" .

最后就是 ACAM 问题 . 它的处理失配和前缀函数思路相同不假 , 但是处理方式上有区别 , 而且容易混乱 . 从实用主义的视角看 , 不如和所谓 "KMP" 切割 , 并且回归字符串匹配基本思路这一点 .

一句话总结 , 推荐把 "KMP" , "z函数" , "ACAM" 三者切割后理解 .

以上观点可能比较激进 , 但是确实是字符串算法方面一开始非常容易混乱的点 .

当你很熟悉这些算法再回来讨论他们的联系也不迟 .

独立思考很重要 .


后记

以上完全不能看作一篇 ACAM 的学习笔记 , 不过是个人关于 ACAM 以及相关的话题的一些想法 . 字符串这一块很容易混乱 , 真正有效的学习还是就题分析 , 通过实践 , 最后上升到对算法本身的理解程度上 .

全文都是观点输出 , 主要还是写给自己看的 , 用来整理一下自己的零碎观点和见解 .

posted @ 2025-06-11 18:30  youlv  阅读(160)  评论(0)    收藏  举报