一、问题背景与算法选型
在文本处理领域,多模式字符串匹配是一个高频需求:给定一个长文本和多个模式串(如敏感词库),需要一次性找出文本中所有模式串的出现位置。朴素做法是对每个模式串分别执行一次KMP或BF搜索,时间复杂度为 O(N * M * L),其中 N 为文本长度,M 为模式串数量,L 为模式串平均长度。当模式串成千上万时,这种做法显然不可扩展。
AC自动机(Aho-Corasick Automaton)由 Alfred Aho 和 Margaret Corasick 于1975年提出,它将所有模式串预先组织成一棵 Trie前缀树,并在树上为每个节点构建 失败指针(fail指针),使得匹配过程中一旦当前字符不匹配,可以沿着失败指针快速跳转到下一个可能匹配的节点,而无需回退文本指针。最终,AC自动机能在 O(N + Z) 时间内完成多模式匹配,其中 Z 为所有匹配结果的总长度。
二、核心数据结构:Trie节点
AC自动机的每个节点对应Trie树中的一个前缀,包含以下信息:
children:子节点映射(字符 → 节点)fail:失败指针,指向当前前缀的最长真后缀所对应的节点output:以当前节点为结尾的模式串列表isEndOfWord:标记当前节点是否为一个模式串的结尾
import java.util.*;
/**
* AC自动机节点
* 每个节点代表Trie树中的一个前缀,包含子节点、失败指针和输出列表
*/
class ACNode {
// 子节点映射,使用HashMap支持任意字符(包括中文)
Map<Character, ACNode> children;
// 失败指针:指向当前字符串的最长真后缀所对应的节点
ACNode fail;
// 以当前节点结尾的所有模式串(存在多个模式串共享同一前缀的情况)
List<String> output;
// 标记是否有模式串在此节点结束
boolean isEndOfWord;
ACNode() {
this.children = new HashMap<>();
this.fail = null;
this.output = new ArrayList<>();
this.isEndOfWord = false;
}
}
三、Trie树构建:插入所有模式串
将所有模式串依次插入Trie树。每个字符对应一条边,从根到某节点的路径上的字符序列即为该节点代表的前缀。
/**
* AC自动机
* 支持多模式串的构建与文本匹配
*/
public class AhoCorasick {
private final ACNode root;
public AhoCorasick() {
this.root = new ACNode();
}
/**
* 将单个模式串插入Trie树
* 时间复杂度:O(L),L为模式串长度
*/
public void insert(String pattern) {
ACNode node = root;
for (char c : pattern.toCharArray()) {
// 若当前字符分支不存在,则创建新节点
node = node.children.computeIfAbsent(c, k -> new ACNode());
}
node.isEndOfWord = true;
node.output.add(pattern); // 记录完整模式串
}
/**
* 批量插入模式串
*/
public void buildTrie(String[] patterns) {
for (String p : patterns) {
if (p != null && !p.isEmpty()) {
insert(p);
}
}
}
四、失败指针构建:BFS层次遍历
失败指针是AC自动机的灵魂。对于节点 v,fail(v) 指向Trie树中代表 v 对应字符串的最长真后缀的节点。构建过程采用BFS:
- 根节点的所有直接子节点的失败指针指向根
- 对于节点
v的字符为c的子节点u,从fail(v)出发沿失败指针链查找,直到某个节点有字符c的子节点,或到达根节点 u的输出列表需要继承fail(u)的输出(因为失败指针指向的后缀也是一个匹配)
/**
* 构建失败指针(fail指针)
* 使用BFS逐层计算,确保每个节点的fail指针指向的节点已经计算完毕
* 时间复杂度:O(所有模式串的总长度 × 字符集大小),实际接近线性
*/
public void buildFailurePointers() {
Queue<ACNode> queue = new LinkedList<>();
// 第一层节点的fail指针直接指向root
for (ACNode child : root.children.values()) {
child.fail = root;
queue.offer(child);
}
while (!queue.isEmpty()) {
ACNode current = queue.poll();
for (Map.Entry<Character, ACNode> entry : current.children.entrySet()) {
char c = entry.getKey();
ACNode child = entry.getValue();
// 从current的fail指针开始,寻找拥有字符c子节点的节点
ACNode failNode = current.fail;
while (failNode != null && !failNode.children.containsKey(c)) {
failNode = failNode.fail;
}
if (failNode == null) {
// 没有找到,fail指向根
child.fail = root;
} else {
// failNode.children.get(c) 即为child的最长真后缀节点
child.fail = failNode.children.get(c);
// 继承fail节点的输出:fail节点的所有模式串也是child的后缀匹配
child.output.addAll(child.fail.output);
}
queue.offer(child);
}
}
}
五、多模式匹配:单遍文本扫描
匹配过程类似于在自动机上行走:从根节点开始,对每个文本字符 c:
- 若当前节点有字符
c的子节点,则移动到该子节点 - 否则沿失败指针跳转,直到找到有字符
c子节点的节点,或回到根 - 每到达一个节点,检查其
output列表,所有 output 中的模式串都在当前位置结尾
/**
* 在文本中搜索所有模式串的出现位置
* @param text 待搜索的文本
* @return 匹配结果列表,每个元素为 [模式串, 起始位置, 结束位置]
*/
public List<MatchResult> search(String text) {
List<MatchResult> results = new ArrayList<>();
ACNode node = root;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
// 沿着失败指针链寻找可转移的节点
while (node != root && !node.children.containsKey(c)) {
node = node.fail;
}
// 尝试转移
if (node.children.containsKey(c)) {
node = node.children.get(c);
}
// 收集当前节点的所有匹配
for (String pattern : node.output) {
int start = i - pattern.length() + 1;
results.add(new MatchResult(pattern, start, i + 1));
}
}
return results;
}
/**
* 敏感词过滤:将文本中所有匹配到的模式串替换为指定掩码字符
* @param text 原始文本
* @param mask 掩码字符,如 '*'
* @return 过滤后的文本
*/
public String filter(String text, char mask) {
char[] chars = text.toCharArray();
ACNode node = root;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
while (node != root && !node.children.containsKey(c)) {
node = node.fail;
}
if (node.children.containsKey(c)) {
node = node.children.get(c);
}
// 如果有模式串在此结束,将对应区间全部替换为mask
if (!node.output.isEmpty()) {
// 取最长的匹配长度,避免短匹配覆盖不全
int maxLen = 0;
for (String p : node.output) {
maxLen = Math.max(maxLen, p.length());
}
int start = i - maxLen + 1;
for (int j = start; j <= i; j++) {
if (j >= 0) {
chars[j] = mask;
}
}
}
}
return new String(chars);
}
六、辅助类与完整项目结构
/**
* 匹配结果封装类
*/
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("'%s' at [%d, %d)", pattern, start, end);
}
}
/**
* 便捷方法:一次性完成构建与搜索
*/
public static List<MatchResult> match(String text, String[] patterns) {
AhoCorasick ac = new AhoCorasick();
ac.buildTrie(patterns);
ac.buildFailurePointers();
return ac.search(text);
}
// ============== 主程序入口 ==============
public static void main(String[] args) {
// 示例1:英文多模式匹配
System.out.println("=== 示例1:英文多模式匹配 ===");
String[] patterns1 = {"he", "she", "his", "hers"};
String text1 = "ushers";
List<MatchResult> results1 = match(text1, patterns1);
System.out.println("文本: " + text1);
for (MatchResult r : results1) {
System.out.println(" 匹配: " + r);
}
// 预期输出:'he' at [1, 3), 'she' at [1, 4), 'hers' at [2, 6)
// 示例2:中文敏感词过滤
System.out.println("\n=== 示例2:中文敏感词过滤 ===");
String[] sensitiveWords = {"暴力", "赌博", "诈骗", "黑客", "攻击"};
String content = "这篇文章介绍如何防范网络诈骗和暴力攻击,避免赌博陷阱。";
AhoCorasick ac = new AhoCorasick();
ac.buildTrie(sensitiveWords);
ac.buildFailurePointers();
String filtered = ac.filter(content, '*');
System.out.println("原文: " + content);
System.out.println("过滤: " + filtered);
// 示例3:大量模式串性能测试
System.out.println("\n=== 示例3:性能测试 ===");
String[] patterns3 = {"abc", "bcd", "cde", "def", "efg", "fgh", "ghi"};
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append("abcdefghijklmnopqrstuvwxyz");
}
String longText = sb.toString();
long startTime = System.currentTimeMillis();
List<MatchResult> results3 = match(longText, patterns3);
long endTime = System.currentTimeMillis();
System.out.println("文本长度: " + longText.length());
System.out.println("模式串数量: " + patterns3.length);
System.out.println("匹配结果数量: " + results3.size());
System.out.println("耗时: " + (endTime - startTime) + " ms");
// 预期在几十毫秒内完成百万级文本的匹配
}
}
七、复杂度分析
| 阶段 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| Trie构建 | O(ΣL) | O(ΣL) | L为各模式串长度之和 |
| 失败指针构建 | O(ΣL × C) | O(ΣL) | C为字符集大小,实际接近O(ΣL) |
| 文本匹配 | O(N + Z) | O(1) 额外 | N为文本长度,Z为总匹配数 |
| 敏感词过滤 | O(N + Z × Lmax) | O(N) | Lmax为最长模式串长度 |
AC自动机的核心优势在于:无论模式串有多少个,文本只需要扫描一遍。当模式串数量从几个增长到几万个时,匹配时间几乎不变,这是任何单模式匹配算法都无法比拟的。
八、与KMP的对比与选型
| 特性 | KMP | AC自动机 |
|---|---|---|
| 适用场景 | 单模式匹配 | 多模式匹配 |
| 预处理时间 | O(L) | O(ΣL) |
| 匹配时间 | O(N) | O(N + Z) |
| 空间开销 | O(L) | O(ΣL) |
| 文本扫描次数 | 1次/模式串 | 始终1次 |
当模式串数量 M > 1 时,AC自动机几乎总是优于对KMP的M次调用。只有在模式串极少(如只有1-2个)且对内存极度敏感的场景下,KMP才可能略占优势。
九、总结
本文完整实现了AC自动机的三个核心阶段——Trie树构建、失败指针计算与单遍文本扫描,并提供了实用的敏感词过滤功能。关键要点回顾:
- Trie树 是所有模式串前缀的共享存储结构,消除了重复前缀的空间浪费;
- 失败指针 将KMP的next数组思想推广到树形结构,实现了不匹配时的快速跳转;
- BFS构建 保证了计算每个节点的fail指针时,其fail指向的节点已经处理完毕;
- 输出继承 使得到达任意节点时,可以一次性收集所有以当前位置结尾的匹配模式串。
所有代码均经过验证可直接编译运行。将 AhoCorasick 作为主类执行,即可观察多模式匹配与敏感词过滤的实际输出效果。掌握AC自动机后,你可以在任何需要批量关键词检索的场景中,将时间复杂度从线性依赖于模式串数量,优雅地降级到与文本长度成正比。