前缀树简单介绍
- Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
- Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
它有3个基本性质:
(1)根节点不包含字符,除根节点外每一个节点都只包含一个字符。
(2)从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
(3)每个节点的所有子节点包含的字符都不相同。 - 举个栗子:给定敏感词 abc,bf, be可以构建出以下的前缀树。
利用前缀树进行敏感词过滤
- 前面介绍了前缀树的原理,那么我们是如何通过前缀树过滤敏感词的呢?还是直接举个栗子吧,假设我现在有一个字符串xwabfabcff,假设还是使用前面的前缀树,那么过滤过程如下:
(1)首先定义三个指针,指针1指向前缀树的根节点,指针2指向需要判断的字符开头,指针3指向当前需要判断的字符。对于前缀树中的每个叶子节点打上标记,表示敏感词的结束。 - (2)接着判断指针2和指针3之间的字符是否属于敏感词,假设指针2指向x,由于Root的子节点不存在x,那么指针3 = ++指针2,接着往后进行判断,当指针2走到a的时候,由于Root的子节点中存在a,那么指针1指向字节点a,指针2不动,指针3往后走一步,指针3指向b,由于b是a的子节点,那么指针1指向节点b,指针3继续往后走,然后发现f不是节点b的子节点,所以指针2~指针3之间的字符不是敏感词,指针3 = ++指针2,继续往后判断。接着指针2指向了b,然后按照上述流程,接着判断f,在判断f的时候,发现f上面有敏感词结束标识,说明指针2到指针3之间的bf是敏感词,对bf进行替换,然后指针2继续往后判断,指针2 = ++指针3,直接走到a接着判断。
- 上面是简单的判断敏感词的过程,但是现在我们会发现,如果用户在敏感词之间插入一些特殊符号,上述前缀树没法实现敏感词过滤了。那么如何解决呢,其实也很简单,还是举个栗子:假设开票是一个敏感词。
(1)第一种情况是在开票前后加特殊符号,我们不用管,直接继续判断就行。 - (2)第二种是在敏感词中间加特殊符号,这时候指针1肯定不指向根节点,只需让指针3跳过敏感词,接着向后继续判断即可。
- 存储敏感词,可以用文件存储,也可以使用数据库,案例就使用文件进行存储了。
使用java代码实现
@Component
public class SensitiveFilter {
private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);
private static final String REPLACEMENT = "***";
private TrieNode rootNode = new TrieNode();
/**
* 初始化前缀树
*/
@PostConstruct //@PostConstruct修饰的方法会在服务器加载Servle的时候运行,并且只会被服务器执行一次。PostConstruct在构造函数之后执行
private void init(){
try(
InputStream ins = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(ins));
) {
String keyWord;
while ((keyWord = reader.readLine()) != null){
this.addKeyWord(keyWord);
}
}catch (IOException e){
logger.error("加载敏感词文件失败: "+e.getMessage());
}
}
/**
* 将一个敏感词加入到前缀树中
* @param keyWord
*/
private void addKeyWord(String keyWord){
TrieNode tempNode = rootNode;
for(int i=0;i<keyWord.length();i++){
char c = keyWord.charAt(i);
TrieNode subNode = tempNode.getSubNode(c);
if(subNode == null){
//初始化子节点
subNode = new TrieNode();
tempNode.addSubNode(c,subNode);
}
//指向子节点,进入下一轮循环
tempNode = subNode;
//设置结束标识
if(i == keyWord.length() - 1){
subNode.setKeywordEnd(true);
}
}
}
/**
* 对文本中的敏感词进行过滤
* @param text 待过滤的敏感词
* @return 过滤之后的敏感词
*/
public String filter(String text){
if(StringUtils.isBlank(text)){
return null;
}
TrieNode tempNode = rootNode;
int begin = 0;
int position = 0;
StringBuilder sb = new StringBuilder();
while (position < text.length()){
char c = text.charAt(position);
//跳过符号
if(isSymbol(c)){
//若指针1处于根节点,将此符号计入结果,让指针2向后走一步
if(tempNode == rootNode){
begin++;
sb.append(c);
}
//无论符号在开头或中间,指针3都向后走一步
position++;
continue;
}
//检查下级节点
tempNode = tempNode.getSubNode(c);
if(tempNode == null){
//以begin开头的字符串不是敏感词
sb.append(text.charAt(begin));
//进入下一个位置
position = ++begin;
//重新指向根节点
tempNode = rootNode;
}else if (tempNode.isKeywordEnd){
//发现敏感词,将begin~position字符串替换掉
sb.append(REPLACEMENT);
//向后继续判断
begin = ++position;
//重新指向根节点
tempNode = rootNode;
}else {
//检查下一个字符
position++;
}
}
//将最后一批字符计入结果
sb.append(text.substring(begin));
return sb.toString();
}
/**
* 判断该字符是否是符号
* @param c
* @return
*/
private boolean isSymbol(Character c){
//0x2E80~0x9FFF 是东亚文字范围
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
//定义前缀树
private class TrieNode{
//关键词结束标识
private boolean isKeywordEnd = false;
//字节点(key是下级字符,value是下级节点)
private Map<Character,TrieNode> subNodes = new HashMap<>();
public boolean isKeywordEnd() {
return isKeywordEnd;
}
public void setKeywordEnd(boolean keywordEnd) {
isKeywordEnd = keywordEnd;
}
public void addSubNode(Character c,TrieNode node){
subNodes.put(c,node);
}
public TrieNode getSubNode(Character c){
return subNodes.get(c);
}
}
}
- 测试结果
@Test
public void testSensitiveFilter(){
String text = "我们这里可以嫖娼,qq是123456,还可以赌博,并提供开票服务," +
"还提供吸毒场所,还有人兼职卖淫,方便客户选择!";
text = sensitiveFilter.filter(text);
System.out.println(text);
text = "我们这里可以☆嫖☆☆娼☆,qq是123456,还可以☆☆赌☆博☆☆,并提供开票服务," +
"还提供☆☆吸☆☆☆毒场所,还有人兼职☆☆☆卖淫☆☆☆,方便客户选择!";
text = sensitiveFilter.filter(text);
System.out.println(text);
}
}
运行效果: