前缀树是什么
前缀树(Trie)又称字典树,是一种多叉树结构,从上图归纳出Trie树
- 基本性质:从根到某一个节点,拼接长字符串;根节点为空
- 特点:查找效率高 消耗内存大
- 应用:字符串检索 词频统计 字符串排序等
我们可以在每个节点中声明一个Hashmap。使用 Hashmap
来存储子节点。
Hashmap的键是字符,值是相对应的子节点。
过滤敏感词的原理
根据前缀树的原理,我们实际上是将它变成查找器
当文本中有字符串符合该树的节点串时,即视为敏感词,替换为***。
具体实现
- 首先,我们需要有一个文本作为
敏感词库
,我们将敏感词库
转换成前缀树
- 创建一个用来存储过滤后的文章的
StringBuilder
- 将输入的
text
逐个跟前缀树
根节点(为空)的子节点比对- 有符合的开始查找,否则取
text
的下一个字符来比对 - 查找时,如果有符合的字符串就替换为 ***,否则跳到下一个字符
- 有符合的开始查找,否则取
如何判断符合条件?
我们需要在每一个节点中加入一个标识isWordEnd
,当这个标识为true时,意味着敏感字符串结束,完全符合。
查找时的逻辑
需要一个指针 tempNode
来标识当前所在的节点
需要两个指针 begin
和 position
来标识 text
中取出的字符串长度
也就是说,我们依靠这三个指针的移动来处理 text
中的敏感词并存入 StringBuilder
上面已经讨论了他们的作用,那么下面讨论他们各自的移动的条件:
下面用c
指代text
取出的字符
按判断的逻辑如上图所示
敏感词过滤器代码
前缀树类
//前缀树类
private class TrieNode {
//子节点
private Map<Character, TrieNode> subNodes = new HashMap<>();
//敏感词结束标记
private boolean isWordEnd = false;
//获取结束标记
public boolean isWordEnd() {
return isWordEnd;
}
//设置结束标记
public void setWordEnd(boolean wordEnd) {
isWordEnd = wordEnd;
}
//添加子节点
public void addSubNode(Character c, TrieNode node) {
subNodes.put(c, node);
}
//获取子节点
public TrieNode getSubNode(Character c) {
return subNodes.get(c);
}
}
将敏感词库添加到前缀树中
// 根节点
private TrieNode rootNode = new TrieNode();
//初始化前缀树
@PostConstruct
public void init() {
try (
//使用类加载器读取 敏感词库 并缓存
InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
) {
String SensitiveWord;
while ((SensitiveWord = reader.readLine()) != null) {
// 添加到前缀树
this.addSensitiveWord(SensitiveWord);
}
} catch (IOException e) {
logger.error("加载敏感词文件失败: " + e.getMessage());
}
}
// 将一个敏感词添加到前缀树中
private void addSensitiveWord(String sensitiveWord) {
//根节点
TrieNode tempNode = rootNode;
//遍历敏感词 获取每一个字符
for (int i = 0; i < sensitiveWord.length(); i++) {
char c = sensitiveWord.charAt(i);
//根据字符获取子节点
TrieNode subNode = tempNode.getSubNode(c);
//如果子节点为空 创建
if (subNode == null) {
// 初始化子节点
subNode = new TrieNode();
tempNode.addSubNode(c, subNode);
}
// 指向子节点,进入下一轮循环
tempNode = subNode;
// 设置结束标识
if (i == sensitiveWord.length() - 1) {
tempNode.setWordEnd(true);
}
}
}
文本过滤
public String filter(String text) {
if (StringUtils.isBlank(text)) {
return null;
}
// 当前节点
TrieNode tempNode = rootNode;
// text字符开始位置
int begin = 0;
// text字符当前位置
int position = 0;
// 结果
StringBuilder sb = new StringBuilder();
while (position < text.length()) {
char c = text.charAt(position);
// 跳过符号
if (isSymbol(c)) {
// 若当前节点处于根节点,将此符号计入结果,取下一个text字符
if (tempNode == rootNode) {
sb.append(c);
begin++;
}
// 无论符号在开头或中间,当前位置都向下走一步
position++;
continue;
}
// 检查下级节点
tempNode = tempNode.getSubNode(c);
if (tempNode == null) {
// 以begin开头的字符串不是敏感词
sb.append(text.charAt(begin));
// 进入下一个位置
position = ++begin;
// 重新指向根节点
tempNode = rootNode;
} else if (tempNode.isWordEnd()) {
// 发现敏感词,将begin~position字符串替换掉
sb.append(REPLACEMENT);
// 进入下一个位置
begin = ++position;
// 重新指向根节点
tempNode = rootNode;
} else {
// 检查下一个字符
position++;
}
}
// 将最后一批字符计入结果
sb.append(text.substring(begin));
return sb.toString();
}
// 判断是否为符号
private boolean isSymbol(Character c) {
// 0x2E80~0x9FFF 是东亚文字范围
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
完整代码
@Component
public class SensitiveFilter {
private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);
// 替换符
private static final String REPLACEMENT = "***";
// 根节点
private TrieNode rootNode = new TrieNode();
//初始化前缀树
@PostConstruct
public void init() {
try (
//使用类加载器读取 敏感词库 并缓存
InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
) {
String SensitiveWord;
while ((SensitiveWord = reader.readLine()) != null) {
// 添加到前缀树
this.addSensitiveWord(SensitiveWord);
}
} catch (IOException e) {
logger.error("加载敏感词文件失败: " + e.getMessage());
}
}
// 将一个敏感词添加到前缀树中
private void addSensitiveWord(String sensitiveWord) {
//根节点
TrieNode tempNode = rootNode;
//遍历敏感词 获取每一个字符
for (int i = 0; i < sensitiveWord.length(); i++) {
char c = sensitiveWord.charAt(i);
//根据字符获取子节点
TrieNode subNode = tempNode.getSubNode(c);
//如果子节点为空 创建
if (subNode == null) {
// 初始化子节点
subNode = new TrieNode();
tempNode.addSubNode(c, subNode);
}
// 指向子节点,进入下一轮循环
tempNode = subNode;
// 设置结束标识
if (i == sensitiveWord.length() - 1) {
tempNode.setWordEnd(true);
}
}
}
/**
* 文本过滤
*
* @return
*/
public String filter(String text) {
if (StringUtils.isBlank(text)) {
return null;
}
// 当前节点
TrieNode tempNode = rootNode;
// text字符开始位置
int begin = 0;
// text字符当前位置
int position = 0;
// 结果
StringBuilder sb = new StringBuilder();
while (position < text.length()) {
char c = text.charAt(position);
// 跳过符号
if (isSymbol(c)) {
// 若当前节点处于根节点,将此符号计入结果,取下一个text字符
if (tempNode == rootNode) {
sb.append(c);
begin++;
}
// 无论符号在开头或中间,当前位置都向下走一步
position++;
continue;
}
// 检查下级节点
tempNode = tempNode.getSubNode(c);
if (tempNode == null) {
// 以begin开头的字符串不是敏感词
sb.append(text.charAt(begin));
// 进入下一个位置
position = ++begin;
// 重新指向根节点
tempNode = rootNode;
} else if (tempNode.isWordEnd()) {
// 发现敏感词,将begin~position字符串替换掉
sb.append(REPLACEMENT);
// 进入下一个位置
begin = ++position;
// 重新指向根节点
tempNode = rootNode;
} else {
// 检查下一个字符
position++;
}
}
// 将最后一批字符计入结果
sb.append(text.substring(begin));
return sb.toString();
}
// 判断是否为符号
private boolean isSymbol(Character c) {
// 0x2E80~0x9FFF 是东亚文字范围
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
//前缀树类
private class TrieNode {
//子节点
private Map<Character, TrieNode> subNodes = new HashMap<>();
//敏感词结束标记
private boolean isWordEnd = false;
//获取结束标记
public boolean isWordEnd() {
return isWordEnd;
}
//设置结束标记
public void setWordEnd(boolean wordEnd) {
isWordEnd = wordEnd;
}
//添加子节点
public void addSubNode(Character c, TrieNode node) {
subNodes.put(c, node);
}
//获取子节点
public TrieNode getSubNode(Character c) {
return subNodes.get(c);
}
}
}