暴力匹配
遍历文本的每个位置,尝试用每个敏感词进行匹配。
- 时间复杂度:假设敏感词库有
n个词,平均长度为m,待匹配文本长度为L,时间复杂度为 。 - 缺点分析:
- 重复扫描:每个敏感词都要遍历整段文本,大量字符被重复比较。
- 无状态复用:敏感词之间没有关联,无法利用已匹配的信息。
- 扩展性差:词库增长时性能线性下降。
Trie 字典树
Trie 树(发音为 /ˈtraɪ/)也称为字典树、前缀树,通过空间换时间的策略优化暴力匹配。
- 核心思想是:利用字符串的公共前缀来减少存储空间和查询时间的开销。
- 使用场景:浏览器搜索框的关键词提示
- 基本特性:
- 根节点不包含字符,除根节点外每一个节点只包含一个字符。
- 从根节点到某一节点,路径上经过的字符连接起来,就是该节点对应的字符串。
- 每个节点的所有子节点包含的字符都不相同。
- 时间复杂度:假设敏感词库有
n个词,平均长度为m,待匹配文本长度为L,时间复杂度为 。 - 核心优势:所有敏感词共享同一棵树,一次遍历就能尝试匹配所有词。适用于较小的字符集
- 缺点分析:存在回溯问题
AC自动机
其核心思想与 KMP 算法一脉相承:利用已匹配的信息,在失配时跳转到合适位置继续匹配,避免回溯。区别在于 KMP 处理单模式串,而 AC 自动机处理多模式串。
核心组件
| 函数 | 作用 |
|---|---|
| goto 函数 | 状态转移:从当前状态读入字符后跳转到哪个状态 |
| failure 指针 | 失配跳转:失配时跳转到「最长相同后缀」状态,避免回溯 |
| output 函数 | 输出匹配:记录每个状态对应的匹配词集合 |
失配指针
当模式匹配失败时,快速跳转到最长的后缀匹配节点,避免重复匹配,是 AC 自动机实现多模式匹配的关键。
如果一个点的Fail指针指向,那么到的字符串是root到的字符串的一个后缀。的深度更大。
例如:3->5, 4->7, 7->9.
计算规则:广度优先搜索逐层遍历,对于当前节点 temp:
- 找到
temp父节点的 fail 节点 - 在该 fail 节点的子节点中寻找与
temp字符相同的节点 - 若存在,则
temp.fail指向该子节点 - 若不存在,继续找 fail 节点的 fail 节点,直到找到或到达 root
class ACNode:
# ......
def build_fail(self):
"""【核心】构建失配指针(BFS实现)"""
queue = deque()
# 1. 初始化根节点的子节点:失配指针指向根,并入队
self.root.fail = None
for child in self.root.children.values():
child.fail = self.root
queue.append(child)
# 2. BFS遍历所有节点,计算失配指针
while queue:
current_node = queue.popleft() # 取出当前节点
# 遍历当前节点的所有子节点
for char, child_node in current_node.children.items():
# 3. 查找子节点的失配指针
fail_node = current_node.fail # 从父节点的失配指针开始回溯
# 循环回溯:直到找到存在当前字符的节点,或到达根节点
while fail_node is not None and char not in fail_node.children:
fail_node = fail_node.fail
# 4. 设置当前子节点的失配指针
if fail_node is None:
# 回溯到根节点仍未找到,指向根节点
child_node.fail = self.root
else:
# 找到匹配节点,指向对应子节点
child_node.fail = fail_node.children[char]
# 5. 将当前子节点入队,继续处理它的子节点
queue.append(child_node)
性能对比
| 算法 | 预处理 | 匹配时间 | 特点 |
|---|---|---|---|
| 暴力匹配 | 每个词单独扫描 | ||
| Trie 树 | 可能回溯 | ||
| AC 自动机 | 哈希存储 | 单次扫描,z 为所有匹配命中的总次数(含重叠匹配) |
DAT:双数组Trie
DAT 的压缩效率与词库的公共前缀比例强相关。极端情况下(无公共前缀),压缩效果有限。
对抗变形词
| 变形方式 | 示例 | 应对策略 |
|---|---|---|
| 谐音字 | “赌博” → “读博” | 维护谐音词库 |
| 插入符号 | "fuck" → f*u*c*k | 预处理去除特殊字符 |
| 繁简混用 | “台灣” → “台湾” | 统一转换为简体后再匹配 |
| 全角字符 | "abc" → "abc" | 全角转半角 |
高并发优化
生产环境中,敏感词库需要频繁更新,但不能影响正在进行的匹配请求。通过 AtomicReference 实现原子热替换(Atomic Hot-Swap):先在后台构建新 Trie,构建完成后原子替换旧实例,确保读线程不受影响。
public class SensitiveWordFilter {
privatefinal AtomicReference<SimpleTrie> trieRef;
public SensitiveWordFilter(List<String> initialWords) {
this.trieRef = new AtomicReference<>(buildTrie(initialWords));
}
// 匹配时获取当前 Trie
public List<String> match(String text) {
SimpleTrie trie = trieRef.get();
return trie != null ? trie.matchAll(text) : Collections.emptyList();
}
// 更新词库:先构建新 Trie,再原子发布
public void refreshWords(List<String> newWords) {
SimpleTrie newTrie = buildTrie(newWords);
trieRef.set(newTrie); // 原子发布,对读线程立即可见
}
private SimpleTrie buildTrie(List<String> words) {
SimpleTrie trie = new SimpleTrie();
for (String word : words) {
trie.addWord(word);
}
return trie;
}
}
关键点:
- 使用
AtomicReference确保切换操作是原子的。 - 旧 Trie 可能仍有线程在使用,依赖 GC 自动回收。
- 可在后台异步构建新 Trie,不影响服务响应。
并行处理:超长文本分段
对于超长文本(如文章、评论),可以分段后并行处理。
注意:分段时必须加入重叠区域,否则会遗漏跨边界的敏感词。
快速排除:布隆过滤器
使用布隆过滤器(Bloom Filter) 做初筛,可以快速排除不含敏感词的文本。
适用前提:该方案仅在绝大多数文本不含敏感词且布隆过滤器假阳性率极低时有收益。因为 quickCheck 本身的复杂度为 O(L × maxWordLen),与 Trie 匹配同阶,如果文本频繁命中布隆过滤器(假阳性),反而会增加额外开销。
def 初筛(文本):
切出所有可能的子串
for 子串 in 所有子串:
if 布隆过滤器.might_contain(子串):
return True # 可疑,需要精确检查
return False # 100% 安全,直接放行
整体架构
graph TD
%% 定义子图(模块分组)
subgraph 敏感词过滤服务
A1[前置清洗<br/>去符号/转码] --> A2[布隆过滤器<br/>快速排除]
A2 -->|可能包含| A3[AC 自动机<br/>精确匹配]
A2 -->|确定不含| A4[返回空列表]
A3 --> A5[返回匹配结果]
end
subgraph 词库管理
B1[词库存储] --> B2[构建 Trie]
B2 --> B3[原子发布]
end
%% 无锁更新流向(虚线 + 标注)
B3 -.->|无锁更新| A3