引言
在文本处理领域,单模式串匹配(如 KMP 算法)只能在一个文本中查找一个关键词。但实际应用中,往往需要同时查找成百上千个关键词——例如敏感词过滤、病毒特征码检测、日志关键词提取等场景。AC 自动机(Aho-Corasick Automaton)由 Alfred Aho 和 Margaret Corasick 于1975年提出,它通过构建 Trie 树并添加失败指针(Failure Link),实现了O(n + m + z) 的多模式串匹配复杂度,其中 n 是文本长度、m 是所有模式串总长度、z 是匹配次数。Linux 的 fgrep 命令、Snort 入侵检测系统、ClamAV 杀毒软件都采用了 AC 自动机。
本文将用 Java 从零实现一个完整的 AC 自动机,深入讲解 Trie 树构建、失败指针计算、输出链接优化与实际应用。
核心思想
AC 自动机是 Trie 树与 KMP 失败函数思想的结合:
- Trie 树阶段:将所有模式串插入 Trie 树,共享公共前缀
- 失败指针阶段:为每个节点计算失败指针,指向当前字符串的最长真后缀所对应的 Trie 节点(该后缀也是某个模式串的前缀)
- 匹配阶段:在文本上自动机状态转移,每步 O(1),遇到匹配则输出
失败指针的直观理解
假设模式串为 {“he”, “she”, “his”, “hers”}。当在文本 “ushers” 中匹配到 “she” 后,如果下一个字符不是 ‘r’,不必回到根节点重新开始,而是跳到 “he” 节点——因为 “she” 的最长真后缀 “he” 恰好是另一个模式串的前缀。
Java 实现
AC 自动机节点
import java.util.*;
public class AhoCorasickNode {
// 子节点映射:字符 -> 子节点
Map<Character, AhoCorasickNode> children;
// 失败指针
AhoCorasickNode fail;
// 输出集合:以当前节点结尾的所有模式串(考虑后缀匹配)
List<String> outputs;
// 是否为一个模式串的结尾
boolean isEnd;
// 对应的模式串(仅 isEnd 为 true 时有效)
String pattern;
public AhoCorasickNode() {
this.children = new HashMap<>();
this.fail = null;
this.outputs = new ArrayList<>();
this.isEnd = false;
this.pattern = null;
}
}
AC 自动机核心类
import java.util.*;
public class AhoCorasick {
private final AhoCorasickNode root;
private boolean built; // 是否已构建失败指针
public AhoCorasick() {
this.root = new AhoCorasickNode();
this.built = false;
}
/**
* 添加模式串到 Trie 树
* 时间复杂度:O(L),L 为模式串长度
*/
public void addPattern(String pattern) {
if (pattern == null || pattern.isEmpty()) {
return;
}
AhoCorasickNode node = root;
for (char c : pattern.toCharArray()) {
node = node.children.computeIfAbsent(c, k -> new AhoCorasickNode());
}
node.isEnd = true;
node.pattern = pattern;
}
/**
* 构建失败指针
* 使用 BFS 按层计算,确保处理某节点时其父节点的失败指针已计算
* 时间复杂度:O(m),m 为所有节点数
*/
public void build() {
if (built) return;
Queue<AhoCorasickNode> queue = new LinkedList<>();
// 第一层节点的失败指针指向根节点
for (AhoCorasickNode child : root.children.values()) {
child.fail = root;
queue.offer(child);
}
// BFS 逐层构建失败指针
while (!queue.isEmpty()) {
AhoCorasickNode current = queue.poll();
for (Map.Entry<Character, AhoCorasickNode> entry : current.children.entrySet()) {
char c = entry.getKey();
AhoCorasickNode child = entry.getValue();
// 计算 child 的失败指针
AhoCorasickNode failNode = current.fail;
// 沿着失败指针链查找具有相同字符子节点的节点
while (failNode != null && !failNode.children.containsKey(c)) {
failNode = failNode.fail;
}
if (failNode == null) {
child.fail = root;
} else {
child.fail = failNode.children.get(c);
}
// 优化:将失败节点的输出合并到当前节点,避免匹配时回溯
if (child.fail != null && !child.fail.outputs.isEmpty()) {
child.outputs.addAll(child.fail.outputs);
}
queue.offer(child);
}
// 将当前节点自身的模式串加入输出列表
if (current.isEnd && current.pattern != null) {
current.outputs.add(current.pattern);
}
}
// 根节点的输出为空
root.outputs.clear();
built = true;
}
/**
* 在文本中搜索所有模式串匹配
* @param text 待搜索文本
* @return 匹配结果列表,每个结果包含模式串和结束位置
*/
public List<MatchResult> search(String text) {
if (!built) {
build();
}
List<MatchResult> results = new ArrayList<>();
AhoCorasickNode current = root;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
// 状态转移:如果没有对应子节点,沿失败指针跳转
while (current != root && !current.children.containsKey(c)) {
current = current.fail;
}
if (current.children.containsKey(c)) {
current = current.children.get(c);
} else {
current = root;
continue;
}
// 输出当前节点的所有匹配
for (String pattern : current.outputs) {
results.add(new MatchResult(pattern, i - pattern.length() + 1, i));
}
}
return results;
}
/**
* 检查文本中是否包含任意模式串
*/
public boolean containsAny(String text) {
return !search(text).isEmpty();
}
/**
* 将文本中的模式串替换为指定字符串
*/
public String replace(String text, String replacement) {
List<MatchResult> matches = search(text);
if (matches.isEmpty()) return text;
// 按位置排序并处理重叠匹配(优先最长匹配)
matches.sort((a, b) -> {
if (a.start != b.start) return a.start - b.start;
return b.pattern.length() - a.pattern.length();
});
StringBuilder sb = new StringBuilder();
int lastEnd = 0;
Set<Integer> replacedPositions = new HashSet<>();
for (MatchResult match : matches) {
if (match.start < lastEnd) continue; // 跳过重叠匹配
sb.append(text, lastEnd, match.start);
sb.append(replacement);
lastEnd = match.end + 1;
}
sb.append(text.substring(lastEnd));
return sb.toString();
}
public static class MatchResult {
public final String pattern;
public final int start;
public final int end;
public MatchResult(String pattern, int start, int end) {
this.pattern = pattern;
this.start = start;
this.end = end;
}
@Override
public String toString() {
return String.format("Pattern '%s' at [%d, %d]", pattern, start, end);
}
}
}
内存优化版:使用数组代替 HashMap
对于字符集较小(如 ASCII)的场景,可用数组替代 HashMap 以提升性能:
public class CompactAhoCorasickNode {
// 假设使用 ASCII 字符集,也可用 Trie 压缩(双数组 Trie)
CompactAhoCorasickNode[] children = new CompactAhoCorasickNode[128];
CompactAhoCorasickNode fail;
List<String> outputs = new ArrayList<>();
boolean isEnd;
public CompactAhoCorasickNode getChild(char c) {
return children[c];
}
public void setChild(char c, CompactAhoCorasickNode node) {
children[c] = node;
}
}
测试与验证
public class AhoCorasickDemo {
public static void main(String[] args) {
AhoCorasick ac = new AhoCorasick();
// 添加敏感词/模式串
String[] patterns = {"he", "she", "his", "hers", "sher", "her"};
for (String p : patterns) {
ac.addPattern(p);
}
// 构建自动机
ac.build();
// 测试文本
String text = "ushers";
System.out.println("=== AC 自动机多模式匹配测试 ===\n");
System.out.println("模式串: " + String.join(", ", patterns));
System.out.println("文本: \"" + text + "\"\n");
List<AhoCorasick.MatchResult> results = ac.search(text);
System.out.println("匹配结果:");
for (AhoCorasick.MatchResult r : results) {
System.out.println(" " + r);
}
// 敏感词过滤测试
System.out.println("\n=== 敏感词过滤测试 ===");
AhoCorasick filter = new AhoCorasick();
String[] sensitiveWords = {"暴力", "赌博", "色情", "诈骗"};
for (String w : sensitiveWords) {
filter.addPattern(w);
}
filter.build();
String userInput = "这个网站涉及赌博和诈骗内容";
System.out.println("用户输入: " + userInput);
System.out.println("包含敏感词: " + filter.containsAny(userInput));
System.out.println("过滤后: " + filter.replace(userInput, "***"));
// 性能对比测试
System.out.println("\n=== 性能对比:AC 自动机 vs 逐个 KMP ===");
int patternCount = 1000;
int textLength = 100000;
AhoCorasick largeAc = new AhoCorasick();
java.util.Random rand = new java.util.Random(42);
String chars = "abcdefghijklmnopqrstuvwxyz";
// 生成随机模式串
for (int i = 0; i < patternCount; i++) {
StringBuilder sb = new StringBuilder();
for (int j = 0; j < 5; j++) {
sb.append(chars.charAt(rand.nextInt(chars.length())));
}
largeAc.addPattern(sb.toString());
}
largeAc.build();
// 生成随机文本
StringBuilder textSb = new StringBuilder(textLength);
for (int i = 0; i < textLength; i++) {
textSb.append(chars.charAt(rand.nextInt(chars.length())));
}
String largeText = textSb.toString();
long start = System.currentTimeMillis();
largeAc.search(largeText);
long acTime = System.currentTimeMillis() - start;
System.out.println("模式串数量: " + patternCount);
System.out.println("文本长度: " + textLength);
System.out.println("AC 自动机耗时: " + acTime + " ms");
System.out.println("理论逐个 KMP 耗时: ~" + (acTime * patternCount / 10) + " ms (估算)");
}
}
复杂度分析
| 阶段 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 构建 Trie | O(m) | O(m) | m 为所有模式串总长度 |
| 构建失败指针 | O(m |Σ|) | O(m) | |Σ| 为字符集大小 |
| 搜索匹配 | O(n + z) | O(1) | n 为文本长度,z 为匹配数 |
与单模式算法的对比
| 算法 | 预处理方式 | 单次搜索 | k 个模式串搜索 |
|---|---|---|---|
| KMP | O(m) | O(n) | O(k(n+m)) |
| BM | O(m) | O(n/m)~O(nm) | O(k(n+m)) |
| AC 自动机 | O(m|Σ|) | – | O(n + m + z) |
当模式串数量 k > 5 时,AC 自动机通常是最优选择。
实际应用场景
- 敏感词过滤:微博、论坛等平台的实时内容审核系统
- 入侵检测:Snort/Suricata 使用 AC 自动机匹配网络流量中的恶意特征
- 病毒检测:ClamAV 使用 AC 自动机匹配病毒特征码
- 搜索引擎:查询词扩展、拼写纠错中的词典匹配
- 日志分析:从海量日志中实时提取关键词指标
- DNA 序列分析:生物信息学中的多序列匹配
总结
AC 自动机将 Trie 树的前缀共享优势与 KMP 的最长公共前后缀思想完美融合,以线性时间完成了多模式串匹配这一看似需要平方时间的任务。理解 AC 自动机不仅能掌握一种高效的文本匹配工具,更能体会”预处理换查询”和”状态机思维”在算法设计中的强大威力。