🌟Trie
https://labuladong.github.io/algo/2/22/59/
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
———–
Trie 树又叫字典树、前缀树、单词查找树,是一种二叉树衍生出来的高级数据结构,主要应用场景是处理字符串前缀相关的操作。
我是在《算法 4》第一次学到这种数据结构,不过书中的讲解不是特别通俗易懂,所以本文按照我的逻辑帮大家重新梳理一遍 Trie 树的原理,并基于《算法 4》的代码实现一套更通用易懂的代码模板,用于处理力扣上一系列字符串前缀问题。
阅读本文之前希望你读过我旧文讲过的 回溯算法代码模板 和 手把手刷二叉树(总结篇)。
本文主要分三部分:
1、讲解 Trie 树的工作原理。
2、给出一套 TrieMap 和 TrieSet 的代码模板,实现几个常用 API。
3、实践环节,直接套代码模板秒杀 5 道算法题。本来可以秒杀七八道题,篇幅考虑,剩下的我集成到 刷题插件 中。
关于 Map 和 Set,是两个抽象数据结构(接口),Map 存储一个键值对集合,其中键不重复,Set 存储一个不重复的元素集合。
常见的 Map 和 Set 的底层实现原理有哈希表和二叉搜索树两种,比如 Java 的 HashMap/HashSet 和 C++ 的 unorderd_map/unordered_set 底层就是用哈希表实现,而 Java 的 TreeMap/TreeSet 和 C++ 的 map/set 底层使用红黑树这种自平衡 BST 实现的。
而本文实现的 TrieSet/TrieMap 底层则用 Trie 树这种结构来实现。
了解数据结构的读者应该知道,本质上 Set 可以视为一种特殊的 Map,Set 其实就是 Map 中的键。
所以本文先实现 TrieMap,然后在 TrieMap 的基础上封装出 TrieSet。
PS:为了模板通用性的考虑,后文会用到 Java 的泛型,也就是用尖括号
<>指定的类型变量。这些类型变量的作用是指定我们实现的容器中存储的数据类型,类比 Java 标准库的那些容器的用法应该不难理解。
前文 学习数据结构的框架思维 说过,各种乱七八糟的结构都是为了在「特定场景」下尽可能高效地进行增删查改。
你比如 HashMap<K, V> 的优势是能够在 O(1) 时间通过键查找对应的值,但要求键的类型 K 必须是「可哈希」的;而 TreeMap<K, V> 的特点是方便根据键的大小进行操作,但要求键的类型 K 必须是「可比较」的。
本文要实现的 TrieMap 也是类似的,由于 Trie 树原理,我们要求 TrieMap<V> 的键必须是字符串类型,值的类型 V 可以随意。
接下来我们了解一下 Trie 树的原理,看看为什么这种数据结构能够高效操作字符串。
Trie 树原理
Trie 树本质上就是一棵从二叉树衍生出来的多叉树。
二叉树节点的代码实现是这样:
其中 left, right 存储左右子节点的指针,所以二叉树的结构是这样:
多叉树节点的代码实现是这样:
其中 children 数组中存储指向孩子节点的指针,所以多叉树的结构是这样:
而 TrieMap 中的树节点 TrieNode 的代码实现是这样:
这个 val 字段存储键对应的值,children 数组存储指向子节点的指针。
但是和之前的普通多叉树节点不同,TrieNode 中 children 数组的索引是有意义的,代表键中的一个字符。
比如说 children[97] 如果非空,说明这里存储了一个字符 'a',因为 'a' 的 ASCII 码为 97。
我们的模板只考虑处理 ASCII 字符,所以 children 数组的大小设置为 256。不过这个可以根据具体问题修改,比如改成更小的数组或者 HashMap<Character, TrieNode> 都是一样的效果。
有了以上铺垫,Trie 树的结构是这样的:
一个节点有 256 个子节点指针,但大多数时候都是空的,可以省略掉不画,所以一般你看到的 Trie 树长这样:
这是在 TrieMap<Integer> 中插入一些键值对后的样子,白色节点代表 val 字段为空,橙色节点代表 val 字段非空。
这里要特别注意,TrieNode 节点本身只存储 val 字段,并没有一个字段来存储字符,字符是通过子节点在父节点的 children 数组中的索引确定的。
形象理解就是,Trie 树用「树枝」存储字符串(键),用「节点」存储字符串(键)对应的数据(值)。所以我在图中把字符标在树枝,键对应的值 val 标在节点上:
明白这一点很重要,有助于之后你理解代码实现。
PS:《算法 4》以及网上讲 Trie 树的文章中都是把字符标在节点上,我认为这样很容易让初学者产生误解,所以建议大家按照我的这种理解来记忆 Trie 树结构。
现在你应该知道为啥 Trie 树也叫前缀树了,因为其中的字符串共享前缀,相同前缀的字符串集中在 Trie 树中的一个子树上,给字符串的处理带来很大的便利。
首先我们看一下本文实现的TrieMap的 API,为了举例 API 的功能,假设 TrieMap 中已经存储了如下键值对:
至于TrieSet的 API 大同小异,所以这里不重复列举,后文直接给出实现。
接下来是重头戏,我们一个一个实现TrieMap的上述 API 函数。
首先,TrieMap类中一定需要记录 Trie 的根节点root,以及 Trie 树中的所有节点数量用于实现size()方法:
另外,我们再实现一个工具函数getNode:
有了这个getNode函数,就能实现containsKey方法和get方法了:
这里需要注意,就算getNode(key)的返回值x非空,也只能说字符串key是一个「前缀」;除非x.val同时非空,才能判断键key存在。
不过,这个特性恰好能够帮我们实现hasKeyWithPrefix方法:
类似getNode方法的逻辑,我们可以实现shortestPrefixOf方法,只要在第一次遇到存有val的节点的时候返回就行了:
这里需要注意的是 for 循环结束之后我们还需要额外检查一下。
因为之前说了 Trie 树中「树枝」存储字符串,「节点」存储字符串对应的值,for 循环相当于只遍历了「树枝」,但漏掉了最后一个「节点」,即query本身就是TrieMap中的一个键的情况。
如果你理解了shortestPrefixOf的实现,那么longestPrefixOf也是非常类似的:
每次遇到p.val非空的时候说明找到一个键,但是我们不急着返回,而是更新max_len变量,记录最长前缀的长度。
同样的,在 for 循环结束时还是要特殊判断一下,处理query本身就是键的情况。
接下来,我们来实现keysWithPrefix方法,得到所有前缀为prefix的键。
看过前文 手把手刷二叉树(总结篇) 的读者应该可以想到,先利用getNode函数在 Trie 树中找到prefix对应的节点x,然施展多叉树的遍历算法,遍历以x为根的这棵 Trie 树,找到所有键值对:
代码实现如下:
这段代码中traverse函数你可能看起来特别熟悉,就是 回溯算法核心套路 中讲的回溯算法代码框架。
关于回溯算法框架和标准多叉树框架的区别我在 图论算法基础 中探讨过,关键在于遍历「节点」和遍历「树枝」的区别。由于 Trie 树将字符存储在「树枝」上,traverse函数是在遍历树枝上的字符,所以采用的是回溯算法框架。
另外,再注意一下这段逻辑:
回顾一下我们 Trie 树的图:
你是否会有疑问:代码中 for 循环会执行 256 次,但是图中的一个节点只有几个子节点,也就是说每个节点的children数组中大部分都是空指针,这不会有问题吗?
是不是应该把代码改成这样:
答案是,改不改都行,这两种写法从逻辑上讲完全相同,因为traverse函数开始的时候如果发现node == null也会直接返回。
我为了保持框架的一致性,就没有在 for 循环中判断子节点是否为空,而是依赖递归函数的 base case。当然你完全可以按照自己的喜好来实现。
下面来实现keysWithPattern方法,使用通配符来匹配多个键,其关键就在于通配符.可以匹配所有字符。
在代码实现上,用path变量记录匹配键的路径,遇到通配符时使用类似回溯算法的框架就行了:
可以看到,keysWithPattern和keysWithPrefix的实现是有些类似的,而且这两个函数还有一个潜在的特性:它们返回的结果列表一定是符合「字典序」的。
原因应该不难理解,每一个节点的children数组都是从左到右进行遍历,即按照 ASCII 码从小到大的顺序递归遍历,得到的结果自然是符合字典序的。
好,现在我们实现了keysWithPattern方法得到模式串匹配的所有键,那你是否可以实现hasKeyWithPattern方法,仅仅判断是否存在键匹配模式串?
这是一个偷懒的实现,因为它的复杂度比较高。我们的目的仅仅是判断是否存在匹配模式串的键,你却把所有匹配的键都算出来了,这显然是没有必要的。
我们只需稍微改写一下keysWithPattern方法就可以高效实现hasKeyWithPattern方法:
有之前的铺垫,这个实现应该是不难理解的,类似于 回溯算法解数独游戏 中找到一个可行解就提前结束递归的做法。
到这里,TrieMap的所有和前缀相关的方法都实现完了,还剩下put和remove这两个基本方法了,其实它们的难度不大,就是递归修改数据结构的那一套,如果不熟悉的话可以参见 二叉搜索树基本操作。
先说put方法的实现吧,直接看代码:
因为是递归修改数据结构,所以我们必须额外创建一个返回类型为TrieNode的辅助函数,并且在递归调用的时候接收其返回值,拼接到父节点上。
前文说了,Trie 树中的键就是「树枝」,值就是「节点」,所以插入的逻辑就是沿路新建「树枝」,把key的整条「树枝」构建出来之后,在树枝末端的「节点」中存储val:
最后,我们说一下remove函数,似乎所有数据结构的删除操作相对其他操作都会更复杂一些。
比如说下图这个场景,如果你想删除键"team",那么需要删掉"eam"这条树枝才是符合逻辑的:
删多了肯定不行,但删少了也不行,否则前文实现的hasKeyWithPrefix就会出错。
那么如何控制算法来正确地进行删除呢?
首先,递归修改数据结构的时候,如果一个节点想删掉自己,直接返回空指针就行了。
其次,一个节点如何知道自己是否需要被删除呢?主要看自己的val字段是否为空以及自己的children数组是否全都是空指针。
这里就要利用前文 手把手刷二叉树(总结篇) 中说到的后序位置的特点了:
一个节点要先递归处理子树,然后在后序位置检查自己的val字段和children列表,判断自己是否需要被删除。
如果自己的val字段为空,说明自己没有存储值,如果同时自己的children数组全是空指针,说明自己下面也没有接树枝,即不是任何一个键的前缀。这种情况下这个节点就没有存在的意义了,应该删掉自己。
直接看代码:
到这里,TrieMap的所有 API 就实现完了,完整代码如下:
接下来我们只要对TrieMap做简单的封装,即可实现TrieSet:
有了TrieMap和TrieSet,力扣上所有前缀树相关的题目都可以直接套用了,下面我举几个题目实践一下。
秒杀题目
首先需要说明,上文实现的算法模板的执行效率在具体的题目里面肯定是有优化空间的。
比如力扣前缀树相关题目的输入都被限制在小写英文字母a-z,所以TrieNode其实不用维护一个大小为 256 的children数组,大小设置为 26 就够了,可以减小时间和空间上的复杂度。
不过本文只考虑模板的通用性,重在思路,所以就直接套用上文给出的算法模板解题,具体实现上的细节优化我集成在 刷题插件 的「思路」按钮中。
先看下力扣第 208 题「实现前缀树」:
题目让我们实现的几个函数其实就是TrieSet的部分 API,所以直接封装一个TrieSet就能解决这道题了:
接下来看下力扣第 648 题「单词替换」:
现在你学过 Trie 树结构,应该可以看出来这题就在考察最短前缀问题。
所以可以把输入的词根列表dict存入TrieSet,然后直接复用我们实现的shortestPrefixOf函数就行了:
继续看力扣第 211 题「添加与搜索单词 - 数据结构设计」:
这道题的考点就在于这个search函数进行通配符匹配,其实就是我们给TrieSet实现的hasKeyWithPattern方法,直接套就行了:
上面列举的这几道题用的都是TrieSet,下面来看看TrieMap的题目。
先看力扣第 1804 题「实现前缀树 II」:
这题就可以用到TrieMap,每个插入的word就是键,插入的次数就是对应的值,然后复用TrieMap的 API 就能实现题目要求的这些函数:
反正都是直接套模板,也没什么意思,再看最后一道题目吧,这是力扣第 677 题「键值映射」:
这道题还是标准的TrieMap的应用,直接看代码吧:
Trie 树这种数据结构的实现原理和题目实践就讲完了,如果你能够看到这里,真得给你鼓掌。
可以看到,我最近写的图论算法以及本文讲的 Trie 树算法,都和「树」这种基本数据结构相关,所以我才会在刷题插件中集成 手把手刷 100 道二叉树题目 的功能。
最后,纸上得来终觉浅,绝知此事要躬行,我建议最好亲手实现一遍上面的代码,去把题目刷一遍,才能对 Trie 树有更深入的理解。
之后我准备继续讲解一些基本数据结构在高级数据结构或实际算法题中的应用,大家期待就好。
Last updated
Was this helpful?




