前缀树Trie及其应用(下) ---过滤敏感词

本篇文章主要介绍前缀树在开发中的应用。
采用前缀树这种数据结构,实现一个过滤敏感词算法,并将其封装成一个字符串工具,以供后续使用。如果充分理解上篇文章所说的两道Leetcode算法题,那么实现过滤敏感词的功能,并不困难。

1.简单复习前缀树

  • 名称:Trie、字典树、查找树
  • 特点:查找效率高,消耗内存大,主要是采取空间换时间的方式。
  • 应用:字符串检索、词频统计、字符串排序等
  • 结构:根节点为空,每个节点只包含一个字符,每个节点的所有子节点都必须不同,否则应当合并。
    在这里插入图片描述

2.定义前缀树与insert方法

我们不应当直接读取或者修改类的成员变量,而是应当通过getset方法去获取或者改变变量的值。这么做是为了解决安全问题!
因此此处我们将前缀树定义如下:

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);
        }
    }

两个成员变量,分别是isEndchildren。他们均通过指定的方法访问。
在进行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不是特殊字符,那么通过pgetChildren()方法获取这个字符。
    • 如果获取不到,那么说明这个字符不是敏感词,直接加入结果中。并且begin指针向后移动,同时让position指针回到begin指针更新过后的位置,并且p重新置为root
    • 如果获取到了,再判断当前的字符是否为敏感词的终止位置。
      • 如果是一个敏感词的终止位置,那么直接往结果里加替代符,并且将position指针向后动,并且begin指针前往更新过后的position指针的位置。
      • 如果不是一个敏感词的终止位置,那么position指针继续向后移动。
  • 继续循环,直到循环结束。
  • 最后返回结果,注意要把StringBuilder类型转换成String类型。

小结:

最后可以将上述方法封装成一个字符串工具,需要使用的时候,直接传入text文本就可以了,非常好用!
通过刷Leetcode相关题目,学习数据结构与算法,虽然有的时候,非常的生无可恋,但是回头看看真的有助于写出更加简洁,高效的代码。非常有用啊!继续加油吧。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值