涛涛实习记录——黑名单词匹配(敏感词过滤)优化

234 阅读9分钟

1. 背景描述:

黑名单词库原来存储在服务器内存中,需要将词库迁移到 redis 中。然后希望优化词库的匹配算法。

2. 原来的词库存储格式:

{
    "张": {
        "张三丰": [
            {
                "compoundWord": "",//复合词字段
                "level": 1,
                "name": "pm",
                "originCompoundWordStr": "",//原始复合词字符串
                "typeWa": 0
            }
        ]
    },
    "李": {
        "李四喜": [
            {
                "compoundWord": boolean,
                "level": 3,
                "name": "pm",
                "originCompoundWordStr": "",
                "typeWa": 0
            }
        ]
    }
}

这是一个二级索引字典的 存储方式,在匹配黑名单词的时候, 遍历文本,找到一个字后,命中一级索引,然后遍历去匹配一级索引下的所有敏感词。所以是 O(n*m)

3. 业界常用敏感词过滤算法

敏感词过滤用的使用比较多的 Trie 树算法DFA 算法

3.1. Trie 树算法

Trie 树的核心原理其实很简单,就是通过公共前缀来提高字符串匹配效率。

前缀树又叫做单词查找树,树形结构。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。

如果敏感词的长度为 m,则每个敏感词的平均查找时间复杂度是 o(m),字符串的长度为n,我们需要遍历 n 遍,所以敏感词查找这个过程的时间复杂度是 0(n * m)。

Trie 树是一种利用空间换时间的数据结构,占用的内存会比较大。也正是因为这个原因,实际工程项目中都是使用的改进版 Trie 树例如双数组 Trie 树

3.2. AC 自动机

Aho-Corasick(AC)自动机是一种建立在 Trie 树上的一种改进算法,是一种多模式匹配(就把他理解为多 字符串匹配)算法。

AC自动机算法使用 Trie 树来存放模式串的前缀,通过失败匹配指针(失配指针)来处理匹配失败的跳转。

用大白话来说,AC自动机就是一个超级找词机器。想象一下你手上有一本厚厚的书和一张包含多个词语的列表,你的任务是在书中找出所有列表上的词语出现的位置。如果一个一个词去查,那工作量就很大了但是,如果你有一个聪明的工具——比如AC自动机,它就会帮你大大提升效率。这个工具首先会建立一个特殊的树结构(就像一本字典,但每个词的字母都会指向下一个可能的字母),这就是所谓的“前缀树”或“Trie ”。

然后,为了处理查找失败的情况(比如在书中查找到一半发现不匹配某个词了),它会在每棵树节点上额外添加一些指向其他节点的指针,这些就是“失配指针”或者叫“fail指针”

一边快速地遍历这个特殊的树结构,

所以,在实际应用中,AC自动机能够一边读取书的内容(文本流),一旦发现任何一个词出现在文本中,就能立即报告这个词出现的位置。这样一来,不管你要查找多少个词,都能一次性高效完成,而不是对每一个词单独进行搜索。

Aho-Corasick算法在寻找多个单词时表现出色,本质就是使用所有关键词构建一个字典树(Trie)结构。

Aho-Corasick算法的核心组件包括:goto(转跳),fail(失败转移),output(输出)

遇到的每一个字符都会被提交给 goto结构中的状态对象。如果有匹配的状态,则将其提升为新的当前状态。然而,如果没有找到匹配状态,算法会触发fail并回溯至深度更浅(即匹配长度更短)的状态,并从那里继续搜索,直到找到一个匹配状态,或者已经到达根状态为止。

每当达到与整个关键词相匹配的状态时,该状态会被发送到输出集合中,扫描完成后即可读取这些匹配项。算法的时间复杂度为0(n)。

下面是一些直观的图示:

AC 算法使用场景:

在文本中查找并链接或突出显示关键词:

为纯文本添加语义;

检查文本是否出现了语法错误,通过对比词典完成此操作。

个人感觉AC 自动机算法核心就是使用 Trie 树来存放模式串的前缀,通过失败匹配指针(失配指针)来处理匹配失败的跳转。

3.3. DFA 算法

DFA全称为:Deterministic Finite Automaton,即确定有穷自动机。

其特征为:

有一个有限状态集合和一些从一个状态通向另一个状态的边,每条边上标记有一个符号,其中一个状态是初态,某些状态是终态。 对于每个状态和输入符号,有确定的下一个状态。

敏感词过滤很适合用DFA算法,用户每次输入都是状态的切换,如果出现敏感词,即是终态,就可以结束判断。我们把数组形式的敏感词整理为一个树状结构,准确的说是一个森林。

这样,就把查找敏感词就变成了一个查找路径的问题,如果用户输入的内容中包含一个从根节点到叶子节点的完整路径,就说明包含敏感词。

基本原理是构建一种树形的数据结构,以敏感词首字符为根节点,以与首字符相连的下一个字符为下一级节点,直到最后一个字符为叶子节点,叶子节点上同时标记一个结束状态,这样具有相同首字符的敏感词会存在于同一个树上。

(这里其实就很像前缀树对吧,hhhhh)

3.4. 总结该如何选择

结论:

AC 自动机广泛用于字符串匹配的情况下。

但是为什么呢?其实我自己在搜集资料的时候有点晕,在这里总结一下:

DFA是识别正则语言的自动机模型, 对于每个状态和输入符号,有确定的下一个状态。

字典树是用于字符串存储和快速检索的树形数据结构

AC自动机是在字典树基础上加上失败指针构成的自动机,用于高效的多模式字符串匹配。

其实 AC自动机是就是 DFA的一个特殊形式,专用于高效的多模式串匹配。它利用了DFA的确定性,但通过失败指针等机制,克服了传统DFA在处理大量模式串时的效率问题。

4. 具体实现

(这里只是基中于我的情景关于敏感词过滤算法这一部分的实现)

4.1. 构建 AC 状态机:

从词库中读取所有敏感词,构状态转移表。

这个过程可以被看作是构建了多个树过程,其中每个节点(状态)通过指针(状态转移关系)相互连接,

具体的实现是用嵌套的 map

外层 Map 的键是状态 ID,值是另一个 Map。

内层 Map 的键是输入字符,值是转移到的下一个状态的 ID。

我们在每个状态中添加一个特殊的键来表示终结状态,其值包含对应的黑名单词信息

具体就是 map 表示一组字符转移关系,Map<Integer, Map<Character, Integer>> 就是一个节点,然后用嵌套的 map表示转移关系 来将节点连接成树。同时添加属性表示节点终结状态。

4.2. 存储状态转移表

将 DFA 的状态转移表存储在 Redis 中,用哈希来存储每个状态及其对应的转移关系,

key 就是状态数字,field 是下一个匹配的字,value 是下一个状态的数字。

还有一个是表示结束的 field,他存在就表示黑名单词匹配结束,并且 value 是黑名单词的具体信息。

一个文本遍历的时候,首先会找表示结束状态的 filed,如果有就表示匹配到一个黑名单词,记录匹配结果,然后接着匹配到下一个字。如果没有匹配到下一个字就会回退到初始状态开始重新开始匹配。

然后在查询的时候使用 lua 脚本来进行搜索的 ,避免频繁的 io 交互

4.3. 与 AC 的区别

这里并没有直接使用回退指针指向 上一个回退状态,而是选择直接回退到初始状态。

首先第一点回退指针会占用较多内存,

第二点回退指针回退到上一级是在处理什么问题呢?

拿下面的举例子,比如过滤“卧槽蛋”,使用回退指针就可以过滤出卧槽,槽蛋,但是这种情况很少呀。

这里如下的表示其实没有办法过滤出卧槽,槽蛋的,但是问题不大,整个黑名单词库的过滤是有海量的匹配规则和词库去做的,并不是由匹配算法解决全部问题的。

4.3.1. redis 中状态转移的存储(这里可能例子有点脏了,毕竟黑名单词词嘛,hhhhhh)

"他爷爷的""他妈的""他妈的腿"
"卧槽"
"槽蛋"

// 初始状态 0
HSET DFA:0 '他' 1
           '卧' 4
           '槽' 6

// 状态 1 (处理"他")
HSET DFA:1 '爷' 2
           '妈' 3

// 状态 2 (处理"他爷")
HSET DFA:2 '爷' 8

// 状态 3 (处理"他妈")
HSET DFA:3 '的' 5

// 状态 4 (处理"卧")
HSET DFA:4 '槽' 9

// 状态 5 (处理"他妈的")
HSET DFA:5 'END' "他妈的:info"
           '腿' 10

// 状态 6 (处理"槽")
HSET DFA:6 '蛋' 11

// 状态 8 (处理"他爷爷")
HSET DFA:8 '的' 12

// 状态 9 (处理"卧槽")
HSET DFA:9 'END' "卧槽:info"
           '蛋' 11

// 状态 10 (处理"他妈的腿")
HSET DFA:10 'END' "WHITELIST:他妈的腿:info"

// 状态 11 (处理"槽蛋")
HSET DFA:11 'END' "槽蛋:info"

// 状态 12 (处理"他爷爷的")
HSET DFA:12 'END' "他爷爷的:info"

5. 修改后的优点与缺点

5.1.1. 优点:

  1. 提高匹配效率:
  • 原方案: 使用二级索引,匹配需要遍历一级索引下的所有敏感词,时间复杂度为 O(n * m1) ,其中 n 是文本长度,m1 是一级索引下敏感词的数量。
  • 优化方案: 使用自动机结构,通过状态转移进行匹配,避免了遍历,提高了匹配速度,整体时间复杂度为 O(n)

5.1.2. 缺点:

  1. 实现复杂度增加
  2. 更新复杂度增加

6. 参考:

www.bilibili.com/video/BV1Ag…

www.bilibili.com/video/BV1nJ…