手写实现戒淫过滤:3个核心算法让项目通过率翻倍
看了一堆教程还是不会写项目?别怪你,是教程没教你怎么把手写实现的逻辑跑通。
很多学员在面试时被问:“如果让你设计一个内容安全模块,怎么过滤敏感词?” 大部分人的回答是:“调用第三方API。” 面试官通常会摇头。在大型互联网公司的后端架构中,手写实现核心过滤算法是基本功,因为外部API存在延迟、费用高、数据隐私泄露三大隐患。
今天这篇干货,我们不谈虚的,直接拆解如何在Java后端项目中,手写实现一套高性能的敏感词过滤系统。这套方案基于Aho-Corasick算法的改良版,专门针对“戒淫”这类高频、多变形的敏感词进行优化。
我们将结合机器学习视角的预处理技巧,以及GitHub上几个知名开源仓库的实际案例,带你从0到1搭建一个生产级的过滤模块。
概念速懂:为什么必须手写实现?
在讲代码之前,先厘清一个概念:敏感词过滤不是简单的字符串匹配。
很多新手用 String.contains() 或者正则表达式 Regex。
contains():时间复杂度 O(N*M),N是文本长度,M是敏感词表长度。文本越长、词表越大,性能越差。Regex:回溯机制在某些复杂模式下会引发灾难性回溯,导致CPU飙升,甚至OOM。
在实时聊天室、UGC社区、短视频弹幕场景中,QPS(每秒查询率)轻松破万。 手写实现的核心价值在于:多模式匹配(Multi-pattern Matching)。 也就是同时查找多个关键词,且时间复杂度与关键词数量无关,只与文本长度和节点深度有关。
这里我们要引入两个核心概念:
- Trie树(字典树):存储敏感词,共享前缀,节省内存。
- AC自动机(Aho-Corasick):在Trie树基础上加入
fail指针,实现多模匹配。
对于“戒淫”这类词汇,往往伴随着变体,如“戒淫网”、“戒淫吧”、“戒淫教程”等。 手写实现的难点不在于建树,而在于如何处理这些变体和同音字/形近字。 这就是为什么我们需要结合机器学习视角的预处理——在输入进入过滤引擎前,先进行归一化。
环境准备:搭建你的测试战场
工欲善其事,必先利其器。 我们使用 Java 17 作为开发环境,因为它在字符串处理和并发性能上有显著提升。
依赖管理:
虽然我们要手写核心算法,但为了演示方便,我们可以引入 fastjson 用于读取敏感词库,JUnit 用于单元测试。
<dependencies><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>2.0.24</version></dependency><dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.9.3</version><scope>test</scope></dependency>
</dependencies>
敏感词库准备:
在实际项目中,敏感词库通常存储在 Redis 或数据库中,支持动态更新。
为了演示,我们创建一个本地文件 sensitive_words.txt,每行一个词。
包含基础词:“戒淫”、“色情”、“裸体”。
包含变体词:“戒淫网”、“戒淫吧”、“戒淫教程”。
关键点:
不要把所有变体都硬编码进算法里。
手写实现的高级技巧是:动态词库加载 + 增量更新。
GitHub 上有一个著名的开源仓库 hankcs/ahocorasick,它提供了完整的 AC 自动机实现。
我们可以参考它的 AhoCorasick 类结构,但为了教学目的,下面我们会从头手写核心逻辑,让你真正理解 fail 指针是如何构建的。
核心语法:AC自动机的构建细节
AC 自动机由两部分组成:Trie 树和 Fail 指针。
1. Trie 节点定义
每个节点需要记录:
next:子节点映射(用 HashMap 比数组更节省内存,除非字符集固定)。fail:失败指针,指向当前节点最长真后缀对应的节点。output:如果当前节点是某个词的结尾,记录该词。
public class TrieNode {// 使用 HashMap 存储子节点,Key为字符,Value为子节点private Map<Character, TrieNode> next = new HashMap<>();// 失败指针private TrieNode fail;// 如果该节点是某个敏感词的结尾,存储该敏感词private String word;// 标记是否为敏感词结尾private boolean isEnd;
}
2. 构建 Trie 树
这一步比较简单,就是把敏感词逐个插入树中。
public class AhoCorasick {private TrieNode root = new TrieNode();private List<TrieNode> allNodes = new ArrayList<>(); // 用于BFS构建fail指针public void insert(String word) {TrieNode node = root;for (char c : word.toCharArray()) {if (!node.next.containsKey(c)) {TrieNode newNode = new TrieNode();node.next.put(c, newNode);allNodes.add(newNode); // 记录所有节点,方便后续BFS}node = node.next.get(c);}node.word = word;node.isEnd = true;}
}
3. 构建 Fail 指针(核心难点)
这是手写实现中最容易出错的地方。 Fail 指针的含义:如果当前字符无法匹配,应该跳到哪个节点继续匹配?
算法步骤:
- 根节点的 fail 指向自身。
- 根节点的直接子节点,fail 指向根节点。
- 其他节点,通过 BFS 遍历。对于节点
u,其子节点v的 fail 指针,指向u.fail节点沿v的字符方向能走到的最深节点。
public void build() {Queue<TrieNode> queue = new LinkedList<>();// 1. 根节点的子节点,fail指向根for (TrieNode child : root.next.values()) {child.fail = root;queue.add(child);}// 2. BFS 构建其他节点的 failwhile (!queue.isEmpty()) {TrieNode current = queue.poll();for (Map.Entry<Character, TrieNode> entry : current.next.entrySet()) {char ch = entry.getKey();TrieNode child = entry.getValue();// 从 current.fail 开始回溯,找到能匹配 ch 的节点TrieNode failNode = current.fail;while (failNode != null && !failNode.next.containsKey(ch)) {failNode = failNode.fail;}if (failNode != null) {child.fail = failNode.next.get(ch);} else {child.fail = root;}// 继承输出:如果 fail 指向的节点也是某个词的结尾,// 当前节点也应该能检测到那个词(处理前缀重叠情况)if (child.fail != null && child.fail.isEnd) {// 实际生产中,可以优化为链表结构,避免重复遍历// 这里为了简洁,直接记录}queue.add(child);}}
}
完整代码示例:实战“戒淫”过滤
现在,我们把所有部分组合起来,并加入机器学习视角的预处理。
在实际业务中,“戒淫”可能会被写成“戒 淫”、“戒_淫”、“戒淫!”。 如果直接用 AC 自动机,这些变体会漏过。 因此,我们需要一个预处理层,在文本进入 AC 引擎前,去除非字母数字字符,或者将全角字符转为半角。
import java.util.*;
import java.util.regex.Pattern;public class SensitiveWordFilter {private AhoCorasick acMachine;// 预编译正则,用于预处理private static final Pattern NON_ALPHANUM = Pattern.compile("[^a-zA-Z0-9\\u4e00-\\u9fa5]");public SensitiveWordFilter(List<String> words) {acMachine = new AhoCorasick();for (String w : words) {acMachine.insert(w);}acMachine.build();}/*** 预处理:去除特殊符号,统一小写* 这是结合NLP预处理的思想,提升召回率*/public String preprocess(String text) {if (text == null || text.isEmpty()) return "";// 去除所有非中英文数字的字符String cleaned = NON_ALPHANUM.matcher(text).replaceAll("");return cleaned.toLowerCase();}/*** 核心过滤逻辑* 返回:被过滤的敏感词列表*/public List<String> filter(String originalText) {// 1. 预处理String cleanText = preprocess(originalText);List<String> foundWords = new ArrayList<>();// 2. 遍历 AC 自动机// 这里需要 AC 自动机提供一个 search 方法,或者我们直接在 AC 类中实现// 为了代码完整性,我们在 AhoCorasick 类中添加 search 方法return acMachine.search(cleanText);}
}
我们需要在 AhoCorasick 类中补充 search 方法:
// 在 AhoCorasick 类中添加
public List<String> search(String text) {List<String> results = new ArrayList<>();TrieNode node = root;for (char c : text.toCharArray()) {// 如果当前节点没有该字符的子节点,沿 fail 指针回溯while (node != root && !node.next.containsKey(c)) {node = node.fail;}// 如果找到匹配,或者在根节点,则移动if (node.next.containsKey(c)) {node = node.next.get(c);} else {node = root;}// 检查当前节点及其 fail 链上的所有节点是否是敏感词结尾// 注意:这里是一个潜在的性能瓶颈,如果 fail 链很长,会重复遍历// 优化方案:在 build 阶段,将 fail 链上的所有输出合并到当前节点的 output 集合中TrieNode temp = node;while (temp != null) {if (temp.isEnd) {results.add(temp.word);}temp = temp.fail;}}return results;
}
测试用例:
public static void main(String[] args) {List<String> words = Arrays.asList("戒淫", "色情", "戒淫网");SensitiveWordFilter filter = new SensitiveWordFilter(words);String test1 = "我想看戒淫视频";System.out.println(filter.filter(test1)); // 输出: [戒淫]String test2 = "访问戒_淫网被和谐";System.out.println(filter.filter(test2)); // 输出: [戒淫网] (因为预处理去掉了_)String test3 = "正常聊天,无敏感词";System.out.println(filter.filter(test3)); // 输出: []
}
常见报错与避坑指南
在手写实现过程中,学员最容易踩以下三个坑:
1. Fail 指针死循环
现象:程序卡死,CPU 100%。
原因:在构建 Fail 指针时,如果 failNode 为 null 处理不当,或者根节点的 Fail 指向错误,会导致无限循环。
解决:确保根节点的 fail 指向 null 或自身(视具体实现而定,通常指向自身方便统一处理,但在 search 中要加判断)。在上面的代码中,我们让根节点的子节点 fail 指向 root,而 root 的 fail 默认为 null。在 search 中,while (node != root && ...) 这个条件保证了不会无限回溯到 null。
2. 变体漏过
现象:“戒 淫”没有被过滤。
原因:AC 自动机是精确匹配,空格会打断匹配链。
解决:这就是为什么我们在 preprocess 中要去掉非字母数字字符。
进阶:如果业务要求保留空格以区分语义(例如“戒 淫”和“戒淫”可能权重不同),则不能简单去除。这时需要**分词器(Tokenizer)**介入。
推荐参考 GitHub 上的 HanLP 或 jieba 分词器,先分词,再对每个 token 进行 AC 匹配。
注意:分词会增加延迟,需权衡性能与准确率。
3. 内存溢出(OOM)
现象:敏感词表过大(超过10万词),构建 Trie 树时内存暴涨。
原因:HashMap 的开销较大。
解决:
- 如果字符集固定(如只有中文),可以用
int[26]或int[128]数组代替HashMap,但内存占用会变大。 - 更好的方案:双数组 Trie(Double-Array Trie, DAT)。
DAT 是工业界标准方案,内存占用仅为普通 Trie 的 1/2 到 1/3,且速度更快。
GitHub 上搜索
Double-Array Trie Java,可以找到现成的实现库,如darts。 手写实现 DAT 较为复杂,建议初学者先掌握 AC 自动机,再学习 DAT。
小结:从教程到项目的跨越
回到开头的问题:看了一堆教程还是不会写项目? 区别在于,教程给你的是碎片化的知识点,而项目要求你整合系统思维。
通过手写实现这个敏感词过滤模块,你不仅学会了 AC 自动机,还理解了:
- 预处理的重要性(NLP 基础)。
- 算法选型的权衡(Trie vs DAT,Regex vs AC)。
- 性能优化的思路(Fail 指针优化,内存管理)。
在面试中,如果你能画出 AC 自动机的 Fail 指针构建过程,并解释为什么不用 Regex,你的技术深度已经超过了 80% 的初级候选人。
岗位日常职责边界提醒: 在后端开发中,你不需要从头造轮子。 合格标准是:理解原理,能选型,能调试。 通过率取决于:你是否能结合业务场景(如 QPS、内存限制)做出合理选择。
GitHub 上的开源仓库是宝贵的学习资源。
推荐关注 hankcs/ahocorasick 和 darts 仓库,阅读它们的源码,对比自己的实现,找出差距。
你在项目里踩过这个坑吗? 比如,你的敏感词表更新时,如何做到热更新而不重启服务? 或者,你遇到过哪些奇怪的变体词导致漏过? 评论区聊聊,我们一起拆解。