【图解 Trie】两种实现 Tire 的方式及其优缺点对比分析|Java 刷题打卡

简介: 【图解 Trie】两种实现 Tire 的方式及其优缺点对比分析|Java 刷题打卡

题目描述



这是 LeetCode 上的208. 实现 Trie (前缀树),难度为 Medium


Trie(发音类似 "try")或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。


这一数据结构有相当多的应用情景,例如自动补完和拼写检查。


请你实现 Trie 类:


  • Trie() 初始化前缀树对象。
  • void insert(String word) 向前缀树中插入字符串 word 。
  • boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false 。
  • boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true ;否则,返回 false 。

 

示例:


输入
["Trie", "insert", "search", "search", "startsWith", "insert", "search"]
[[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]
输出
[null, null, true, false, true, null, true]
解释
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
复制代码


提示:


  • 1 <= word.length, prefix.length <= 2000
  • word 和 prefix 仅由小写英文字母组成
  • insert、search 和 startsWith 调用次数 总计 不超过 3 * 10410^4104


Trie 树



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


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


网络异常,图片无法展示
|


二维数组



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


  • 使用二维数组 trie[]trie[]trie[] 来存储我们所有的单词字符。
  • 使用 indexindexindex 来自增记录我们到底用了多少个格子(相当于给被用到格子进行编号)。
  • 使用 count[]count[]count[] 数组记录某个格子被「被标记为结尾的次数」(当 idxidxidx 编号的格子被标记了 nnn 次,则有 cnt[idx]=ncnt[idx] = ncnt[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;
    }
}
复制代码


  • 时间复杂度:TrieTrieTrie 树的每次调用时间复杂度取决于入参字符串的长度。复杂度为 O(Len)O(Len)O(Len)
  • 空间复杂度:二维数组的高度为 nnn,字符集大小为 kkk。复杂度为 O(nk)O(nk)O(nk)


TrieNode



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


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


代码:


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;
    }
}
复制代码


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


两种方式的对比



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


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


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


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


class Trie {
    // 以下 static 成员独一份,被创建的多个 Trie 共用
    static int N = 100009; // 直接设置为十万级
    static int[][] trie = new int[N][26];
    static int[] count = new int[N];
    static int index = 0;
    // 在构造方法中完成重置 static 成员数组的操作
    // 这样做的目的是为减少 new 操作(无论有多少测试数据,上述 static 成员只会被 new 一次)
    public Trie() {
        for (int row = index; row >= 0; row--) {
            Arrays.fill(trie[row], 0);
        }
        Arrays.fill(count, 0);
        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;
    }
}
复制代码


关于「二维数组」是如何工作 & 1e5 大小的估算



要搞懂为什么行数估算是 1e5,首先要搞清楚「二维数组」是如何工作的。


在「二维数组」中,我们是通过 indexindexindex 自增来控制使用了多少行的。


当我们有一个新的字符需要记录,我们会将 indexindexindex 自增(代表用到了新的一行),然后将这新行的下标记录到当前某个前缀的格子中。


举个🌰,假设我们先插入字符串 abc 这时候,前面三行会被占掉。


  • 第 0 行 a 所对应的下标有值,值为 1,代表前缀 a 后面接的字符串会被记录在下标为 1 的行内
  • 第 1 行 b 所对应的下标有值,值为 2,代表前缀 ab 后面接的字符串会被记录在下标为 2 的行内
  • 第 2 行 c 所对应的下标有值,值为 3,代表前缀 abc 后面接的字符串会被记录在下标为 3 的行内


当再插入 abcl 的时候,这时候会先定位到 abl 的前缀行(第 3 行),将 l 的下标更新为 4,代表 abcl 被加入前缀树,并且前缀 abcl 接下来会用到第 4 行进行记录。


但当插入 abl 的时候,则会定位到 ab 的前缀行(第 2 行),然后将 l 的下标更新为 5,代表 abl 被加入前缀树,并且前缀 abl 接下来会使用第 5 行进行记录。


当搞清楚了「二维数组」是如何工作之后,我们就能开始估算会用到多少行了,调用次数为 10410^4104,传入的字符串长度为 10310^3103,假设每一次的调用都是 insertinsertinsert,并且每一次调用都会使用到新的 10310^3103 行。那么我们的行数需要开到 10710^7107


但由于我们的字符集大小只有 26,因此不太可能在 10410^4104 次调用中都用到新的 10310^3103 行。


而且正常的测试数据应该是 searchsearchsearchstartsWithstartsWithstartsWith 调用次数大于 insertinsertinsert 才有意义的,一个只有 insertinsertinsert 调用的测试数据,任何实现方案都能 AC。


因此我设定了 10510^5105 为行数估算,当然直接开到 10610^6106 也没有问题。


关于 Trie 的应用面



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


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


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


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


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


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


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


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


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


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


最后



这是我们「刷穿 LeetCode」系列文章的第 No.208 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先将所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

相关文章
|
2月前
|
存储 Java 大数据
Java 大视界 -- Java 大数据在智能家居能源消耗模式分析与节能策略制定中的应用(198)
简介:本文探讨Java大数据技术在智能家居能源消耗分析与节能策略中的应用。通过数据采集、存储与智能分析,构建能耗模型,挖掘用电模式,制定设备调度策略,实现节能目标。结合实际案例,展示Java大数据在智能家居节能中的关键作用。
|
3月前
|
数据采集 搜索推荐 算法
Java 大视界 -- Java 大数据在智能教育学习社区用户互动分析与社区活跃度提升中的应用(274)
本文系统阐述 Java 大数据技术在智能教育学习社区中的深度应用,涵盖数据采集架构、核心分析算法、活跃度提升策略及前沿技术探索,为教育数字化转型提供完整技术解决方案。
|
3月前
|
Java 数据库连接 API
互联网大厂校招 JAVA 工程师笔试题解析及常见考点分析
本文深入解析互联网大厂校招Java工程师笔试题,涵盖基础知识(数据类型、流程控制)、面向对象编程(类与对象、继承与多态)、数据结构与算法(数组、链表、排序算法)、异常处理、集合框架、Java 8+新特性(Lambda表达式、Stream API)、多线程与并发、IO与NIO、数据库操作(JDBC、ORM框架MyBatis)及Spring框架基础(IoC、DI、AOP)。通过技术方案讲解与实例演示,助你掌握核心考点,提升解题能力。
159 2
|
4月前
|
人工智能 Java
Java参数传递分析
本文详细探讨了Java中参数传递的机制,明确指出Java采用的是值传递而非引用传递。通过基本数据类型(如int)和引用类型(如Map、自定义对象People)的实例测试,证明方法内部对参数的修改不会影响原始变量。即使在涉及赋值返回的操作中,表面上看似引用传递,实际仍是值传递的结果。文中结合代码示例与执行结果,深入解析了值传递的本质及容易引起混淆的情形,帮助读者准确理解Java参数传递的核心概念。
|
传感器 分布式计算 安全
Java 大视界 -- Java 大数据在智能安防入侵检测系统中的多源数据融合与分析技术(171)
本文围绕 Java 大数据在智能安防入侵检测系统中的应用展开,剖析系统现状与挑战,阐释多源数据融合及分析技术,结合案例与代码给出实操方案,提升入侵检测效能。
|
4月前
|
缓存 安全 Java
【高薪程序员必看】万字长文拆解Java并发编程!(3-1):并发共享问题的解决与分析
活锁:多个线程相互影响对方退出同步代码块的条件而导致线程一直运行的情况。例如,线程1的退出条件是count=5,而线程2和线程3在其代码块中不断地是count进行自增自减的操作,导致线程1永远运行。内存一致性问题:由于JIT即时编译器对缓存的优化和指令重排等造成的内存可见性和有序性问题,可以通过synchronized,volatile,并发集合类等机制来解决。这里的线程安全是指,多个线程调用它们同一个实例的方法时,是线程安全的,但仅仅能保证当前调用的方法是线程安全的,不同方法之间是线程不安全的。
86 0
|
4月前
|
Java 程序员
【高薪程序员必看】万字长文拆解Java并发编程!(3-2):并发共享问题的解决与分析
wait方法和notify方法都是Object类的方法:让当前获取锁的线程进入waiting状态,并进入waitlist队列:让当前获取锁的线程进入waiting状态,并进入waitlist队列,等待n秒后自动唤醒:在waitlist队列中挑一个线程唤醒:唤醒所有在waitlist队列中的线程它们都是之间协作的手段,只有拥有对象锁的线程才能调用这些方法,否则会出现IllegalMonitorStateException异常park方法和unpark方法是LockSupport类中的方法。
88 0
|
2月前
|
安全 算法 Java
Java 多线程:线程安全与同步控制的深度解析
本文介绍了 Java 多线程开发的关键技术,涵盖线程的创建与启动、线程安全问题及其解决方案,包括 synchronized 关键字、原子类和线程间通信机制。通过示例代码讲解了多线程编程中的常见问题与优化方法,帮助开发者提升程序性能与稳定性。
124 0
|
2月前
|
Java API 调度
从阻塞到畅通:Java虚拟线程开启并发新纪元
从阻塞到畅通:Java虚拟线程开启并发新纪元
282 83

热门文章

最新文章