【数据结构与算法基础】代码仓库:https://github.com/jinrunheng/datastructure-and-algorithm

一:什么是前缀树

前缀树(Trie)又被称为字典树或单词查找树。Trie 的典型应用是词频统计,如果文本中有 n 个条目,我们使用平衡二叉树,查询一个条目的时间复杂度为 O(logn);如果我们使用 Trie 的话,查询每个条目的时间复杂度和数据量无关,和单词的长度相关!如果单词的长度为 w,那么时间复杂度即为 O(w)。

那么,Trie 究竟是怎样的一种数据结构呢?

image.png

上面就是一个 Trie,我们可以看到 Trie 是一个多叉树。

在这张图中,我使用红,蓝节点表示从 root 出发直至该节点是否构成了单词,蓝色表示构成一个完整的单词,红色节点表示尚未构成单词,end 表示从 root 出发直至该节点构成了几个同样的单词;path 表示通向这个节点共有多少条路径;并且,我们使用了一个 Map 来存储该节点指向的所有的下一个节点。

上图中的 Trie 包含的所有字符串为:

  1. {"abc","abd","b","ba","cb","cd"}

又譬如:

image.png
上图中的 Trie 包含的所有字符串为:

  1. {"cat","car","pan","pan","panda","door","dear"}

要注意,我们实现的 Trie 是可以添加重复字符串的。

二:前缀树的基本方法

代码链接🔗

向 Trie 中添加,查询,删除字符串的操作非常简单,这里就不再赘述了,代码如下:

前缀树的添加操作

  1. /**
  2. * 向 Trie 中添加一个单词
  3. *
  4. * @param word
  5. */
  6. public void add(String word) {
  7. if (word == null) {
  8. return;
  9. }
  10. TrieNode cur = root;
  11. for (int i = 0; i < word.length(); i++) {
  12. char c = word.charAt(i);
  13. if (cur.next.get(c) == null) {
  14. cur.next.put(c, new TrieNode());
  15. }
  16. cur = cur.next.get(c);
  17. cur.path++;
  18. }
  19. cur.end++;
  20. size++;
  21. }

前缀树的查询操作

Trie 的查询包括两种,第一种是查询 Trie 中是否包含某个单词;第二种是查询在 Trie 中有多少个以 prefix 为前缀的字符串。

contains

  1. /**
  2. * 查询单词 word 是否在 Trie 中
  3. *
  4. * @param word
  5. * @return
  6. */
  7. public boolean contains(String word) {
  8. if (word == null) {
  9. return false;
  10. }
  11. TrieNode cur = root;
  12. for (int i = 0; i < word.length(); i++) {
  13. char c = word.charAt(i);
  14. if (cur.next.get(c) == null || cur.next.get(c).path == 0) {
  15. return false;
  16. }
  17. cur = cur.next.get(c);
  18. }
  19. return cur.end > 0;
  20. }

prefix

  1. /**
  2. * 返回在 Trie 中有多少个以 prefix 为前缀的字符串
  3. *
  4. * @param prefix
  5. * @return
  6. */
  7. public int prefix(String prefix) {
  8. if (prefix == null) {
  9. return 0;
  10. }
  11. TrieNode cur = root;
  12. for (int i = 0; i < prefix.length(); i++) {
  13. char c = prefix.charAt(i);
  14. if (cur.next.get(c) == null || cur.next.get(c).path == 0) {
  15. return 0;
  16. }
  17. cur = cur.next.get(c);
  18. }
  19. return cur.path;
  20. }

前缀树的删除操作

  1. /**
  2. * 从 Trie 中删除 word
  3. *
  4. * @param word
  5. */
  6. public void remove(String word) {
  7. if (contains(word)) {
  8. TrieNode cur = root;
  9. for (int i = 0; i < word.length(); i++) {
  10. char c = word.charAt(i);
  11. cur = cur.next.get(c);
  12. cur.path--;
  13. }
  14. cur.end--;
  15. size--;
  16. }
  17. }

三:Trie 和简单的模式匹配

代码链接🔗

LeetCode 211 题链接:211. 添加与搜索单词 - 数据结构设计

本题的考察点实际上就是设计 Trie 这种数据结构,不过它的要求是查找操作可以实现模式匹配。

例如,我的 Trie 中包含 “deer” 这个字符串,当我们查询的字符串为 “d..r” 时,“.” 可以代表任何字符,所以,该结果也是返回 true 的。

我们设计这个方法的方法签名为:

  1. // TrieNode node : 遍历 Trie 的当前的节点
  2. // String word : 查询的字符串
  3. // int index : 遍历到字符串的哪个位置
  4. private boolean match(TrieNode node,String word,int index)

该方法是一个递归的过程;和普通的查找有一些略微的不同,我们遍历查找的字符串,当遍历到的字符不等于 “.” 时,我们就去 Trie 对应的节点的 next 里面寻找有没有该字符,如果没有或下一个节点的 path 为 0 则返回 false,否则就继续递归。

当遍历到的字符为 “.” 时,我们就对 Trie 对应的节点的 next 里面的每一个节点进行递归,如果有一个命中,就返回 true;如果都没有命中,则返回 false。

该方法的代码如下:

  1. /**
  2. * 匹配查询:
  3. * <p>
  4. * 符号 "." 可以匹配任何字符
  5. * 例如输入 word = d..r
  6. * <p>
  7. * 如果 Trie 中包含字符串 deer,那么 d..r 就可以匹配到 deer,结果就会返回 true
  8. *
  9. * @param word
  10. * @return
  11. */
  12. public boolean match(String word) {
  13. return match(root, word, 0);
  14. }
  15. private boolean match(TrieNode node, String word, int index) {
  16. if (index == word.length()) {
  17. return node.end > 0;
  18. }
  19. char c = word.charAt(index);
  20. if (c != '.') {
  21. if (node.next.get(c) == null || node.next.get(c).path == 0) {
  22. return false;
  23. }
  24. return match(node.next.get(c), word, index + 1);
  25. } else {
  26. for (char nextChar : node.next.keySet()) {
  27. if (match(node.next.get(nextChar), word, index + 1)) {
  28. return true;
  29. }
  30. }
  31. return false;
  32. }
  33. }