【数据结构】- 如何高效地实现敏感词过滤?

什么是敏感词

敏感词一般是指带有敏感政治倾向(或反执政党倾向)、暴力倾向、不健康色彩的词或不文明语,分为色情类、民生类、反动类、政治类、暴恐类、广告类、医疗类、特殊网址等。
主要用在评价、社区、聊天系统中。
一般对于敏感词的处理有两种方式,第一种是直接删除包含敏感词的关键词和内容,第二种是对敏感词进行替换,比如替换为***符号。

如何处理敏感词

目前常见的有3种方案:

  1. 暴力破解;
  2. 构建Trie树;
  3. 双数组Trie树;

方案一: 暴力破解,遍历所有敏感词

这是最简单的过滤方案,遍历所有敏感词,逐个检测输入的文本中是否含有指定的敏感词。代码如下:

@Test
public void sensitiveWordTest() {  
    Set<String> sensitiveWords = new HashSet<>();
    sensitiveWords.add("天🐱");
    sensitiveWords.add("二狗子");
    sensitiveWords.add("特朗普");
    String text = "天🐱店铺地址是二狗子...";
    for(String sensitiveWord : sensitiveWords) {
        if(text.contains(sensitiveWord)) {
            System.out.println("输入的文本存在敏感词:" + sensitiveWord);
        break;
        }
    }
}

这种方案代码简单,也能实现敏感词过滤要求,但是仅仅适用于敏感词非常少的情况,例如只有十几个敏感词的时候,使用这种方案不会存在太大的性能问题。但是敏感词较多的话,时间复杂度是非常高的,CPU消耗很大。所以,一般暴力破解的方式都会被舍弃。


使用DFA算法来匹配敏感词,这也是大多数的敏感词过滤系统采用的方案。 DFA(Deterministic Finite
Automaton)翻译成中文是“确定有穷自动机
”。它的基本思想是基于状态转移来检索敏感词,只需要扫描一次待检测文本,就能对所有敏感词进行检测,所以效率比方案一高不少。

方案二:字典树

Trie树,即字典树,可以理解为前缀树算法,我们可以先把敏感词中有相同前缀的词组合成一个树形结构,不同前缀的词分属不同树形分支。
例如有以下敏感词:军品气枪店、军用手枪、军用枪支、军迷购枪、微型冲锋枪、微声手枪
在这里插入图片描述
把敏感词组成树形结构最大的好处就是可以减少检索次数,我们只需要遍历一次待检测文本,然后在敏感词库中检索出有没有该字符对应的子树就行了,如果没有相应的子树,说明当前检测的字符不在敏感词库中,则直接跳过继续检测下一个字符;如果有相应的子树,则接着检查下一个字符是不是前一个字符对应的子树的子节点,这样迭代下去,就能找出待检测文本中是否包含敏感词了。

比如对于文本“售卖军用枪支是违法行为”,依次检测每个字符,发现前两个字符“售”和“卖”都找不到对应的子树,所以直接跳过,继续检测第三个字符“军”,发现找到了对应的子树,这时候右侧“微”字开头的子树,压根不用再去扫描。接着再检测下个字符“用”字,则排除了“军”下面的“品”和“迷”的子树,在中间的两个子树下查找就够了,然后再根据“枪”字查找,发现只有一个子树了,并且“枪”字下面正好是“支”字,于是成功检索到了敏感词。

以下是本人手撕的一段Trie树的构建过程和匹配过程的算法(好像leetcode也有类似的算法题),非常简短的构建过程,提供出来做参考:

import lombok.Data;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class DFA {
    private final DNode startNode = new DNode('\u0000');

    //public static final int minMatchType = 1;//匹配符合的最短词

    //public static final int maxMatchType = 2;//匹配符合的最短词

    public void buildNodeTree(List<String> sensitiveWords) {
        sensitiveWords.forEach(word -> {
            DNode nextNode = startNode;
            for (int i = 0; i < word.length(); i++) {
                char charAt = word.charAt(i);
                nextNode = nextNode.addNextNode(charAt);
                if (i == word.length() - 1) {
                    nextNode.setEnd(true);
                }
            }
        });
    }

    public String findSensitiveWord(String context) {
        if (context == null)
            return null;
        return startNode.find(context);
    }

    public static void main(String[] args) {
        DFA dfa = new DFA();
        List<String> sensitiveWords = Arrays.asList("江大爷", "sdfs", "火枪", "江江");
        dfa.buildNodeTree(sensitiveWords);
        System.out.println(dfa.findSensitiveWord("我们江大爱学习"));
        System.out.println(dfa.findSensitiveWord("我们江大爷爱学习"));
    }
}

@Data
class DNode {

    private boolean end = false;

    private final Map<Character, DNode> nextNodes = new ConcurrentHashMap<>();

    private char val;

    public DNode(char val) {
        this.val = val;
    }

    public DNode addNextNode(char val) {
        DNode nextNode = nextNodes.getOrDefault(val, new DNode(val));
        nextNodes.putIfAbsent(val, nextNode);
        return nextNode;
    }

    public String find(String context) {
        for (int i = 0; i < context.length(); i++) {
            int len = findNext(context, i);
            if (len > 0) {
                return context.substring(i, i + len);
            }
        }
        return null;
    }

    public int findNext(String context, int index) {
        if (index >= context.length())
            return 0;

        char charAt = context.charAt(index);
        DNode node = nextNodes.get(charAt);
        if (node == null) {
            return 0;
        }
        if (node.isEnd()) {
            return 1;
        }
        int len = node.findNext(context, ++index);
        return len == 0 ? 0 : len + 1;
    }

    @Override
    public String toString() {
        return String.valueOf(val);
    }
}

普通的DFA算法,性能很好,但也有个弊端,就是占用内存过大,如果仅仅只是用作敏感词过滤,还是没啥问题的。但如果使用在分词领域,那内存就会爆炸。我们举个例子,如下几个词语:[美利坚联众国, 联众国, 坚定, 坚定定, 坚定定定定],这个DFA树有会有很多个重复的字,和重复的词,如果我们有办法把这块内存给压缩掉,那么内存会极大的节约。
改进:使用双数组Trie树 -DAT算法,优点:词语越多,内存压缩越高效,在大量词语下,内存使用仅是普通算法的1/10,甚至更低

方案三 - 双数组Trie树

在聊双数组Trie树之前,需要了解双数组Trie树与普通Trie树的区别,有三:

  • 算法实现上不同:普通Trie树,一般使用深度优先遍历算法(也可使用广度优先遍历算法)即可实现需求;而双数组Trie只能使用广度优先遍历算法;
  • 树形结构构造方式不同:普通Trie树,使用Node构建树形接口;而双数组Trie使用数组构建树形结构;
  • 内存结构不同:数组构建树形结构,内存结构紧凑且占用内存小;普通Trie树构建树形结构,内存结构松散且占用内存大;

但其本质都是需要构建树形结构的。
双数组Trie树涉及到两个状态方程:

  1. base[s] + offset + c = t
  2. check[t] = s

描述一下两个公式的准确用途:
第一个函数是说,每个词进来以后,通过公式的计算,看这个值是否存在,比如:【手枪,银行】,这两个词计算出来的值一样,那就表示手枪和银行可能存在;
第二个函数,就是第一个函数计算出来的可能性变成确定性。

假设

字符集编码: 中=1, 人=2, 华=3, 大=4, 学=5, 新=6, 清=7
词典: dict = [“清华”, “中华”, “华人”, “清新”, “清华大学”]
词典最大的长度是4,那我们就要循环遍历4次。

Base数组

再看状态方程:base[s] + offset + c = t,offset初始为0
在该状态方程中, s和t分别是表示两种状态,而c是表示一个字符输入,可以使状态从s变成t,即状态转移. Base[s]表示状态t的转移基数。而c表示输入字符c的编码值。
第一层:
计算步骤如下:

  1. 起始状态0: Base[0] = 1;
  2. 输入字符 ‘清’, 状态转移至: Base[0] + ‘清’.c = 1 + 7 = 8,并把base[0]的值赋值到base[8]
  3. 输入字符 ‘中’,状态转移至:Base[0] + ‘中’.c = 1 + 1 = 2,并把base[0]的值赋值到base[2]
  4. 输入字符 ‘华’,状态转移至:Base[0] + ‘华’.c = 1 + 3 = 4,并把base[0]的值赋值到base[4]

所以我们能看到,在2,4,8上分别有对应的汉字,并且base数组上标记了1

    word           null           null            []           null            []           null           null           null            []           null           null           null           null
Position              0              1              2              3              4              5              6              7              8              9             10             11             12
    base              1              0              1              0              1              0              0              0              1              0              0              0              0
   check             -1             -1              0             -1              0             -1             -1             -1              0             -1             -1             -1             -1

同理第二层:
首先在word那行,2,4,8的位置上的汉字不动,需要增加 “清[华]”, “中[华]”, “华[人]”, “清[新]” ,代表第二层的标记,这里有3个父节点,所以要计算3次。
计算步骤如下:

  1. 起始状态 ‘清’: Base[8] = 1;
    1.1 输入字符 ‘华’, 状态转移至’清华’: Base[8] + ‘华’.c = 1 + 3 + 0 = 4,并把base[8]的值赋值到base[4]
    1.2 输入字符 ‘新’,状态转移至’清新’:Base[8] + ‘新’.c = 1 + 6 + 0 = 7,并把base[8]的值赋值到base[7]
  2. 起始状态 ‘中’: Base[2] = 1;
    2.1 输入字符 ‘华’, 状态转移至’中华’: Base[2] + ‘华’.c = 1 + 3 + 0 = 4,并把base[2]的值赋值到base[4],但我们发现base[4]已经被占用了,所以就需要将offset + 1,base[5]没有被占用,那我们把base[2]的值 + 1在赋给base[5]
  3. 其他的同理
    word           null           null            [][]            [][][]           null            [][]           null           null           null
Position              0              1              2              3              4              5              6              7              8              9             10             11             12
    base              1              0              2              1              1              2              3              0              3              3              0              0              0
   check             -1             -1              0              4              0              2              8             -1              0              8             -1             -1             -1

第三和四层:同理第二层,最终得到结果如下

    word           null           null            [][]            [][][]        清华[]            [][]      清华大[]           null           null
Position              0              1              2              3              4              5              6              7              8              9             10             11             12
    base              1              0              2              1              1              2              3              5              3              3              5              0              0
   check             -1             -1              0              4              0              2              8              6              0              8              7             -1             -1

这样,我们就构建了一个从上到下的树结构。
我们发现,在双数组Trie树中的Base数组以及转移基数是非常重要的。使用双数组Trie树来压缩经典Trie树的内存空间的关键在于转移基数的计算,计算转移基数的基本思路是:在Base数组中, 从某一地址开始,向下找,直到找到一个地址,使得其子结点中所有需要的位置[可达状态]未被占用。不被需要的位置是不存在的状态, 或称不可达状态,可以被其他可达状态占用。

Check数组

当我们对词"清中"进行检测时,其过程如下(请对照着生成好的结构):

  1. 起始状态 0: Base[0] = 1;
  2. 输入字符 ‘清’, 状态转移至’清’: Base[0] + ‘清’.code = 1 + 7 = 8
  3. 输入字符 ‘中’,状态转移至’清中’:Base[‘清’] + ‘中’.code = 3 + 1 = 4.
  4. Base[‘清中’] != 0, 所以"清中"存在.

以上结论显然是错误的,在我们的假设词典中,并不存在"清中",所以其状态是不可达的。那怎么来区分它们呢?

引入Check数组

我们知道状态"清中",是在状态"清"下输入"中"转移过来的。而状态"华"是在状态"0"下输入"华"转移过来的。这两个状态的上一个状态并不一样,所以我们可以维护另外一个与Base数组长度相同的数组,即Check数组。
在Check数组中,我们在每个状态对应位置保存该状态上一状态。我们在判断一个词是否存在,不只判断Base位置是否为空,还要判断Check数组中对应位置上的值是否指向该状态的上一状态。有的博客中在Check数组中保存的是上一状态的转移基数, 而本文保存上一状态的位置。
所以,计算步骤如下:
第一层的计算步骤就会变成:
计算步骤如下:

  1. 起始状态0: Base[0] = 1;
  2. 输入字符 ‘清’, 状态转移至: Base[0] + ‘清’.c = 1 + 7 = 8,并把base[0]的值赋值到base[8],check[8]=[0]
  3. 输入字符 ‘中’,状态转移至:Base[0] + ‘中’.c = 1 + 1 = 2,并把base[0]的值赋值到base[2],check[2]=[0]
  4. 输入字符 ‘华’,状态转移至:Base[0] + ‘华’.c = 1 + 3 = 4,并把base[0]的值赋值到base[4],check[4]=[0]

其余层,同上。

重新对词"清中"进行检测:

  1. 起始状态 0: Base[0] = 1;
  2. 输入字符 ‘清’, 状态转移至’清’: Base[0] + ‘清’.code = 1 + 7 = 8.
  3. 输入字符 ‘中’,状态转移至’清中’:Base[‘清’] + ‘中’.code = 3 + 1 = 4.
  4. Base[‘清中’]不为空, 可能是一个词, 需要与Check数组比较确认.
  5. Check[‘清中’] = 0 = Base[0] != Base[‘清’],所以"清中"不存在.
词尾的逻辑判断

我们再来匹配一个词’清华中’,在其过程如下:

  1. 起始状态0: Base[0] = 1;
  2. 输入字符 ‘清’, 状态转移至 ‘清’:
  3. 输入字符 ‘华’,状态转移至 ‘清华’:
  4. 输入字符 ‘中’,状态转移至 ‘清华中’:
  5. Base[清华中]不为空, 可能是一个词, 需要与Check数组比较确认.
  6. Check[清华中] = X = Base[清华], 所以"清华中"是一个词

显然上面的结论也是错误的,因为在我们的假设词典里并没有词"清华中"。所以仅仅在Base数组里存放一个非空值并且在Check数组保存上一状态,也还是不够的。
逻辑也是很简单的:通常做法是用一个负值标志词尾,就是把对应的数字变成负数,在base数组或者check数组中操作都行。

我们再来匹配一个词’清华中’,在其过程如下:

  1. 起始状态0: Base[0] = 1;
  2. 输入字符 ‘清’, 状态转移至 ‘清’:
  3. 输入字符 ‘华’,状态转移至 ‘清华’:
  4. 输入字符 ‘中’,状态转移至 ‘清华中’:
  5. Base[清华中]不为空, 可能是一个词, 需要与Check数组比较确认.
  6. Check[清华中] = X = Base[清华], 但是 X > 0,所以不是一个完整的词。

手撕了双数组的data-trie的代码:
打印结果如下

# 代表了每个汉字的编码值
{=1, 人=2, 华=3, 大=4, 学=5, 新=6, 清=7}

# 每个层级的对应base和check值
----------------------------------------------------

    word           null           null            []           null            []           null           null           null            []           null           null           null           null
Position              0              1              2              3              4              5              6              7              8              9             10             11             12
    base              1              0              1              0              1              0              0              0              1              0              0              0              0
   check             -1             -1              0             -1              0             -1             -1             -1              0             -1             -1             -1             -1
----------------------------------------------------

    word           null           null            [][]            [][][]           null            [][]           null           null           null
Position              0              1              2              3              4              5              6              7              8              9             10             11             12
    base              1              0              2              1              1              2              3              0              3              3              0              0              0
   check             -1             -1              0              4              0              2              8             -1              0              8             -1             -1             -1
----------------------------------------------------

    word           null           null            [][]            [][][]        清华[]            [][]           null           null           null
Position              0              1              2              3              4              5              6              7              8              9             10             11             12
    base              1              0              2              1              1              2              3              3              3              3              0              0              0
   check             -1             -1              0              4              0              2              8              6              0              8             -1             -1             -1
----------------------------------------------------

    word           null           null            [][]            [][][]        清华[]            [][]      清华大[]           null           null
Position              0              1              2              3              4              5              6              7              8              9             10             11             12
    base              1              0              2             -1              1             -2             -3              5              3             -3             -5              0              0
   check             -1             -1              0              4              0              2              8              6              0              8              7             -1             -1
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

徐徐柒柒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值