当系统要在一段文本里匹配一个关键词时,直接用 indexOf、KMP 或正则通常就够了。但工程里更常见的问题是:我们有一批词、规则或签名,需要在持续到来的文本流里反复查找。例如敏感词过滤、日志脱敏、告警规则命中、恶意特征预筛、内容审核、搜索高亮、协议载荷检测等。这时如果对每个模式串都单独扫一遍文本,复杂度会接近 模式串数量 × 文本长度,关键词库一大就会非常吃力。
AC 自动机,也就是 Aho-Corasick automaton,解决的正是多模式串匹配问题。它由 Alfred V. Aho 和 Margaret J. Corasick 在 1975 年提出,原始论文题为《Efficient String Matching: An Aid to Bibliographic Search》。它的核心思想是:先把所有模式串构建成一棵 Trie,再为 Trie 上的每个状态补充失败转移,也就是 fail 指针。匹配时文本只从左到右扫描一遍,自动机在 Trie 状态之间跳转,并在合适的位置输出所有命中的模式串。
从工程视角看,AC 自动机可以理解为"把一批固定字符串预编译成一个可复用的匹配器"。预处理成本在构建阶段付出,之后每次扫描文本都能做到接近线性时间。
多模式串匹配为什么不能简单循环
假设有模式串集合:
he, she, his, hers
要在文本:
ushers
中查找所有出现位置。最直观的做法是:对每个模式串分别在文本里查一次。模式串数量少时没问题,但如果词库有几十万条、文本每天有数十亿行日志,这种方式会不断重复读取同一段文本。
KMP 能把单个模式串匹配优化到线性时间,但它主要解决的是"一个模式串对一个文本"的场景。多模式串匹配需要进一步复用模式串之间的公共前缀和公共后缀。AC 自动机就是 Trie 和 KMP 失败回退思想的结合:Trie 复用公共前缀,fail 指针复用匹配失败后的最长可用后缀。
第一步:用 Trie 合并公共前缀
图 1. he、she、his、hers 展示了 Trie 如何把多个模式串压到同一棵前缀树里。
Trie 是一棵前缀树。把 he、she、his、hers 插入 Trie 后,公共前缀会共享节点。例如 he 和 hers 共享 h -> e 这段路径。
在 Trie 中,每个节点都表示一个前缀状态。从根节点走到某个节点,路径上的字符拼起来就是当前已经匹配到的字符串。比如走到 h -> e -> r,就表示当前状态对应前缀 her。
只靠 Trie 还不够。匹配文本时,如果当前已经匹配了 she 的前缀 sh,下一个字符却不是 e,我们不能简单回到根节点,因为当前文本后缀可能仍然是某些模式串的前缀。如何高效回退,就是 fail 指针要解决的问题。
fail 指针的直觉:失败后跳到"最长可继续匹配的后缀"
图 2. 黑色实线是 Trie 边,红色虚线是 fail 指针。它强调的是:匹配失败时不是重扫文本,而是把自动机状态跳到仍可能继续匹配的位置。
对 Trie 上的一个节点 v,假设从根到 v 的路径字符串是 S。fail(v) 指向另一个 Trie 节点,它代表 S 的最长真后缀,并且这个后缀同时也是某个模式串的前缀。
这句话很关键,可以拆开理解:
当前已经匹配到 S。
如果下一个字符无法继续匹配,
那就找 S 的某个后缀,看看它能不能作为新的已匹配前缀继续往下走。
为了不漏匹配,也为了尽量少回退,选择最长的那个后缀。
这和 KMP 的 next 数组很像。KMP 针对一个模式串计算失败后应该跳到哪里;AC 自动机针对一整棵 Trie 计算每个状态失败后应该跳到哪里。
以模式串 he, she, his, hers 为例,节点 she 对应字符串 she。它的后缀包括 he、e。其中 he 是 Trie 中的前缀,而且还是一个完整模式串,所以 fail(she) 会指向 he 对应的节点。这样当文本扫描到 she 时,自动机既能报告 she,也能沿着 fail 关系发现后缀模式 he。
构建 AC 自动机
AC 自动机的构建通常分两步。第一步把所有模式串插入 Trie。第二步用 BFS 从浅到深计算 fail 指针。
根节点的 fail 指向自己,根节点的直接子节点 fail 指向根。对于任意边 parent --c--> child,我们要计算 child.fail。直觉是:如果 parent 失败后能跳到 parent.fail,那么 child 的失败目标应该尝试从 parent.fail 继续走字符 c。如果走不了,就继续沿 fail 链往上找,直到找到一个能走 c 的状态,或者回到根。
伪代码如下:
ini
buildFail():
root.fail = root
root 的所有子节点 fail = root,并入队
while queue 非空:
parent = queue.pop()
for (字符 c, child) in parent.children:
f = parent.fail
while f 不是 root 且 f 没有 c 边:
f = f.fail
if f 有 c 边:
child.fail = f.children[c]
else:
child.fail = root
child.outputs += child.fail.outputs
child 入队
这里的 child.outputs += child.fail.outputs 是一个常见实现技巧。它把 fail 链上能匹配到的模式串合并到当前节点,匹配时就不用每次再沿 fail 链枚举输出节点。代价是输出列表可能被复制,词库很大时要权衡内存;另一种做法是维护 output link,在匹配时按需访问。
匹配过程
图 3. 扫描 ushers 时的状态轨迹:读到 she 时同时输出 she 和后缀模式 he,继续读到 hers 时输出 hers。整个过程中,文本指针始终向前。
构建完成后,匹配文本非常直接。维护当前状态 state,初始为根。对文本的每个字符 c:如果当前状态有 c 转移,就走过去;否则沿 fail 指针回退,直到找到能走 c 的状态或回到根。每处理完一个字符,就输出当前状态对应的所有模式串。
伪代码如下:
ini
search(text):
state = root
for i in [0..text.length):
c = text[i]
while state 不是 root 且 state 没有 c 边:
state = state.fail
if state 有 c 边:
state = state.children[c]
else:
state = root
report state.outputs
关键点是:文本指针永远不回退。失败时只移动自动机状态,不重新扫描文本字符。因此在自动机构建好之后,匹配阶段的复杂度通常写作 O(n + z),其中 n 是文本长度,z 是实际输出的匹配数量。
一个面向工程的 Java 实现
下面的实现使用 Map<Integer, Integer> 存储转移,并按 Unicode code point 扫描文本。很多示例会直接用 Java char,但 char 是 UTF-16 code unit,不等于完整 Unicode 字符。对中文、英文、常见 BMP 字符来说 char 通常能工作;如果你的输入可能包含 emoji 或其他补充平面字符,按 code point 处理更稳妥。
ini
import java.util.*;
public class AhoCorasick {
public static class Match {
public final int start; // code point index, inclusive
public final int end; // code point index, exclusive
public final String pattern;
public Match(int start, int end, String pattern) {
this.start = start;
this.end = end;
this.pattern = pattern;
}
@Override
public String toString() {
return "Match{" + pattern + ", [" + start + ", " + end + ")} ";
}
}
private static class Node {
Map<Integer, Integer> next = new HashMap<>();
int fail = 0;
List<String> outputs = new ArrayList<>();
List<Integer> outputLens = new ArrayList<>();
}
private final List<Node> nodes = new ArrayList<>();
private boolean built = false;
public AhoCorasick() {
nodes.add(new Node()); // root = 0
}
public void addPattern(String pattern) {
if (built) {
throw new IllegalStateException("cannot add pattern after build");
}
int[] cps = pattern.codePoints().toArray();
if (cps.length == 0) {
throw new IllegalArgumentException("empty pattern is not supported");
}
int cur = 0;
for (int cp : cps) {
Integer next = nodes.get(cur).next.get(cp);
if (next == null) {
next = nodes.size();
nodes.get(cur).next.put(cp, next);
nodes.add(new Node());
}
cur = next;
}
nodes.get(cur).outputs.add(pattern);
nodes.get(cur).outputLens.add(cps.length);
}
public void build() {
Queue<Integer> queue = new ArrayDeque<>();
for (int child : nodes.get(0).next.values()) {
nodes.get(child).fail = 0;
queue.add(child);
}
while (!queue.isEmpty()) {
int parent = queue.remove();
for (Map.Entry<Integer, Integer> edge : nodes.get(parent).next.entrySet()) {
int cp = edge.getKey();
int child = edge.getValue();
int f = nodes.get(parent).fail;
while (f != 0 && !nodes.get(f).next.containsKey(cp)) {
f = nodes.get(f).fail;
}
Integer failTo = nodes.get(f).next.get(cp);
nodes.get(child).fail = (failTo == null) ? 0 : failTo;
Node childNode = nodes.get(child);
Node failNode = nodes.get(nodes.get(child).fail);
childNode.outputs.addAll(failNode.outputs);
childNode.outputLens.addAll(failNode.outputLens);
queue.add(child);
}
}
built = true;
}
public List<Match> search(String text) {
if (!built) {
throw new IllegalStateException("call build() before search()");
}
List<Match> result = new ArrayList<>();
int state = 0;
int index = 0; // code point index
PrimitiveIterator.OfInt it = text.codePoints().iterator();
while (it.hasNext()) {
int cp = it.nextInt();
while (state != 0 && !nodes.get(state).next.containsKey(cp)) {
state = nodes.get(state).fail;
}
Integer next = nodes.get(state).next.get(cp);
state = (next == null) ? 0 : next;
Node node = nodes.get(state);
for (int i = 0; i < node.outputs.size(); i++) {
int len = node.outputLens.get(i);
result.add(new Match(index - len + 1, index + 1, node.outputs.get(i)));
}
index++;
}
return result;
}
public static void main(String[] args) {
AhoCorasick ac = new AhoCorasick();
for (String p : List.of("he", "she", "his", "hers")) {
ac.addPattern(p);
}
ac.build();
System.out.println(ac.search("ushers"));
// 输出包含 she、he、hers,且只扫描文本一遍
}
}
这份代码优先强调可读性和工程边界,而不是极致性能。如果你的字符集很小,例如只处理小写英文字母,可以把 Map<Integer, Integer> 换成定长数组,查询会更快,也更利于 CPU 缓存。但如果字符集大、节点转移稀疏,数组会浪费大量内存,Map 或压缩转移结构更合适。
复杂度怎么理解
设所有模式串总长度为 m,文本长度为 n,实际匹配输出数量为 z,字符集大小为 k。
构建 Trie 的节点数最多是 m + 1,因为每插入一个字符最多新增一个节点。使用稀疏转移结构时,构建 Trie 和 fail 指针通常可以理解为 O(m) 级别,但具体常数和查找成本取决于转移结构。如果使用 HashMap,平均查找接近 O(1);如果使用有序映射,可能带有 log k 因子。
匹配阶段是 O(n + z)。n 来自对文本的一次扫描,z 来自必须输出的匹配结果。这个 z 不能省略,因为如果文本中确实有大量命中,光是把结果写出来就需要这么多时间。
空间复杂度也取决于转移表示。稀疏 Trie 通常是 O(m);如果为每个状态都建立一个大小为 k 的完整转移表,空间可能达到 O(mk)。完整转移表的好处是匹配时每个字符都能直接查表,坏处是当字符集大或状态多时内存会非常可观。
工程应用中的典型姿势
AC 自动机最适合"很多固定字符串 + 反复扫描文本"的场景。它经常不是完整业务逻辑本身,而是一个高吞吐的候选召回或预过滤模块。
在敏感词过滤中,AC 自动机可以快速找出文本中的候选敏感词,再由业务层决定如何处理重叠、优先级、最长匹配、白名单、词边界和替换策略。这里真正困难的往往不是匹配速度,而是词库治理、误伤控制和规避处理,比如大小写、繁简体、全半角、Unicode 变体、插入空格或符号等。
在日志处理或数据脱敏中,AC 自动机适合匹配大量固定 token、字段名、密钥前缀或业务标识。命中后通常还需要结合上下文做二次校验,因为脱敏规则可能不只是"出现某个词就替换"。
在安全领域,入侵检测和恶意扫描系统常用多模式匹配作为快速预筛。AC 自动机可以在网络载荷或文件内容中快速召回可能命中的签名,再交给更复杂的规则引擎继续判断。Suricata、Snort、ClamAV 等系统的文档都体现了这种"快速字符串匹配 + 后续规则验证"的工程思路。
在生物信息学中,AC 自动机也常用于多 motif、多 k-mer 的序列扫描。这个场景字符集很小,但数据量极大,因此内存布局、缓存友好性和批处理方式往往比算法的大 O 表达式更影响实际性能。
常见坑
第一个坑是漏掉后缀匹配。如果只在当前节点保存"是否为模式串结尾",而匹配时不沿 fail 链查输出,也不在构建时合并 fail 节点的输出,就会漏掉 she 命中时同时命中的 he。
第二个坑是重叠匹配的业务语义不清。AC 自动机会自然报告所有重叠结果。例如词库有 he、hers,文本 hers 会同时命中两个词。业务上到底要全部返回、最长优先、最短优先、左侧优先,还是按词库权重过滤,需要在 AC 自动机之外定义清楚。
第三个坑是 Unicode 与归一化。Java 的 char 不是完整 Unicode 字符;同一个视觉字符也可能有不同编码形式。敏感词、用户名、日志等业务场景里,通常需要在进入 AC 自动机前做统一归一化,例如大小写折叠、Unicode normalization、繁简转换、全半角转换、去除零宽字符等。否则匹配器本身再快,也可能被输入变体绕过。
第四个坑是把 AC 自动机当成正则引擎。AC 自动机擅长固定字符串集合匹配,不擅长表达任意复杂模式。如果规则里有大量通配符、分组、重复次数、前后文约束,就应该把 AC 作为候选召回层,再配合正则或规则引擎做二次验证。
第五个坑是忽视构建成本和热更新。如果词库频繁变化,每次全量重建可能成为瓶颈。常见做法包括版本化构建、后台构建新自动机、原子切换引用、按业务域拆分词库,或者对小规模增量使用辅助结构。标准 AC 自动机本身并不是为高频动态增删设计的。
如何选实现方案
如果你是在算法题或小字符集服务里使用,数组版实现通常简单且快,例如只处理 a-z 时每个节点开 int[26]。如果你处理的是中文、日志、URL、混合 Unicode 文本,Map 或压缩稀疏结构更稳妥。若词库规模非常大,建议进一步关注节点对象开销、输出列表复制成本、缓存局部性,以及是否可以把字符先映射到更小的字母表。
生产系统里还要明确匹配结果的坐标单位。上面的 Java 示例返回的是 code point 下标,不是 UTF-16 char 下标,也不是字节偏移。如果结果要回写到原始字符串、日志字节流或前端高亮,需要统一偏移定义,否则很容易出现截断错误。
小结
AC 自动机的本质是:用 Trie 表达一组模式串的公共前缀,用 fail 指针表达匹配失败时仍可复用的最长后缀,用输出集合报告当前位置能命中的所有模式串。构建完成后,它可以对文本进行一次线性扫描,并输出所有匹配结果。
对工程开发者来说,掌握 AC 自动机不只是理解算法流程,还要理解它的边界:它适合大量固定字符串的高吞吐匹配;它需要清晰的重叠处理策略;它依赖输入归一化;它的性能高度受转移结构和内存布局影响。用得好时,它是文本处理链路里非常可靠的底层加速器。
参考资料
DBLP: Aho and Corasick 1975 paper record
NIST Dictionary of Algorithms and Data Structures: Aho-Corasick
Stanford CS166: Aho-Corasick Automata lecture slides
cp-algorithms: Aho-Corasick algorithm
GeeksforGeeks: Aho-Corasick Algorithm for Pattern Searching
Unicode FAQ: UTF-8, UTF-16, UTF-32 & BOM
Suricata Performance Tuning Considerations