本篇文章主要介绍前缀树在开发中的应用。
采用前缀树这种数据结构,实现一个过滤敏感词算法,并将其封装成一个字符串工具,以供后续使用。如果充分理解上篇文章所说的两道Leetcode算法题,那么实现过滤敏感词的功能,并不困难。
1.简单复习前缀树
- 名称:Trie、字典树、查找树
- 特点:查找效率高,消耗内存大,主要是采取空间换时间的方式。
- 应用:字符串检索、词频统计、字符串排序等
- 结构:根节点为空,每个节点只包含一个字符,每个节点的所有子节点都必须不同,否则应当合并。
2.定义前缀树与insert方法
我们不应当直接读取或者修改类的成员变量,而是应当通过get
、set
方法去获取或者改变变量的值。这么做是为了解决安全问题!
因此此处我们将前缀树定义如下:
private class TrieNode {
private boolean isEnd = false;
private Map<Character, TrieNode> children = new HashMap<>();
public boolean isEnd() {
return isEnd;
}
public void setEnd(boolean isEnd) {
this.isEnd = isEnd;
}
public void addChildren(Character c, TrieNode node) {
children.putIfAbsent(c, node);
}
public TrieNode getChildren(Character c) {
return children.get(c);
}
}
两个成员变量,分别是isEnd
与children
。他们均通过指定的方法访问。
在进行addChildren
操作时,使用了putIfAbsent()
方法,该方法会试图向HashMap
中插入一个key-value
键值对,如果当前的HashMap
已经存在将要插入的key
,那么就不会做这个插入动作。如果不存在,那么直接插入这个key-value
。这个方法很trick!
有了定义好的树,那么定义相应的insert也很简单了。
private void insert(String word) {
TrieNode p = root;
for(char c : word.toCharArray()) {
p.addChildren(c, new TrieNode());
p = p.getChildren(c);
}
p.setEnd(true);
}
主要就是获取root,然后开始遍历单词,一边遍历单词,一边插入节点(有就不插入,没有就插入)。将指针继续往下移动。遍历结束后,给最后一个节点的isEnd
变量赋值,标识该单词的结束。
3.根据敏感词初始化前缀树
既然是过滤敏感词,总是需要一堆敏感词去构建Trie
。本文选择将敏感词按行存在某一文件中。每行存储一个。这样也方便更新。
问题来了?如何更新呢?//TODO:
并且我们希望再整个项目运行起来之后,就把这个字典给创建好,那么应当使用注解@PostConstruct
。
在方法上加上@PostConstruct
之后,当服务启动之后,容器实例化这个bean以后,在调用完了其构造器之后,该方法就会被自动的调用。
在读取sensitive-words.txt
文件时,可以很清楚地看到,BufferReader
方法的实现,用了装饰器模式
。
一边读取敏感词一遍构建Trie
。这样就成功的构建了一棵全部都是敏感词的前缀树。代码如下:
@PostConstruct
public void init() {
try( // 写在try的括号()里,最终会自动在finally中调用close方法关闭这些
// 把文件中的敏感词文件读出来,采用任意对象的获取类加载器(类加载器是从类路径下加载资源)
// 类路径就是classes文件夹下的资源,程序只要一编译,所有的资源都会编译到类路径下。
InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
// 从字节流中读文字不方便,需要将其转换成字符流,再讲字符流转化成缓冲流,这样效率会比较高。
BufferedReader reader = new BufferedReader(new InputStreamReader(is))
) {
String keyword;
while((keyword = reader.readLine()) != null) {
//添加到前缀树
this.insert(keyword);
}
} catch (IOException e) {
logger.error("加载敏感词文件失败:" + e.getMessage());
}
}
4.编写过滤敏感词方法
最为核心的构建敏感词的初始化方法写完之后,那么就可以正式开始写过滤敏感词的方法。
本方法采用替换法,如果遇到敏感词,就将其替换成***
。
当然了很多聪明的用户会采取在敏感词中间夹杂特殊符号,这种情况我们也是需要过滤的。
如何判断一个字符是否是特殊符号呢?
CharUtils.isAsciiAlphanumeric(c)
方法判断这个是不是合法的字符,是不是普通的字符。
0x2E80~0x9FFF
是东亚文字范围
判断字符是否是特殊符号代码如下:
private boolean isSymbol(Character c) {
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
最后采用双指针
结合Trie
的方法,就可以成功的将输入的文本中的敏感词全部过滤掉。
private static final String REPLACEMENT = "***";
public String filter(String text) {
if(text==null || text.length()==0) {
return null;
}
TrieNode p = root;
int begin = 0;
int position = 0;
StringBuilder sb = new StringBuilder();
while(position<text.length()) {
char c = text.charAt(position);
if(isSymbol(c)) {
if (p == root) {
sb.append(c);
begin++;
}
position++;
continue;
}
p = p.getChildren(c);
if(p==null) {
sb.append(text.charAt(begin));
position = ++begin;
p = root;
} else if(p.isEnd) {
sb.append(REPLACEMENT);
begin = ++position;
} else {
position++;
}
}
return sb.toString();
}
简单解释一下代码:
- 边界条件:当输入为空时,直接返回空。(当然前端肯定也是不会允许这么做的)。
- 定义临时的
TrieNode
TrieNode p = root;
以及双指针int begin = 0;
,int position = 0;
- 定义用于存储最后结果的
StringBuilder
- 只要
position<text.length()
就继续进行移动判断 - 获取当前
position
所指向的字符c
。- 如果
c
是特殊字符,且p==root
说明这就是一个普通的特殊字符,那么直接将该字符加入结果中,并且begin
指针向后移动。之后position
指针向后移,continue
循环。 - 如果这个字符是特殊符号,但是
p!=root
,说明当前的特殊符号是加载在某个潜在的敏感词之间的。那么这个字符就可能需要被cover住,那么这个时候,position
指针向后移,而begin
指针保持不动,continue
循环。
- 如果
- 如果
c
不是特殊字符,那么通过p
的getChildren()
方法获取这个字符。- 如果获取不到,那么说明这个字符不是敏感词,直接加入结果中。并且
begin
指针向后移动,同时让position
指针回到begin
指针更新过后的位置,并且p
重新置为root
。 - 如果获取到了,再判断当前的字符是否为敏感词的终止位置。
- 如果是一个敏感词的终止位置,那么直接往结果里加替代符,并且将
position
指针向后动,并且begin
指针前往更新过后的position
指针的位置。 - 如果不是一个敏感词的终止位置,那么
position
指针继续向后移动。
- 如果是一个敏感词的终止位置,那么直接往结果里加替代符,并且将
- 如果获取不到,那么说明这个字符不是敏感词,直接加入结果中。并且
- 继续循环,直到循环结束。
- 最后返回结果,注意要把
StringBuilder
类型转换成String
类型。
小结:
最后可以将上述方法封装成一个字符串工具,需要使用的时候,直接传入text
文本就可以了,非常好用!
通过刷Leetcode相关题目,学习数据结构与算法,虽然有的时候,非常的生无可恋,但是回头看看真的有助于写出更加简洁,高效的代码。非常有用啊!继续加油吧。