引入

在笔者的项目中有一个对敏感词检测并替换的需求,这个需求应该算很常见的,而且也有很多的方式可以解决,主要的解决方式有这几种:

  • 暴力匹配
  • KMP算法
  • Trie(字典树)
  • AC自动机
  • 正则匹配
  • ……

在本文及笔者的项目中采用了Trie的方式,其他的实现方式笔者感兴趣可以自行了解。采用Trie的优缺点如下:

优点:

  • 节省了大量的字符存储空间和字符匹配时间
  • 随着字典树不断完善,新增的分支会越来越少

缺点:

  • 需要一段时间来构建字典树
  • 效率还不是最优

下面我们来看看如何实现

Trie

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

在一般的设计中,要求Trie在结构设计中:

  • 根节点不包含字符,除根节点外每一个节点都只包含一个字符

  • 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串

  • 每个节点的所有子节点包含的字符都不相同。

本文中的实现也采用上述原则,实现代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 单词查找树节点
*
* @author marx
* @date 2022/03/23
*/
public class TrieNode {
/**
* 数据
*/
private Character data;
/**
* 结束
*/
private boolean end = false;
/**
* 下一个节点
*/
private final Map<Character, TrieNode> nextNodes;

public TrieNode() {
nextNodes = new HashMap<>();
}

public TrieNode(Character data) {
this.data = data;
nextNodes = new HashMap<>();
}

public boolean isEnd() {
return end;
}

public void setEnd(boolean end) {
this.end = end;
}

public TrieNode getNextNode(Character data) {
return nextNodes.get(data);
}

/**
* 添加下一个节点
*
* @param character 字符
* @return {@link TrieNode}
*/
public TrieNode addNextNode(Character character) {
if (nextNodes.get(character) == null) {
nextNodes.put(character, new TrieNode(character));
}
return nextNodes.get(character);
}
}

敏感词检测并替换

在本文中,敏感词检测及替换的原则是先根据敏感词先建立对应的字典树(在项目中要注意将其设为单例的,避免敏感词缺失或者造成空间浪费),然后再根据要检测或修改的字符串逐位进行匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
/**
* 非法词工具
*
* @author marx
* @date 2022/03/23
*/
public class IllegalWordMatcher {
/**
* 根
*/
private final TrieNode root = new TrieNode();

/**
* 设置匹配列表
*
* @param matchList 匹配列表
*/
public void setMatchList(List<String> matchList) {
matchList.forEach(this::addWord);
}

/**
* 添加单词进Trie
*
* @param word 词
*/
public void addWord(CharSequence word) {
int n = word.length();
TrieNode temp = root;

for (int i = 0; i < n - 1; i++) {
temp = temp.addNextNode(word.charAt(i));
}

temp.addNextNode(word.charAt(n - 1)).setEnd(true);
}

public String replace(CharSequence text) {
return replace(text, '*');
}

/**
* 替换敏感词 (线程不安全)
* 需要先通过 setMatchList 或 addWord 添加屏蔽词
*
* @param text 需要检测的文本
* @param replace 替换的字符
* @return {@link String}
*/
public String replace(CharSequence text, Character replace) {
// 当前字符为空串
if (isBlank(text)) {
return text.toString();
}

// 断言字典树未构建的情况,仅在VM参数加上-ea的情况下生效
assert root.getKeyWordCount() != 0: "The match trie is null";

StringBuilder result = new StringBuilder();

int size = text.length();
// 匹配的字符串起点位置,匹配的当前字符的位置
int begin = 0, now = 0;

TrieNode temp = root;

// 遍历字符串
while (now < size) {
TrieNode next = temp.getNextNode(text.charAt(now));

// 不存在当前字符的非法串开头
// 将当前字符存入结果集
// 并移动指针、恢复temp节点开始下一个字符判断
if (next == null) {
result.append(text.charAt(begin));
now = begin + 1;
begin = now;
temp = root;
}
// 已经找到一个非法词匹配
else if (next.isEnd()) {
// 填充对应长度的替换符
for (int i = begin; i <= now; i++) {
result.append(replace);
}
// 从下一个字符继续开始匹配
now++;
begin = now;
temp = root;
}
// 存在非法词出现的可能,但是还不确定
else {
now++;
temp = next;
}
}

result.append(text.subSequence(begin, text.length()));

return result.toString();
}

/**
* 当前字符是否合法
*
* @param text 文本
* @return boolean
*/
public boolean isLegal(String text) {
// 当前字符为空串
if (isBlank(text)) {
return true;
}

// 断言字典树未构建的情况,仅在VM参数加上-ea的情况下生效
assert root.getKeyWordCount() != 0: "The match trie is null";

int size = text.length();
// 匹配的字符串起点位置,匹配的当前字符的位置
int begin = 0, now = 0;

TrieNode temp = root;
char[] textChars = text.toCharArray();

// 遍历字符串
while (now < size) {
TrieNode next = temp.getNextNode(text.charAt(now));

// 不存在当前字符的非法串开头
// 将当前字符存入结果集
// 并移动指针、恢复temp节点开始下一个字符判断
if (next == null) {
now = begin + 1;
begin = now;
temp = root;
}
// 已经找到一个非法词匹配
else if (next.isEnd()) {
return false;
}
// 存在非法词出现的可能,但是还不确定
else {
now++;
}
}

return true;
}

/**
* 字符串判空
*
* @param charSequence 年代
* @return boolean
*/
public boolean isBlank(CharSequence charSequence) {
if (charSequence == null || "".contentEquals(charSequence)) {
return true;
}
for (int i = 0; i < charSequence.length(); i++) {
if (charSequence.charAt(i) != ' ') {
return false;
}
}
return true;
}
}

总结

这次的分享就到这里了,建议读者可以自己实现一下,在这里还推荐一款高性能非法词(敏感词)检测组件 ToolGood.Words(Java版),支持多语言版本,感兴趣的读者还可以看看源码参照他的实现。