1、什么是字典树
如下图就是一颗字典树, 这是往树里插入字符串 he 、she 、hers 、his 、shy 生成的树
特点
-
字典树又名前缀树 和 单词查找树 , 每个字符串的公共前缀都将作为一个字符节点保存。 -
它本质是一颗多叉树, 除了根节点, 每个节点只存放一个字符, 从根节点到某一绿色节点 ,路径上经过的字符连接起来,就是该节点对应的字符串。
- 比如 从根节点root到 8号节点 经过的路径连接起来就是一个插入的字符串
his
2、字典树能做什么
核心是空间换时间,优点是 利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的,查询的时间复杂度仅与搜索的字符串的长度有关 ,即 时间复杂度为O(len) , 而普通二分搜索树的时间复杂度为O(logN), 缺点是比较耗空间 , 毕竟以前一个节点可以存放一整个字符串,现在只能存放一个字符, 虽然可以通过修改为压缩字典树 , 但同时也增加了维护成本
常见应用场景
词频统计
- 如果不需要用到字典树的其他特性,还是用哈希表好,毕竟时间复杂度接近O(1), 而且字典树比较耗空间
前缀匹配
字符串搜索
其他数据结构扩展
3、代码实现
public class Trie {
@Data
@EqualsAndHashCode
class Node {
public Character name;
public TreeMap<Character,Node> next;
public boolean isWordEnd;
public int prefixCount;
private Node parent;
public Node(boolean isWordEnd) {
this(null,isWordEnd,0);
}
public Node(Character name, boolean isWordEnd, int prefixCount) {
this.name = name;
this.isWordEnd = isWordEnd;
this.prefixCount = prefixCount;
this.next = new TreeMap<>();
}
public Node(Character name, boolean isWordEnd, int prefixCount, Node parent) {
this(name,isWordEnd,prefixCount);
this.parent = parent;
}
}
private Node root;
private int size;
public Trie() {
this.root = new Node(false);
this.size = 0;
}
public void add(String word){
Node cur = this.root;
for (char key : word.toCharArray()) {
if(!cur.next.containsKey(key)){
cur.next.put(key,new Node(key,false,1,cur));
}else{
cur.next.get(key).prefixCount++;
}
cur = cur.next.get(key);
}
if (!cur.isWordEnd){
cur.isWordEnd = true;
this.size++;
}
}
public void remove(String word){
Node node = getPrefixLastNode(word);
if (node == null || !node.isWordEnd){
System.out.println("单词不存在");
return;
}
if (!node.next.isEmpty()){
node.isWordEnd = false;
}else{
Node pre = node;
node = node.parent;
while (node != null && !node.isWordEnd && node.prefixCount <= 1){
pre = node;
node = node.parent;
}
if (node != null){
node.next.remove(pre.name);
}
}
while(node != null){
node.prefixCount--;
node = node.parent;
}
}
public void bfsTraverse() {
Queue<Node> queue = new ArrayDeque<>();
queue.offer(this.root);
Node preLayerLastNode = this.root;
Node curLayerLastNode = this.root;
int curLayer = 0;
while(!queue.isEmpty()){
Node tmp = queue.remove();
if (curLayer != 0){
System.out.print(tmp.name +"("+ tmp.prefixCount+"-" + tmp.isWordEnd + ")" + "\t");
}
TreeMap<Character, Node> treeMap = tmp.next;
if (treeMap != null && !treeMap.isEmpty()){
List<Node> arrayList = new ArrayList<>(treeMap.values());
queue.addAll(arrayList);
if (!arrayList.isEmpty()){
curLayerLastNode = arrayList.get(arrayList.size()-1);
}
}
if (preLayerLastNode.equals(tmp)){
curLayer++;
preLayerLastNode = curLayerLastNode;
System.out.print("\n" + curLayer + "| ");
}
}
}
public boolean contains(String word) {
Node node = getPrefixLastNode(word);
return node != null && node.isWordEnd;
}
public boolean hasPrefix(String prefix){
return getPrefixLastNode(prefix) != null;
}
public boolean containPatternWord(String word) {
return match(root, word, 0);
}
private boolean match(Node node, String word, int index){
if(index == word.length())
return node.isWordEnd;
char c = word.charAt(index);
if(c != '.'){
if(node.next.get(c) == null)
return false;
return match(node.next.get(c), word, index + 1);
}
else{
for(char nextChar: node.next.keySet())
if(match(node.next.get(nextChar), word, index + 1))
return true;
return false;
}
}
public List<String> searchPrefix(String prefix) {
Node cur = getPrefixLastNode(prefix);
List<String> paths = new ArrayList<>();
dfsSearchAllPath(cur,paths,prefix);
return paths;
}
private void dfsSearchAllPath(Node node, List<String> paths, String curPath) {
if (node == null || node.next.isEmpty()) {
paths.add(curPath);
return;
}
for (Node child : node.next.values()) {
dfsSearchAllPath(child,paths,curPath + child.name);
}
}
public int getPrefixCount(String prefix){
Node node = getPrefixLastNode(prefix);
return node != null ? node.prefixCount : 0;
}
private Node getPrefixLastNode(String prefix){
Node cur = root;
for (char key : prefix.toCharArray()) {
if(!cur.next.containsKey(key))
return null;
cur = cur.next.get(key);
}
return cur;
}
public List<String> search(String patternWord){
patternWord = patternWord
.replaceAll("\s*","")
.replaceAll("((?=[\x21-\x7e]+)[^A-Za-z0-9])[\x21-\x7e]+[^A-Za-z0-9]","");
List<String> paths = new ArrayList<>();
dfsSearchAllPatternPath(root,patternWord,0,paths,"");
return paths;
}
private void dfsSearchAllPatternPath(Node node, String patternWord, int index, List<String> paths, String curPath){
if (node == null) {
return;
}
if (node.isWordEnd && patternWord.length() == index){
paths.add(curPath);
}
for (Node child : node.next.values()) {
int tmpIndex = index;
if (tmpIndex < patternWord.length() && patternWord.charAt(tmpIndex) == child.name){
tmpIndex++;
}
dfsSearchAllPatternPath(child,patternWord,tmpIndex,paths,curPath + child.name);
}
}
}
4、快速开始
4.1 生成字典树
Trie trie = new Trie();
trie.add("这个杀手冷静");
trie.add("冷静的杀手");
trie.add("杀手冷静");
trie.add("杀手百度云");
trie.add("杀手冷静点说的什么");
trie.add("杀手冷静成本");
trie.add("这个杀手不太冷静完整版在线观看");
trie.add("这个杀手不太冷静电影");
trie.add("这个杀手不太冷静是什么意思");
trie.add("这个杀手不太冷静电影");
trie.add("这个杀手不太冷静迅雷下载");
trie.add("这个杀手不太冷静百度网盘");
trie.add("豆瓣这个杀手不太冷静");
trie.add("这个杀手不太冷静");
trie.add("这个杀手不太冷静");
trie.add("这个诅咒太棒了");
trie.add("这个杀手不太冷静");
trie.add("极其安静的顶尖杀手");
trie.add("这个杀手不冷漠");
trie.add("最冷酷的杀手");
trie.add("一个极其安静的顶尖杀手");
4.2 树广度遍历
也叫层序遍历, 原理就是通过队列去维护遍历的顺序, 如遍历第一层后, 下一次要遍历的就是第二层, 所以把第二层的元素都添加到队列。
trie.bfsTraverse();
结果如下:
- 冷(1-false)表示一个节点, 存放的字符是冷, 前缀词频是1, fasle表示不是一个单词的结尾
1| 一(1-false) 冷(1-false) 最(1-false) 杀(4-false) 极(1-false) 豆(1-false) 这(12-false)
2| 个(1-false) 静(1-false) 冷(1-false) 手(4-false) 其(1-false) 瓣(1-false) 个(12-false)
3| 极(1-false) 的(1-false) 酷(1-false) 冷(3-false) 百(1-false) 安(1-false) 这(1-false) 杀(11-false) 诅(1-false)
4| 其(1-false) 杀(1-false) 的(1-false) 静(3-true) 度(1-false) 静(1-false) 个(1-false) 手(11-false) 咒(1-false)
5| 安(1-false) 手(1-true) 杀(1-false) 成(1-false) 点(1-false) 云(1-true) 的(1-false) 杀(1-false) 不(10-false) 冷(1-false) 太(1-false)
6| 静(1-false) 手(1-true) 本(1-true) 说(1-false) 顶(1-false) 手(1-false) 冷(1-false) 太(9-false) 静(1-true) 棒(1-false)
7| 的(1-false) 的(1-false) 尖(1-false) 不(1-false) 漠(1-true) 冷(9-false) 了(1-true)
8| 顶(1-false) 什(1-false) 杀(1-false) 太(1-false) 静(9-true)
9| 尖(1-false) 么(1-true) 手(1-true) 冷(1-false) 完(1-false) 是(1-false) 电(2-false) 百(1-false) 迅(1-false)
10| 杀(1-false) 静(1-true) 整(1-false) 什(1-false) 影(2-true) 度(1-false) 雷(1-false)
11| 手(1-true) 版(1-false) 么(1-false) 网(1-false) 下(1-false)
12| 在(1-false) 意(1-false) 盘(1-true) 载(1-true)
13| 线(1-false) 思(1-true)
14| 观(1-false)
15| 看(1-true)
16|
4.3 搜索前缀匹配
如上图通过我们输入前缀这个,就会提示后面可以输入的所有单词如, 这时可以用前缀匹配, 先搜索到前缀的最后一个节点, 然后从该节点开始DFS深搜每条路径,找到所有符合的单词
List<String> searchPrefix = trie.searchPrefix("这个");
for (String prefix : searchPrefix) {
System.out.println(prefix);
}
结果:
这个杀手不冷漠
这个杀手不太冷静完整版在线观看
这个杀手不太冷静是什么意思
这个杀手不太冷静电影
这个杀手不太冷静百度网盘
这个杀手不太冷静迅雷下载
这个杀手冷静
这个诅咒太棒了
4.4 搜索单词提示
如上图, 我们搜索 两个关键字 杀手冷静, 将包含这四个字符的并且顺序一致的所有单词搜索出来。 原理也是用DFS深搜每条路径, 但是只把包含搜索字符的路径保存下来
List<String> tmpList = trie.search("杀手冷静");
for (String tmp : tmpList) {
System.out.println(tmp);
}
结果:
杀手冷静
杀手冷静成本
杀手冷静点说的什么
豆瓣这个杀手不太冷静
这个杀手不太冷静
这个杀手不太冷静完整版在线观看
这个杀手不太冷静是什么意思
这个杀手不太冷静电影
这个杀手不太冷静百度网盘
这个杀手不太冷静迅雷下载
这个杀手冷静
4.5 前缀词频统计
由于在添加的时候就维护了前缀的数量, 所以搜索到单词最后一个节点后直接获取词频即可。
int prefixCount = trie.getPrefixCount("");
List<String> tmp = trie.search("杀手冷静");
for (String name : tmp) {
int prefixCount = trie.getPrefixCount(name);
System.out.println("关键字: "+ name + ", 前缀搜索次数: " + prefixCount);
}
结果:
关键字: 杀手冷静, 前缀搜索次数: 3
关键字: 杀手冷静成本, 前缀搜索次数: 1
关键字: 杀手冷静点说的什么, 前缀搜索次数: 1
关键字: 豆瓣这个杀手不太冷静, 前缀搜索次数: 1
关键字: 这个杀手不太冷静, 前缀搜索次数: 9
关键字: 这个杀手不太冷静完整版在线观看, 前缀搜索次数: 1
关键字: 这个杀手不太冷静是什么意思, 前缀搜索次数: 1
关键字: 这个杀手不太冷静电影, 前缀搜索次数: 2
关键字: 这个杀手不太冷静百度网盘, 前缀搜索次数: 1
关键字: 这个杀手不太冷静迅雷下载, 前缀搜索次数: 1
关键字: 这个杀手冷静, 前缀搜索次数: 1
|