Trie 树

1、Trie 树的定义

Trie 树(又叫「前缀树」或「字典树」)是一种用于快速查询「某个字符串/字符前缀」是否存在的数据结构。

Trie 是一颗非典型的多叉树模型。

其核心是使用「边」来代表有无字符,使用「点」来记录是否为「单词结尾」以及「其后续字符串的字符是什么」。

image-20220918210519271

2、Trie 树的结构

2.1 二维数组

一个朴素的想法是直接使用「二维数组」来实现 Trie 树。

  • 使用二维数组 trie[] 来存储我们所有的单词字符。
  • 使用 index来自增记录我们到底用了多少个格子(相当于给被用到格子进行编号)。
  • 使用 count[]数组记录某个格子被「被标记为结尾的次数」(当 id 编号的格子被标记了 n 次,则有 cnt[idx]=n)。
class Trie {
    int N = 100009; // 直接设置为十万级
    int[][] trie;
    int[] count;
    int index;

    public Trie() {
        trie = new int[N][26];
        count = new int[N];
        index = 0;
    }
    
    public void insert(String s) {
        int p = 0;
        for (int i = 0; i < s.length(); i++) {
            int u = s.charAt(i) - 'a';
            if (trie[p][u] == 0) trie[p][u] = ++index;
            p = trie[p][u];
        }
        count[p]++;
    }
    
    public boolean search(String s) {
        int p = 0;
        for (int i = 0; i < s.length(); i++) {
            int u = s.charAt(i) - 'a';
            if (trie[p][u] == 0) return false;
            p = trie[p][u];
        }
        return count[p] != 0;
    }
    
    public boolean startsWith(String s) {
        int p = 0;
        for (int i = 0; i < s.length(); i++) {
            int u = s.charAt(i) - 'a';
            if (trie[p][u] == 0) return false;
            p = trie[p][u];
        }
        return true;
    }
}
  • 时间复杂度:Trie 树的每次调用时间复杂度取决于入参字符串的长度。复杂度为 O(Len)。
  • 空间复杂度:二维数组的高度为 n,字符集大小为 k。复杂度为 O(nk)。

2.2 TrieNode

相比二维数组,更加常规的做法是建立 TrieNode 结构节点。

随着数据的不断插入,根据需要不断创建 TrieNode 节点。

class Trie {
    class TrieNode {
        boolean end;
        TrieNode[] tns = new TrieNode[26];
    }

    TrieNode root;
    public Trie() {
        root = new TrieNode();
    }

    public void insert(String s) {
        TrieNode p = root;
        for(int i = 0; i < s.length(); i++) {
            int u = s.charAt(i) - 'a';
            if (p.tns[u] == null) p.tns[u] = new TrieNode();
            p = p.tns[u]; 
        }
        p.end = true;
    }

    public boolean search(String s) {
        TrieNode p = root;
        for(int i = 0; i < s.length(); i++) {
            int u = s.charAt(i) - 'a';
            if (p.tns[u] == null) return false;
            p = p.tns[u]; 
        }
        return p.end;
    }

    public boolean startsWith(String s) {
        TrieNode p = root;
        for(int i = 0; i < s.length(); i++) {
            int u = s.charAt(i) - 'a';
            if (p.tns[u] == null) return false;
            p = p.tns[u]; 
        }
        return true;
    }
}

时间复杂度:Trie 树的每次调用时间复杂度取决于入参字符串的长度。复杂度为 O(Len)。
空间复杂度:结点数量为 n,字符集大小为 k。复杂度为 O(nk)。

2.3 两种方式的对比

使用「二维数组」的好处是写起来飞快,同时没有频繁 new对象的开销。但是需要根据数据结构范围估算我们的「二维数组」应该开多少行。

坏处是使用的空间通常是「TrieNode」方式的数倍,而且由于通常对行的估算会很大,导致使用的二维数组开得很大,如果这时候每次创建 Trie对象时都去创建数组的话,会比较慢,而且当样例多的时候甚至会触发 GC(因为 OJ每测试一个样例会创建一个 Trie对象)。

因此还有一个小技巧是将使用到的数组转为静态,然后利用 index自增的特性在初始化 Trie 时执行清理工作 & 重置逻辑。

这样的做法能够使评测时间降低一半,运气好的话可以得到一个「TrieNode」方式差不多的时间。

3、Trie 的应用

首先,在纯算法领域,前缀树算是一种较为常用的数据结构。

不过如果在工程中,不考虑前缀匹配的话,基本上使用 hash 就能满足。

如果考虑前缀匹配的话,工程也不会使用 Trie 。

一方面是字符集大小不好确定(题目只考虑 26 个字母,字符集大小限制在较小的 26 内)因此可以使用 Trie,但是工程一般兼容各种字符集,一旦字符集大小很大的话,Trie 将会带来很大的空间浪费。

另外,对于个别的超长字符 Trie 会进一步变深。

这时候如果 Trie 是存储在硬盘中,Trie 结构过深带来的影响是多次随机 IO,随机 IO 是成本很高的操作。

同时 Trie 的特殊结构,也会为分布式存储将会带来困难。

因此在工程领域中 Trie 的应用面不广。

至于一些诸如「联想输入」、「模糊匹配」、「全文检索」的典型场景在工程主要是通过 ES (ElasticSearch) 解决的。

而 ES 的实现则主要是依靠「倒排索引」。

posted @ 2022-09-19 08:56  王陸  阅读(78)  评论(0编辑  收藏  举报