手绘图系列 06 | 您一上Google就能接触到的Tries

手绘图手撕数据结构与算法系列目录:
手绘图系列 01 | 链表到底是什么?
手绘图系列 02 | 让人害怕的栈溢出
手绘图系列 03 | 排队?还是不排队?
手绘图系列 04 | 使用频率最高的”树“
手绘图系列 05 | 元素减半,快乐加倍的二叉搜索树

在学习数据结构时,我们通常会关注每种数据结构的优缺点,这有助于我们理解每种数据结构是为了解决什么问题而设计的。同样,在学习排序算法时,我们会关注时间和空间效率之间的权衡,以了解在什么情况下一种算法比另一种更适合。

实际上,很多高级的数据结构往往是基于我们已经熟悉的基本数据结构设计的,它们设计目的是为了解决某个特定的问题,通常是为了在时间和空间效率之间找到一个平衡点。

今天我们要探讨的字典树Tries就是这样一种数据结构。

尝试使用字典树

在计算机中,存储一组单词有多种方法,比如使用哈希表或字典。

然而,还有一种很特别的数据结构,叫做字典树(Trie),专门用于表示和检索单词。术语“trie”源自法语单词“retrie”,通常发音为“try”,以区别于其他树状结构。

字典树是一种树形数据结构,其中的每个节点存储字母表中的一个字母。通过以特定方式构建这些节点,我们可以遍历树的路径来高效地检索单词和字符串。

字典树的概念在计算机科学中相对较新。它首次被提出是在1959年,由法国人 René de la Briandais 引入。

使用字典树的初衷,是在运行时间和内存使用之间找到一个良好的平衡。不过,我们稍后再详细讨论这个问题。首先,我们来了解一下字典树的基本结构和工作原理。

字典树通常用于表示字母表中的单词。以下通过一个示例图来解释其具体工作方式。

每个字典树(trie)都有一个空的根节点,根节点包含指向其他节点的引用,除了根节点外,字典树中的每个节点都代表字母表中的一个字母。

需要注意的是,字典树中每个节点的子节点数量取决于可能的字母总数。例如,在表示英文字母表时,由于有 26 个字母(不区分大小写),因此根节点将有 26 个子节点。

如果我们使用字典树来表示高棉语(柬埔寨语)的字母表,该字母表包含 74 个字符,那么根节点将有 74 个子节点,分别指向每一个字符。

因此,根据包含的内容,字典树可能很小或很大。但是,到目前为止,我们讨论的都是根节点,它是空的——根节点本身不存储任何具体的字母或单词信息,它仅仅是一个起始点,用于链接到其他节点。

接下来让我们仔细看看 trie 中其它节点是什么样子的,

在上图中,我们看到一个字典树(Trie),其根节点是空的,包含对子节点的引用。如果我们查看其中一个子节点,我们会发现每个节点仅包含两个部分:

  1. 一个值,可能是null
  2. 对子节点的引用数组,其中的引用也可能是 null。

字典树中的每个节点(包括根节点本身)都只有这两部分。

创建一个表示英语单词的字典树时,它从一个根节点开始,该节点的值通常为空字符串 “”。

这个根节点拥有一个包含 26 个引用的数组,所有这些引用最初都指向 null。随着字典树的增长,这些指针会逐渐被实际节点的引用填满,我们很快就会看到一个具体的例子。

这些引用的表示方式也很有趣。每个节点都包含一个指向其他节点的引用数组。我们可以利用数组的索引来找到特定的节点引用。例如,根节点将保存一个包含 26 个插槽的数组,因为字母表有 26 个字母。并且字母表是按顺序排列的,因此表示字母 A 的节点的引用会存储在数组中索引位置 0处。

现在,我们已经创建了根节点,接下来该如何继续构建字典树呢?是时候扩展我们的字典树了!

字典树的操作

只有根节点的 字典树 根本没什么用!为了使它更有意义,我们可以通过添加实际的单词来扩展它。比如,我们可以将“Peter Piper picked a peck of pickled peppers”这句童谣的每个单词添加到字典树中。

为了简化查看,上图中只绘制了包含实际节点的引用。注意,虽然插图中没有显示,但每个节点实际上都有 26 个指向可能子节点的引用。

在这个字典树中,我们可以看到六个不同的“分支”,每个分支表示一个单词。我们还可以看到一些单词共享父节点,例如,Peter、peck 和 peppers 这三个单词的路径共享了字母 p 和 e 的节点。同样,单词 picked 和 pickled 的路径共享 p、i、c 和 k 节点。

如果我们想将单词 pecked 添加到这个字典树中,我们需要执行以下两个步骤:

  1. 检查单词是否已经存在: 首先,我们需要验证字典树中是否已经包含单词 pecked。

  2. 插入新单词: 如果单词 pecked 不存在,我们需要遍历当前路径,直到找到应该插入新字母的位置。然后,我们在该位置插入字母 e 和 d。

我们如何检查一个单词是否存在于字典树中?

我们又如何将字母插入到正确的位置?

为了更清楚地理解这些操作,我们可以从一个空的字典树开始,尝试在其中插入一些内容。

首先,我们有一个空的根节点,其值为空字符串 “”,并且它有一个包含 26 个引用的数组,所有这些引用最初都是 null。

假设我们要插入单词 “pie”,并为它分配一个值 5。你可以将这个操作看作是在一个哈希表中插入一个条目:{ “pie”: 5 }。

首先,我们从根节点开始寻找字母 ‘p’ 的指针,因为这是我们键 “pie” 中的第一个字母。由于字典树目前为空,因此根节点中的 ‘p’ 的引用是 null。我们会为 ‘p’ 创建一个新节点,并更新根节点的引用数组,使其包含 25 个空槽和 1 个指向 ‘p’ 节点的槽,索引位置为 15。

接下来,我们处理 ‘p’ 节点,将其下的引用数组检查下一个字母 ‘i’。由于 ‘i’ 的引用也是 null,我们为 ‘i’ 创建一个新节点,并将其链接到 ‘p’ 节点的相应位置。

然后,我们处理最后一个字母 ‘e’。同样,我们检查 ‘i’ 节点的引用数组,发现 ‘e’ 的位置也是 null,因此我们为 ‘e’ 创建一个新节点,并在该节点中设置值 5,这是我们希望与键 “pie” 关联的数据。

今后,当我们想要检索键 “pie” 的值时,我们会从根节点开始,沿着数组逐步遍历。我们使用索引找到 ‘p’ 节点,然后是 ‘i’ 节点,最后到 ‘e’ 节点。当我们到达 ‘e’ 节点时,我们将停止遍历,并从该节点检索存储的值,即 5。

让我们详细介绍一下如何在新建的字典树中进行搜索。

假设我们要搜索键 “pie”。在这种情况下,我们会从根节点开始,沿着数组遍历,按照路径 ‘p’ → ‘i’ → ‘e’。如果这些路径上的每个节点都存在,并且到达的最后一个节点有一个对应的值,我们就可以直接返回这个值。这种情况被称为“搜索命中”,因为我们成功找到了键对应的值。

但是,如果我们搜索一个不在字典树中的键,例如 “pi”,会发生什么呢?我们会从根节点开始,找到 ‘p’ 节点,然后继续到 ‘i’ 节点。当我们到达 ‘i’ 节点时,我们会检查节点i是否有值。如果没有值(,这意味着键 “pi” 不存在于字典树中,这种情况被称为“搜索未命中”。

最后,我们可能还需要在字典树中执行删除操作。比如,我们已经在字典树中添加了两个键:“pie” 和 “pies”,每个键都有其对应的值。如果我们想删除键 “pies”,我们需要从字典树中移除这个键和其对应的值。

为了从字典树中删除一个键,我们需要执行两个主要步骤:

  1. 找到并重置值: 首先,我们需要定位包含该键的节点,并将其值设置为 null。这意味着我们要沿着字典树的路径遍历,直到找到键 “pies” 的最后一个字母节点,然后将这个节点的值从 12 重置为 null。

  2. 检查并删除节点:接下来,我们要检查该节点是否仍然被其他节点引用。如果没有其他节点指向它,则可以安全地删除该节点。如果有其他指向节点的引用存在,那么我们不能删除这个节点,因为它可能被其他键使用。

最后的检查尤其重要,因为我们不希望删除那些可能在其他键中作为前缀的节点。除了这个检查,删除操作就没有其他复杂的步骤了。

字典树的优势和劣势

当我第一次学习字典树时,让我联想到我们之前讨论过的哈希表。实际上,随着我对字典树的构建和搜索方法了解得更深入,我开始思考这两种数据结构之间的权衡。

事实上,字典树和哈希表都涉及数组的使用,但它们的实现方式不同。哈希表通常使用数组与链表的组合来处理冲突,而字典树则使用数组与指针/引用的组合。

这两者之间还有许多细微的差别。最明显的区别是,字典树不需要哈希函数,因为每个键可以按照字母顺序唯一表示,并且是唯一可检索的。这样,字典树可以依赖数组的索引而不需要处理哈希冲突。

然而,字典树也有其缺点,其中一个主要问题是大量空指针的存在,这会占用大量内存。随着节点的增加,字典树的大小会显著增长,而每个节点都需要一个包含 26 个空指针的数组。对于长单词来说,很多这些空指针可能永远不会被填充。

例如,假设我们有一个单词“Honorificabilitudinitatibus”,这个超长单词的每个字母都会有许多空指针,这些指针虽然占用了内存,但实际上从未被使用过。

尽管字典树(trie)有其缺点,但它也有许多优点。首先,大部分工作在创建 trie 时就完成了。在最初添加节点时,我们需要为每个节点分配内存,这确实是一个繁重的任务。然而,随着 trie 的增长,添加新值时所需的工作量会减少,因为许多节点及其值和引用已经被初始化,一旦 trie 的结构建立起来,插入新的“中间节点”就变得相对简单。

字典树的另一个“优点”是,每次添加单词时,我们只需检查节点数组中的 26 个可能的索引,因为英文字母表只有 26 个字母。尽管 26 看起来很多,但对于计算机来说,这并不是占用太多空间。

从时间复杂度的角度来看,创建 trie 的时间复杂度与 trie 中包含的单词数量以及单词的长度直接相关。最坏情况下,创建 trie 所需的时间是由单词的长度 m 和单词总数 n 的乘积决定的。因此,创建 trie 的最坏情况时间复杂度是 O(m×n)。

在字典树(trie)中,搜索、插入和删除操作的时间复杂度取决于要处理的单词的长度 a 以及字典树中的单词总数 n。因此,这些操作的时间复杂度为 O(a×n)。显然,对于字典树中最长的单词,这些操作所需的时间和内存会比处理最短单词时更多。

字典树的应用

虽然字典树的内部工作原理已经很清楚,但另一个问题是:字典树通常用于哪些场景?实际上,字典树很少单独使用,它们通常与其他数据结构结合使用,或者在特定算法中发挥作用。

其中一个最有趣的应用是搜索提示词功能,这种功能广泛应用于搜索引擎(如 Google)的搜索框中。在这些场景中,字典树的结构和功能被用来实现高效的搜索词提示建议。

值得注意的是,搜索引擎中的字典树可能会更加复杂,它们不仅仅使用字典树来检索结果,还会根据术语的流行程度对结果进行排序,并可能在字典树的结构中引入额外的逻辑来计算相关权重。

除了搜索功能,字典树还可以用于匹配算法、实现拼写检查器,甚至可以用来实现基数排序。

可以说,字典树在我们的生活中无处不在,当然挑战也随处可见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值