Fork me on GitHub

数据结构-Trie

https://segmentfault.com/a/1190000008877595?utm_source=tag-newest
Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种.典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计.它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高.
  Trie的核心思想是空间换时间.利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的.

它有3个基本性质:
  1. 根节点不包含字符,除根节点外每一个节点都只包含一个字符.
  2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串.
  3. 每个节点的所有子节点包含的字符都不相同.

基本操作

本文是使用链表来实现Trie字典树,字符串的每个字符作为一个Node节点,Node主要有两部分组成:

  1. 是否是单词 (boolean isWord)
  2. 节点所有的子节点,用map来保存 (Map next)

例如插入一个paint单词,如果用户查询pain,尽管 paint 包含了 pain,但是Trie中仍然不包含 pain 这个单词,所以如果往Trie中插入一个单词,需要把该单词的最后一个字符的节点的 isWord设置为true.

节点的所有子节点,通过一个Map来存储,key是当前子节点对应的字符,value是子节点.

添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.TreeMap;

public class Trie {

private class Node{

public boolean isWord;
public TreeMap<Character, Node> next;

public Node(boolean isWord){
this.isWord = isWord;
next = new TreeMap<>();
}

public Node(){
this(false);
}
}

private Node root;
private int size;

public Trie(){
root = new Node();
size = 0;
}

// 获得Trie中存储的单词数量
public int getSize(){
return size;
}

// 向Trie中添加一个新的单词word
public void add(String word){

Node cur = root;
for(int i = 0 ; i < word.length() ; i ++){
char c = word.charAt(i);
if(cur.next.get(c) == null)
cur.next.put(c, new Node());
cur = cur.next.get(c);
}

if(!cur.isWord){
cur.isWord = true;
size ++;
}
}
}

查找

Trie查找操作就比较简单了, 遍历带查找的字符串的字符, 如果每个节点都存在, 并且待查找字符串的最后一个字符对应的NodeisWord 属性为true ,则表示该单词存在

1
2
3
4
5
6
7
8
9
10
11
12
13
// 查询单词word是否在Trie中
public boolean contains(String word){

Node cur = root;
for(int i = 0 ; i < word.length() ; i ++){
char c = word.charAt(i);
if(cur.next.get(c) == null)
return false;
cur = cur.next.get(c);
}
//cur就是word的最后一个字符的Node
return cur.isWord;
}

前缀查询

前缀查询和上面的查询操作基本类似,就是不需要判断 isWord

1
2
3
4
5
6
7
8
9
10
11
12
13
// 查询是否在Trie中有单词以prefix为前缀
public boolean isPrefix(String prefix){

Node cur = root;
for(int i = 0 ; i < prefix.length() ; i ++){
char c = prefix.charAt(i);
if(cur.next.get(c) == null)
return false;
cur = cur.next.get(c);
}

return true;
}

删除

Trie的删除操作就稍微复杂一些,主要分为以下3种情况:

  1. 如果单词是另一个单词的前缀
    如果待删除的单词是另一个单词的前缀,只需要把该单词的最后一个节点的isWord的改成false
    比如Trie中存在 pandapan 这两个单词,删除 pan ,只需要把字符 n 对应的节点的isWord改成 false 即可

  2. 如果单词的所有字母的都没有多个分支,删除整个单词
    如果单词的所有字母的都没有多个分支(也就是说该单词所有的字符对应的Node都只有一个子节点),则删除整个单词

  3. 如果单词的除了最后一个字母,其他的字母有多个分支
    将最后一个字母删掉即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    /*
    * 1,如果单词是另一个单词的前缀,只需要把该word的最后一个节点的isWord的改成false
    * 2,如果单词的所有字母的都没有多个分支,删除整个单词
    * 3,如果单词的除了最后一个字母,其他的字母有多个分支,
    */

    public boolean remove(String word) {
    Node multiChildNode = null;
    int multiChildNodeIndex = -1;
    Node current = root;
    for (int i = 0; i < word.length(); i++) {
    Node child = current.next.get(word.charAt(i));
    //如果Trie中没有这个单词
    if (child == null) {
    return false;
    }
    //当前节点的子节点大于1个
    if (child.next.size() > 1) {
    multiChildNodeIndex = i;
    multiChildNode = child;
    }
    current = child;
    }
    //如果单词后面还有子节点
    if (current.next.size() > 0) {
    if (current.isWord) {
    current.isWord = false;
    size--;
    return true;
    }
    //不存在该单词,该单词只是前缀
    return false;
    }
    //如果单词的所有字母的都没有多个分支,删除整个单词
    if (multiChildNodeIndex == -1) {
    root.next.remove(word.charAt(0));
    size--;
    return true;
    }
    //如果单词的除了最后一个字母,其他的字母有分支
    if (multiChildNodeIndex != word.length() - 1) {
    multiChildNode.next.remove(word.charAt(multiChildNodeIndex + 1));
    size--;
    return true;
    }
    return false;
    }

更多关于Trie的话题

上面实现的Trie中,我们是使用TreeMap来保存节点的所有的子节点,也可以使用HashMap来保存所有的子节点,效率更高:

1
2
3
public Node() {
next = new HashMap<>();
}

当然我们也可以使用一个定长的数组来存储所有的子节点,效率比HashMap更高,因为不需要使用hash函数:

1
2
3
4
public Node(boolean isWord){
this.isWord = isWord;
next = new Node[26];//只能存储26个小写字母
}

Trie查询效率非常高,但是对空间的消耗还是挺大的,这也是典型的空间换时间.

可以使用 压缩字典树(Compressed Trie) , 但是维护相对来说复杂一些.

如果我们不止存储英文单词,还有其他特殊字符,那么维护子节点的集合可能会更多.

可以对Trie字典树做些限制,比如每个节点只能有3个子节点,左边的节点是小于父节点的,中间的节点是等于父节点的,右边的子节点是大于父节点的,这就是三分搜索Trie字典树(Ternary Search Trie).

LeetCode

LeetCode第208号问题

问题描述:

实现一个Trie(前缀树),包含 insert, search, 和startsWith 这三个操作.

示例:

1
2
3
4
5
6
7
8
9

Trie trie = new Trie();

trie.insert("apple");
trie.search("apple"); // 返回 true
trie.search("app"); // 返回 false
trie.startsWith("app"); // 返回 true
trie.insert("app");
trie.search("app"); // 返回 true

你可以假设所有的输入都是由小写字母 a-z 构成的.
保证所有输入均为非空字符串.

这个问题在我们实现的 Trie字典树 中已经实现了这个功能了,add()就是对应的insert(),contains()就是对应的search(),isPrefix()就是对应的startsWith()

LeetCode第211号问题

问题描述:

设计一个支持以下两种操作的数据结构:

1
2
void addWord(word)
bool search(word)

search(word)可以搜索文字或正则表达式字符串,字符串只包含字母.a-z.. 可以表示任何一个字母.

示例:

1
2
3
4
5
6
7
addWord("bad")
addWord("dad")
addWord("mad")
search("pad") -> false
search("bad") -> true
search(".ad") -> true
search("b..") -> true

问题说明:

你可以假设所有单词都是由小写字母a-z组成的.

这个问题就是上一个问题的基础上加上. 的处理,稍微复杂点.

如果下一个字符是. ,那么需要遍历该节点的所有子节点,对所有子节点的处理就是一个递归程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/** Returns if the word is in the data structure. A word could contain the dot character '.' to represent any one letter. */
public boolean search(String word) {
return match(root, word, 0);
}

private boolean match(Node node, String word, int index){
//如果已经到了待查询字符串的尾端了
if(index == word.length())
return node.isWord;

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

LeetCode第677号问题

问题描述:

实现一个 MapSum类里的两个方法,insertsum.

对于方法insert,你将得到一对(字符串,整数)的键值对.字符串表示键,整数表示值.如果键已经存在,那么原来的键值对将被替代成新的键值对.

对于方法 sum,你将得到一个表示前缀的字符串,你需要返回所有以该前缀开头的键的值的总和.

示例 1:

1
2
3
4
输入: insert("apple", 3), 输出: Null
输入: sum("ap"), 输出: 3
输入: insert("app", 2), 输出: Null
输入: sum("ap"), 输出: 5

总结一句话就是,求出所有符合该前缀的字符串的键值的总和.

节点需要保存一个键值,用于求和.节点Node不需要维护isWord这个属性了,因为不关注是不是一个单词.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import java.util.TreeMap;

public class MapSum {

private class Node{

public int value;
public TreeMap<Character, Node> next;

public Node(int value){
this.value = value;
next = new TreeMap<>();
}

public Node(){
this(0);
}
}

private Node root;

/** Initialize your data structure here. */
public MapSum() {

root = new Node();
}

public void insert(String key, int val) {

Node cur = root;
for(int i = 0 ; i < key.length() ; i ++){
char c = key.charAt(i);
if(cur.next.get(c) == null)
cur.next.put(c, new Node());
cur = cur.next.get(c);
}
cur.value = val;
}

public int sum(String prefix) {

Node cur = root;
for(int i = 0 ; i < prefix.length() ; i ++){
char c = prefix.charAt(i);
if(cur.next.get(c) == null)
return 0;
cur = cur.next.get(c);
}

return sum(cur);
}

private int sum(Node node){

int res = node.value;
for(char c: node.next.keySet())
res += sum(node.next.get(c));
return res;
}

public static void main(String[] args) {
MapSum mapSum = new MapSum();
mapSum.insert("apple", 3);
System.out.println(mapSum.sum("ap"));
}
}

完整代码

-------------本文结束感谢您的阅读-------------
坚持原创技术分享,您的支持将鼓励我继续创作!
0%