AC 自动机

字符串 / ac-automaton

本地源文件:docs/string__ac-automaton.md

AC 自动机

概述

AC(Aho–Corasick)自动机是 以 Trie 的结构为基础 ,结合 KMP 的思想 建立的自动机,用于解决多模式匹配等任务.

AC 自动机本质上是 Trie 上的自动机.

在阅读本文之前,请先阅读 KMPTrie

解释

简单来说,建立一个 AC 自动机有两个步骤:

  1. 基础的 Trie 结构:将所有的模式串构成一棵 Trie;
  2. KMP 的思想:对 Trie 树上所有的结点构造失配指针.

建立完毕后,就可以利用它进行多模式匹配.

字典树构建

AC 自动机在初始时会将若干个模式串插入到一个 Trie 里,然后在 Trie 上建立 AC 自动机.这个 Trie 就是普通的 Trie,按照 Trie 原本的建树方法建树即可.

需要注意的是,Trie 中的结点表示的是某个模式串的前缀.我们在后文也将其称作状态.一个结点表示一个状态,Trie 的边就是状态的转移.

形式化地说,对于若干个模式串 𝑠1,𝑠2,⋯,𝑠𝑛s1,s2,⋯,sn,将它们构建一棵字典树后的所有状态的集合记作 𝑄Q

失配指针

AC 自动机利用一个 fail 指针来辅助多模式串的匹配.

状态 𝑢u 的 fail 指针指向另一个状态 𝑣v,其中 𝑣 ∈𝑄v∈Q,且 𝑣v 是 𝑢u 的最长后缀(即在若干个后缀状态中取最长的一个作为 fail 指针).

fail 指针与 KMP 中的 next 指针相比:

  1. 共同点:两者同样是在失配的时候用于跳转的指针.
  2. 不同点:next 指针求的是最长 Border(即最长的相同前后缀),而 fail 指针指向所有模式串的前缀中匹配当前状态的最长后缀.

因为 KMP 只对一个模式串做匹配,而 AC 自动机要对多个模式串做匹配.有可能 fail 指针指向的结点对应着另一个模式串,两者前缀不同.

总结下来,AC 自动机的失配指针指向当前状态的最长后缀状态.

注意:AC 自动机在做匹配时,同一位上可匹配多个模式串.

构建指针

下面介绍构建 fail 指针的 基础思想

构建 fail 指针,可以参考 KMP 中构造 next 指针的思想.

考虑字典树中当前的结点 𝑢u,𝑢u 的父结点是 𝑝p,𝑝p 通过字符 𝑐c 的边指向 𝑢u,即 trie⁡(𝑝,𝑐) =𝑢trie⁡(p,c)=u.假设深度小于 𝑢u 的所有结点的 fail 指针都已求得.

  1. 如果 trie⁡(fail⁡(𝑝),𝑐)trie⁡(fail⁡(p),c) 存在:则让 𝑢u 的 fail 指针指向 trie⁡(fail⁡(𝑝),𝑐)trie⁡(fail⁡(p),c).相当于在 𝑝p 和 fail⁡(𝑝)fail⁡(p) 后面加一个字符 𝑐c,分别对应 𝑢u 和 fail⁡(𝑢)fail⁡(u)
  2. 如果 trie⁡(fail⁡(𝑝),𝑐)trie⁡(fail⁡(p),c) 不存在:那么我们继续找到 trie⁡(fail⁡(fail⁡(𝑝)),𝑐)trie⁡(fail⁡(fail⁡(p)),c).重复判断过程,一直跳 fail 指针直到根结点;
  3. 如果依然不存在,就让 fail 指针指向根结点.

如此即完成了 fail⁡(𝑢)fail⁡(u) 的构建.

例子

下面将使用若干张 GIF 动图来演示对字符串 𝚒i、𝚑𝚎he、𝚑𝚒𝚜his、𝚜𝚑𝚎she、𝚑𝚎𝚛𝚜hers 组成的字典树构建 fail 指针的过程:

  1. 黄色结点:当前的结点 𝑢u
  2. 绿色结点:表示已经 BFS 遍历完毕的结点.
  3. 橙色的边:fail 指针.
  4. 红色的边:当前求出的 fail 指针.

AC_automation_gif_b_3.gif

我们重点分析结点 66 的 fail 指针构建:

AC_automation_6_9.png

找到 66 的父结点 55,fail⁡(5) =10fail⁡(5)=10.然而结点 1010 没有字母 𝚜s 连出的边;继续跳到 1010 的 fail 指针,fail⁡(10) =0fail⁡(10)=0.发现 00 结点有字母 𝚜s 连出的边,指向 77 结点;所以 fail⁡(6) =7fail⁡(6)=7

下图展示了构建完毕的状态:

finish

字典树与字典图

关注构建函数 build,该函数的目标有两个,一个是构建 fail 指针,一个是构建自动机.相关变量定义如下:

  1. tr[u].son[c]:有两种理解方式.我们可以简单理解为字典树上的一条边,即 trie⁡(𝑢,𝑐)trie⁡(u,c);也可以理解为从状态(结点)𝑢u 后加一个字符 𝑐c 到达的状态(结点),即一个状态转移函数 trans⁡(𝑢,𝑐)trans⁡(u,c).为了方便,下文中我们将用第二种理解方式.
  2. 队列 q:用于 BFS 遍历字典树.
  3. tr[u].fail:结点 𝑢u 的 fail 指针.

实现

C++Python

---|---

---|---

解释

build 函数将结点按 BFS 顺序入队,依次求 fail 指针.这里的字典树根结点为 00,我们将根结点的子结点一一入队.若将根结点入队,则在第一次 BFS 的时候,会将根结点儿子的 fail 指针标记为本身.因此我们将根结点的儿子一一入队,而不是将根结点入队.

然后开始 BFS:每次取出队首的结点 𝑢u(fail⁡(𝑢)fail⁡(u) 在之前的 BFS 过程中已求得),然后遍历字符集(这里是 0 ∼250∼25,对应 𝚊 ∼𝚣a∼z,即 𝑢u 的各个子结点):

  1. 如果 trans⁡(𝑢,𝑐)trans⁡(u,c) 存在,我们就将 trans⁡(𝑢,𝑐)trans⁡(u,c) 的 fail 指针赋值为 trans⁡(fail⁡(𝑢),𝑐)trans⁡(fail⁡(u),c).根据之前的描述,我们应该用 while 循环,不停地跳 fail 指针,判断是否存在字符 𝑐c 对应的结点,然后赋值,但此处通过特殊处理简化了这些代码,将在下文说明;
  2. 否则,令 trans⁡(𝑢,𝑐)trans⁡(u,c) 指向 trans⁡(fail⁡(𝑢),𝑐)trans⁡(fail⁡(u),c) 的状态.

这里的处理是,通过 else 语句的代码修改字典树的结构,将不存在的字典树的状态链接到了失配指针的对应状态.在原字典树中,每一个结点代表一个字符串 𝑆S,是某个模式串的前缀.而在修改字典树结构后,尽管增加了许多转移关系,但结点(状态)所代表的字符串是不变的.

而 trans⁡(𝑆,𝑐)trans⁡(S,c) 相当于是在 𝑆S 后添加一个字符 𝑐c 变成另一个状态 𝑆′S′.如果 𝑆′S′ 存在,说明存在一个模式串的前缀是 𝑆′S′,否则我们让 trans⁡(𝑆,𝑐)trans⁡(S,c) 指向 trans⁡(fail⁡(𝑆),𝑐)trans⁡(fail⁡(S),c).由于 fail⁡(𝑆)fail⁡(S) 对应的字符串是 𝑆S 的后缀,因此 trans⁡(fail⁡(𝑆),𝑐)trans⁡(fail⁡(S),c) 对应的字符串也是 𝑆′S′ 的后缀.

换言之在 Trie 上跳转的时侯,我们只会从 𝑆S 跳转到 𝑆′S′,相当于匹配了一个 𝑆′S′;但在 AC 自动机上跳转的时侯,我们会从 𝑆S 跳转到 𝑆′S′ 的后缀,也就是说我们匹配一个字符 𝑐c,然后舍弃 𝑆S 的部分前缀.舍弃前缀显然是能匹配的.同时如果文本串能匹配 𝑆S,显然它也能匹配 𝑆S 的后缀,所以 fail 指针同样在舍弃前缀.所谓的 fail 指针其实就是 𝑆S 的一个后缀集合.

Trie 的结点的孩子数组 son 还有另一种比较简单的理解方式:如果在位置 𝑢u 失配,我们会跳转到 fail⁡(𝑢)fail⁡(u) 的位置.注意这会导致我们可能沿着 fail 数组跳转多次才能来到下一个能匹配的位置.所以我们可以用 son 直接记录记录下一个能匹配的位置,这样保证了程序的时间复杂度.

此处对字典树结构的修改,可以使得匹配转移更加完善.同时它将 fail 指针跳转的路径做了压缩,使得本来需要跳很多次 fail 指针变成跳一次.

过程

这里依然用若干张 GIF 动图展示构建过程:

AC_automation_gif_b_pro3.gif

  1. 蓝色结点:BFS 遍历到的结点 𝑢u
  2. 蓝色的边:当前结点下,AC 自动机修改字典树结构连出的边.
  3. 黑色的边:AC 自动机修改字典树结构连出的边.
  4. 红色的边:当前结点求出的 fail 指针.
  5. 黄色的边:fail 指针.
  6. 灰色的边:字典树的边.

可以发现,众多交错的黑色边将字典树变成了 字典图 .图中省略了连向根结点的黑边(否则会更乱).我们重点分析一下结点 55 遍历时的情况.我们求 trans⁡(5,𝚜) =6trans⁡(5,s)=6 的 fail 指针:

AC_automation_b_7.png

本来的策略是找 fail 指针,于是我们跳到 fail⁡(5) =10fail⁡(5)=10 发现没有 𝚜s 连出的字典树的边,于是跳到 fail⁡(10) =0fail⁡(10)=0,发现有 trie⁡(0,𝚜) =7trie⁡(0,s)=7,于是 fail⁡(6) =7fail⁡(6)=7;但是有了黑边、蓝边,我们跳到 fail⁡(5) =10fail⁡(5)=10 之后直接走 trans⁡(10,𝚜) =7trans⁡(10,s)=7 就走到 77 号结点了.

这就是 build 完成的两件事:构建 fail 指针和建立字典图.这个字典图也会在查询的时候起到关键作用.

多模式匹配

接下来分析匹配函数 query

实现

C++Python

---|---

---|---

解释

这里 𝑢u 作为字典树上当前匹配到的结点,res 即返回的答案.循环遍历匹配串,𝑢u 在字典树上跟踪当前字符.利用 fail 指针找出所有匹配的模式串,并累加到答案中.然后将匹配到的串的出现次数清零,这样就不会重复统计同一个串.在上文中我们分析过,字典树的结构其实就是一个 trans 函数,而构建好这个函数后,在匹配字符串的过程中,我们会舍弃部分前缀达到最低限度的匹配.fail 指针则指向了更多的匹配状态.最后上一份图.对于刚才的自动机:

AC_automation_b_13.png

我们从根结点开始尝试匹配 𝚞𝚜𝚑𝚎𝚛𝚜𝚑𝚎𝚒𝚜𝚑𝚒𝚜ushersheishis,那么 𝑝p 的变化将是:

AC_automation_gif_c.gif

  1. 红色结点:𝑝p 结点.
  2. 粉色箭头:𝑝p 在自动机上的跳转.
  3. 蓝色的边:成功匹配的模式串.
  4. 蓝色结点:示跳 fail 指针时的结点(状态).

效率优化

题目请参考洛谷 P5357【模板】AC 自动机

因为我们的 AC 自动机中,每次匹配,会一直向 fail 边跳来找到所有的匹配,但是这样的效率较低,在某些题目中会超时.

那么需要如何优化呢?首先需要了解到 fail 指针的一个性质:一个 AC 自动机中,如果只保留 fail 边,那么剩余的图一定是一棵树.

这是显然的,因为 fail 不会成环,且深度一定比现在低,所以得证.

这样 AC 自动机的匹配就可以转化为在 fail 树上的链求和问题,只需要优化一下该部分就可以了.

这里提供两种思路.

拓扑排序优化

观察到时间主要浪费在在每次都要跳 fail.如果我们可以预先记录,最后一并求和,那么效率就会优化.

于是我们按照 fail 树,做一次内向树上的拓扑排序,就能一次性求出所有模式串的出现次数.

build 函数在原先的基础上,增加了入度统计一部分,为拓扑排序做准备.

构建

---|---

然后我们在查询的时候就可以只为找到结点的 `ans` 打上标记,在最后再用拓扑排序求出答案.

查询

---|---

最后是主函数:

主函数

---|---

模板题 [Luogu P5357「模板」AC 自动机](https://www.luogu.com.cn/problem/P5357) 拓扑排序优化参考代码

---|---

DFS 优化

和拓扑排序的思路接近,不过我们使用 DFS 来代替拓扑排序.其实这两种方法本质上是相同的,都是将 fail 树的子树求和.

完整代码请见总结模板 3.

AC 自动机上 DP

这部分将以 [P2292 [HNOI2004] L 语言](https://www.luogu.com.cn/problem/P2292) 为例题讲解.

不难想到一个朴素的思路:建立 AC 自动机,在 AC 自动机上对于所有 fail 指针的子串转移,最后取最大值得到答案.

主要代码如下.若不熟悉代码中的类型定义,可以先看末尾的完整代码:

查询部分主要代码

---|---

但是这样的思路复杂度不是线性(因为要跳每个结点的 fail),会在第二个子任务中超时,所以我们需要进行优化.

我们再看看题目的特殊性质,我们发现所有单词的长度只有 2020![](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7),所以可以想到状态压缩优化.

我们发现,目前的时间瓶颈主要在跳 fail 这一步,如果我们可以将这一步优化到 𝑂(1)O(1)![](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7),就可以保证整个问题在严格线性的时间内被解出.

我们可以将前 2020![](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7) 位字母中,可能的子串长度存下来,并压缩到状态中,存在每个子结点中.

那么我们在 `build` 的时候就可以这么写:

构建 fail 指针

---|---

然后查询时就可以去掉跳 fail 的循环,将代码简化如下:

查询

---|---

我们的 `tr[u].stat` 维护的是从结点 𝑢u![](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7) 开始,整条 fail 链上的长度集(因为长度集小于 3232![](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7) 所以不影响),而 `st` 则维护的是查询字符串走到现在,前 3232![](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7) 位(因为状态压缩自然溢出)的长度集.

`&` 运算后结果不为 00![](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7),则代表两个长度集的交集非空,我们此时就找到了一个匹配.

[P2292 [HNOI2004] L 语言](https://www.luogu.com.cn/problem/P2292) 完整代码

---|---

总结

时间复杂度:定义 |𝑠𝑖||si| 是模板串的长度,|𝑆||S| 是文本串的长度,|Σ||Σ| 是字符集的大小(常数,一般为 2626).如果连了 trie 图,时间复杂度就是 𝑂(∑|𝑠𝑖| +𝑛|Σ| +|𝑆|)O(∑|si|+n|Σ|+|S|),其中 𝑛n 是 AC 自动机中结点的数目,并且最大可以达到 𝑂(∑|𝑠𝑖|)O(∑|si|).如果不连 trie 图,并且在构建 fail 指针的时候避免遍历到空儿子,时间复杂度就是 𝑂(∑|𝑠𝑖| +|𝑆|)O(∑|si|+|S|)

模板题 Luogu P3808 AC 自动机(简单版) 参考代码

---|---

模板题 [Luogu P3796 AC 自动机(简单版 II)](https://www.luogu.com.cn/problem/P3796) 参考代码

---|---

模板题 Luogu P5357「模板」AC 自动机 DFS 优化参考代码

---|---

* * *

> __本页面最近更新: 2026/1/7 08:56:54,[更新历史](https://github.com/OI-wiki/OI-wiki/commits/master/docs/string/ac-automaton.md)
>  __发现错误?想一起完善?[在 GitHub 上编辑此页!](https://oi-wiki.org/edit-landing/?ref=/string/ac-automaton.md "edit.link.title")
>  __本页面贡献者:[Ir1d](https://github.com/Ir1d), [Tiphereth-A](https://github.com/Tiphereth-A), [sshwy](https://github.com/sshwy), [ksyx](https://github.com/ksyx), [Marcythm](https://github.com/Marcythm), [orzAtalod](https://github.com/orzAtalod), [Xeonacid](https://github.com/Xeonacid), [c-forrest](https://github.com/c-forrest), [Enter-tainer](https://github.com/Enter-tainer), [GavinZhengOI](https://github.com/GavinZhengOI), [Henry-ZHR](https://github.com/Henry-ZHR), [iamtwz](https://github.com/iamtwz), [shuzhouliu](https://github.com/shuzhouliu), [383494](https://github.com/383494), [abc1763613206](https://github.com/abc1763613206), [alphagocc](https://github.com/alphagocc), [aofall](https://github.com/aofall), [Chrogeek](https://github.com/Chrogeek), [CoelacanthusHex](https://github.com/CoelacanthusHex), [Dafenghh](https://github.com/Dafenghh), [DanJoshua](https://github.com/DanJoshua), [Early0v0](https://github.com/Early0v0), [Gesrua](https://github.com/Gesrua), [GoodCoder666](https://github.com/GoodCoder666), [HeRaNO](https://github.com/HeRaNO), [kenlig](https://github.com/kenlig), [liangbob2023](https://github.com/liangbob2023), [lyccrius](https://github.com/lyccrius), [Menci](https://github.com/Menci), [opsiff](https://github.com/opsiff), [ouuan](https://github.com/ouuan), [partychicken](https://github.com/partychicken), [Persdre](https://github.com/Persdre), [r-value](https://github.com/r-value), [Rickyxrc](https://github.com/Rickyxrc), [Ruakker](https://github.com/Ruakker), [shawlleyw](https://github.com/shawlleyw), [StudyingFather](https://github.com/StudyingFather), [sun2snow](https://github.com/sun2snow), [szdytom](https://github.com/szdytom), [Unnamed2964](https://github.com/Unnamed2964), [XuYueming520](https://github.com/XuYueming520), [zica87](https://github.com/zica87), [ZnPdCo](https://github.com/ZnPdCo), [ZXyaang](https://github.com/ZXyaang), [qq2964](https://github.com/qq2964), [rickyxrc](https://github.com/rickyxrc)
>  __本页面的全部内容在**[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/deed.zh) 和 [SATA](https://github.com/zTrix/sata-license)** 协议之条款下提供,附加条款亦可能应用