一、简介
Trie 树也称为字典树、单词查找树,最大的特点就是共享字符串的公共前缀来达到节省空间的目的。
例如,字符串 “abc” 和 “abd” 构成的 trie 树如下:
字典树的根节点不存任何数据,每整个分支代表一个完整的字符串。像 abc 和 abd 有公共前缀 ab,所以我们可以共享节点 ab。
如果再插入 abf,则变成这样:
如果我再插入 bc,则是这样(bc 和其他三个字符串没有公共前缀)
那如果再插入 “ab” 这个字符串呢?
每个分支的内部可能也含有完整的字符串,所以我们可以对于那些是某个字符串结尾的节点做一个标记,例如 abc,abd,abf 都包含了字符串 ab,所以我们可以在节点 b 这里做一个标记。如下(我用红色作为标记):
二、应用
2.1 提前列出搜索信息
字典树最大的特点就是利用了字符串的公共前缀,像我们有时候在百度、谷歌输入某个关键字的时候,它会给我们列举出很多相关的信息。
2.2 敏感词过滤
例:一段字符串 “abcdefghi" ,以及三个敏感词 “de” ,“bca” ,“bcf” 。
先把三个敏感词建立一颗字典树,如下:
接着可以采用三个指针来遍历。
首先指针 p1 指向 root ,指针 p2 和 p3 指向字符串第一个字符。
然后从字符串的 a 开始,检测有没有以 a 作为前缀的敏感词,直接判断 p1 的孩子节点中是否有 a 这个节点就可以了,显然这里没有。
接着把指针 p2 和 p3 向右移动一格。
然后从字符串 b 开始查找,看看是否有以 b 作为前缀的字符串,p1 的孩子节点中有 b,这时,我们把 p1 指向节点 b ,p2 向右移动一格,不过,p3 不动。
判断 p1 的孩子节点中是否存在 p2 指向的字符 c ,显然有。我们把 p1 指向节点 c ,p2 向右移动一格,p3 不动。
判断 p1 的孩子节点中是否存在 p2 指向的字符 d ,这里没有。这意味着,不存在以字符 b 作为前缀的敏感词。这时我们把 p2 和 p3 都移向字符 c ,p1 还是还原到最开始指向 root 。
和前面的步骤一样,判断有没以 c 作为前缀的字符串,显然这里没有,所以把 p2 和 p3 移到字符 d 。
然后从字符串 d 开始查找,看看是否有以 d 作为前缀的字符串,p1 的孩子节点中有 d ,这时,我们把 p1 指向节点 b ,p2 向右移动一格,不过,p3 和刚才一样不动。
判断 p1 的孩子节点中是否存在 p2 指向的字符 e ,显然有。我们把 p1 指向节点 e ,并且,这里 e 是最后一个节点了,查找结束,所以存在敏感词 de ,即 p3 和 p2 这个区间指向的就是敏感词了,把 p2 和 p3 指向的区间那些字符替换成 * 。并且把 p2 和 p3 移向字符 f 。如下:
接着还是重复同样的步骤,直到 p3 指向最后一个字符。
复杂度分析
如果敏感词的长度为 m,则每个敏感词的查找时间复杂度是 O(m),字符串的长度为 n,我们需要遍历 n 遍,所以敏感词查找这个过程的时间复杂度是 O(n * m)。如果有 t 个敏感词的话,构建字典树的时间复杂度是 O(t * m)。
这里我说明一下,在实际的应用中,构建字典树的时间复杂度我觉得可以忽略,因为字典树我们可以在一开始就构建了,以后可以无数次重复利用。
字典树可以采用 HashMap 来实现,因为一个节点的字节点个数未知,采用 HashMap 可以动态拓展,而且可以在 O(1) 复杂度内判断某个子节点是否存在。
三、敏感词过滤代码实现
3.1 字典树结点
public class TrieNode {
// 是否敏感词的结尾
private boolean isEnd = false;
// 下一层节点
Map<Character, TrieNode> subNodes = new HashMap<>();
/**
* 添加下一个敏感词节点
*/
public void addSubNode(Character c, TrieNode node) {
subNodes.put(c, node);
}
/**
* 获取下一个敏感词节点
*/
public TrieNode getSubNode(Character c) {
return subNodes.get(c);
}
/**
* 设置敏感词标识
*/
public void setEnd(boolean end) {
this.isEnd = end;
}
/**
* 判断是否为敏感词的结尾
*/
public boolean getIsEnd() {
return this.isEnd;
}
}
3.2 构建字典树
public class TireTree {
// 敏感词替换字符
private static final String DEFAULT_REPLACEMENT = "*";
// 根节点
private final TrieNode rootNode;
public TireTree() {
rootNode = new TrieNode();
}
/**
* 识别特殊符号
*/
private boolean isSymbol(Character c) {
int ic = (int) c;
// 0x2E80-0x9FFF 东亚文字范围
return !CharUtils.isAsciiAlphanumeric(c) && (ic < 0x2E80 || ic > 0x9FFF);
}
/**
* 将敏感词添加到字典树中
*/
public void addWord(String text) {
if (text == null || text.trim().equals(""))
return;
TrieNode curNode = rootNode;
int length = text.length();
// 遍历每个字
for (int i = 0; i < length; ++i) {
Character c = text.charAt(i);
// 过滤特殊字符
if (isSymbol(c))
continue;
TrieNode nextNode = curNode.getSubNode(c);
// 第一次添加的节点
if (nextNode == null) {
nextNode = new TrieNode();
curNode.addSubNode(c, nextNode);
}
curNode = nextNode;
// 设置敏感词标识
if (i == length - 1)
curNode.setEnd(true);
}
}
/**
* 过滤敏感词
*/
public String filter(String text) {
if (text == null || text.trim().equals(""))
return text;
StringBuilder result = new StringBuilder();
TrieNode curNode = rootNode;
int begin = 0; // 回滚位置
int position = 0; // 当前位置
while (position < text.length()) {
Character c = text.charAt(position);
// 过滤空格等
if (isSymbol(c)) {
if (curNode == rootNode) {
result.append(c);
++begin;
}
++position;
continue;
}
curNode = curNode.getSubNode(c);
// 当前位置的匹配结束
if (curNode == null) {
// 以begin开始的字符串不存在敏感词
result.append(text.charAt(begin));
// 跳到下一个字符开始测试
position = begin + 1;
begin = position;
// 回到树初始节点,重新匹配
curNode = rootNode;
} else if (curNode.getIsEnd()) {
// 发现敏感词,从begin到position的位置用replacement替换掉
result.append(DEFAULT_REPLACEMENT);
position = position + 1;
begin = position;
// 回到树初始节点,重新匹配
curNode = rootNode;
} else {
++position;
}
}
result.append(text.substring(begin));
return result.toString();
}
}
3.3 测试
class Test {
public static void main(String[] args) {
TireTree tree = new TireTree();
// 添加敏感词
tree.addWord("de");
tree.addWord("bca");
tree.addWord("bcf");
// 过滤敏感词
String res = tree.filter("abcdefghi");
System.out.println(res); // abc*fghi
}
}