1. AC自动机算法简介
AC自动机(Aho-Corasick Automaton)是一种高效的多模式字符串匹配算法,由Alfred V. Aho和Margaret J. Corasick于1975年提出。它能在单次扫描文本的同时,检测出所有预定义的模式串(比如敏感词),时间复杂度仅为O(n),与模式串的数量和长度无关。
这个算法特别适合构建敏感词过滤系统,因为它能同时处理成千上万个敏感词,而不会像传统方法那样随着词库增长而性能下降。举个例子,假设我们要过滤"赌博"、"毒品"等敏感词,AC自动机会把这些词构建成一个状态机,检查文本时就像用一把"梳子"快速梳理所有可能性。
2. 核心原理与数据结构
2.1 Trie树基础
AC自动机的核心是基于Trie树(字典树)构建的。Trie树是一种多叉树结构,每个节点代表一个字符,从根节点到某个节点的路径就构成一个字符串前缀。比如敏感词"java"和"python"会共享根节点到"p"节点的路径。
class TrieNode {
Map<Character, TrieNode> children = new HashMap<>();
boolean isEnd; // 标记是否为敏感词结尾
TrieNode fail; // 失败指针
}
2.2 失败指针机制
这是AC自动机的精髓所在。每个节点都有一个fail指针,当匹配失败时,不是从头开始,而是跳转到另一个节点继续匹配。这类似于KMP算法中的next数组,但是扩展到多模式匹配场景。
构建fail指针的过程采用BFS遍历:
- 根节点的fail指向自己
- 第一层节点fail指向根节点
- 其他节点的fail指向父节点fail指针对应字符的子节点
2.3 双数组优化
原始Trie树用Map存储子节点,虽然灵活但性能不够理想。双数组Trie(Double-Array Trie)用两个数组base和check压缩存储结构,可以大幅减少内存占用并提高查询速度。实测表明,这种优化能使性能提升5-9倍。
3. Java实现详解
3.1 引入依赖
推荐使用hankcs开源的AhoCorasickDoubleArrayTrie:
<dependency>
<groupId>com.hankcs</groupId>
<artifactId>aho-corasick-double-array-trie</artifactId>
<version>1.2.3</version>
</dependency>
3.2 构建敏感词库
首先准备敏感词字典,建议使用TreeMap保证有序性:
TreeMap<String, String> sensitiveWords = new TreeMap<>();
sensitiveWords.put("赌博", "赌博");
sensitiveWords.put("毒品", "毒品");
sensitiveWords.put("色情", "色情");
3.3 初始化自动机
AhoCorasickDoubleArrayTrie<String> acdat = new AhoCorasickDoubleArrayTrie<>();
acdat.build(sensitiveWords);
build()方法内部会完成:
- 构建基础Trie树
- 计算fail指针
- 压缩为双数组结构
3.4 文本过滤实战
String text = "这是一个包含赌博和毒品的非法内容";
List<Hit<String>> hits = acdat.parseText(text);
hits.forEach(hit -> System.out.println(
"发现敏感词[" + hit.value + "] 位置:" + hit.begin + "-" + hit.end
));
输出结果:
发现敏感词[赌博] 位置:6-8
发现敏感词[毒品] 位置:9-11
4. 性能优化技巧
4.1 内存优化
对于中文敏感词,可以使用字符编码优化:
// 将String转为char[]减少内存占用
sensitiveWords.put(new String("赌博".getBytes(), StandardCharsets.UTF_8), "赌博");
4.2 并行构建
当词库超过10万时,可以采用分片并行构建:
List<Map<String, String>> partitions = // 分割词库
partitions.parallelStream().forEach(part -> {
AhoCorasickDoubleArrayTrie<String> temp = new AhoCorasickDoubleArrayTrie<>();
temp.build(part);
// 合并结果
});
4.3 热更新策略
实现动态加载新敏感词:
public class DynamicACAutomaton {
private volatile AhoCorasickDoubleArrayTrie<String> current;
public void update(Set<String> newWords) {
TreeMap<String, String> newMap = new TreeMap<>(current.getKeywords());
newWords.forEach(word -> newMap.put(word, word));
AhoCorasickDoubleArrayTrie<String> newTrie = new AhoCorasickDoubleArrayTrie<>();
newTrie.build(newMap);
this.current = newTrie; // 原子切换
}
}
5. Web应用集成实践
5.1 Spring Boot拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private SensitiveFilter sensitiveFilter;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HandlerInterceptorAdapter() {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String content = request.getParameter("content");
if(sensitiveFilter.containsSensitiveWord(content)) {
throw new IllegalRequestException("包含敏感内容");
}
return true;
}
});
}
}
5.2 高性能过滤方案
对于高并发场景,建议:
- 使用读写锁保证线程安全
- 实现多级缓存(本地缓存+Redis)
- 采用异步检测机制
public class ConcurrentSensitiveFilter {
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private AhoCorasickDoubleArrayTrie<String> trie;
public boolean containsSensitiveWord(String text) {
lock.readLock().lock();
try {
return !trie.parseText(text).isEmpty();
} finally {
lock.readLock().unlock();
}
}
public void updateTrie(TreeMap<String, String> newWords) {
lock.writeLock().lock();
try {
AhoCorasickDoubleArrayTrie<String> newTrie = new AhoCorasickDoubleArrayTrie<>();
newTrie.build(newWords);
this.trie = newTrie;
} finally {
lock.writeLock().unlock();
}
}
}
6. 实际应用中的坑与解决方案
6.1 特殊字符处理
遇到用户故意使用变体如"赌⾙"时:
// 统一规范化处理
String normalized = text.replaceAll("[\\uFE00-\\uFE0F]", "")
.replaceAll("[\\u200B-\\u200D]", "");
6.2 性能监控
添加指标统计:
public class MonitorableACAutomaton {
private final AtomicLong counter = new AtomicLong();
private final AhoCorasickDoubleArrayTrie<String> trie;
public List<Hit<String>> parseText(String text) {
long start = System.nanoTime();
List<Hit<String>> result = trie.parseText(text);
long cost = System.nanoTime() - start;
counter.addAndGet(cost);
return result;
}
public double getAverageCost() {
return counter.get() / 1_000_000.0; // 转为毫秒
}
}
6.3 词库维护建议
- 按业务领域分组词库(政治、色情、广告等)
- 建立词权重机制
- 定期审核误杀案例
- 实现AB测试验证新词效果
我在实际项目中遇到过词库过大导致初始化慢的问题,后来通过分级加载(先加载高频词,异步加载全量词库)解决了冷启动延迟。另外建议对匹配结果做上下文分析,避免误伤正常用语。
985



被折叠的 条评论
为什么被折叠?



