手摸手提桶跑路——LeetCode208.实现Trie(前缀树)

175 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第22天,点击查看活动详情

题目描述

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

什么是前缀树?

题目中提到“前缀树是一种树形数据结构,用于 高效地存储和检索 字符串数据集中的键”。

为啥前缀树它高效呢?接下来跟我来一探究竟。

我们先来看看广义的 Trie 树长什么样。

假设有字符串数组 ['cat', 'watermelon', 'catwatermelon'],生成的 Trie 树长这样:

Trie.png

图1. 字符串数组生成的最终Trie树

创建 Trie 树 & 插入

我们需要记住的是,因为这边都是小写字母,所以前缀树的每个节点都要存储26个字母对应的信息,js 中可以用对象或者数组进行存储。

如果是用数组进行存储,那么 数组中的每个元素要么为空,表示没有任何字符串包含该下标表示的字母,要么为一个 Trie 节点。Trie 节点包含有存储26个字母的数据结构及一个判断字符串是否结束的变量 isEnd。这里还需要注意的是,如果是用数组进行存储的话,字母的ASCII-字母a的ASCII-1 得到的值就是对应字母在数组中的下标。

这里演示一下生成一颗前缀树的过程:

  1. 首先创建一个 Trie 节点作为根节点。
var Trie = function() {
    this.children = new Array(26);
    this.isEnd = false;
};
  1. 遍历字符串数组,对于字符串 cat,我们对其进行遍历。
  2. 首先是字母 c,我们去根节点的数组中找其对应的下标是否为空,如果为空, 则在该位置创建一个 Trie 节点,表示当前路径中字母 c 是存在的;如果不为空,则说明之前已经存储过前缀为 c 的字符串了,我们应该跳到字母 c 对应下标的 Trie 节点的数组去遍历是否存在 cat 的下一个字母 a(有点嵌套的意思,数组中的数组)。
  3. 这里我们是第一次创建,肯定为空的,所以我们应该在根节点数组里字母 c 对应下标的位置创建一个 Trie 节点,然后从这个新 Trie 节点的数组中去查找字母 a 对应下标是否为空,这里肯定为空,所以我们在对应下标创建一个新的 Trie 节点,然后再去该新的 Trie 节点的数组里查看字母 t 对应下标是否为空,这里肯定为空,所以创建一个新的 Trie 节点,同时字符串 cat 遍历结束,所以最后这个 Trie 节点的 isEnd 变量应该置为 true

经过这么一番操作下来,生成的前缀树长这样:

1.png

同理,字符串数组中剩余的字符串执行 2-4 步骤后,最终的 Trie 数就如 图1 所示。

查找 Trie 树

接下来我们演示查找一个 Trie 树的过程。

假设我们已经有了 ['cat', 'watermelon', 'catwatermelon'] 对应的 Trie 树。

查找的过程就是一个层层查询的过程。

拿查找字符串 catwatermelon 来说,我们对该字符串进行遍历。

Trie.prototype.search = function(word) {
    let children = this.children;
    for(let w of prefix) {
        const index = w.charCodeAt()-'a'.charCodeAt();
        if(children[index] == void 0) {
            return false;
        }
        children = children[index].children;
    }
    if(children && children.isEnd) {
        return true;
    } 
    return false;
};

可以看到,如果为空,则查找失败,返回 false,否则去该字符对应下标的数组中去查找下一个字符。只有当被查找字符串正常遍历结束且 isEnd变量为 true 时才说明当前 Trie中存在该字符串。

为什么要强调 isEnd 遍历要为 true 呢?

因为同个前缀的单词有很多,单词 ending 存在的 Trie 树,我们查 end 也是可以找到的,但是 end 严格来说并不是我们存的单词,所以需要一个 isEnd 标记当前是否为单词结尾。

插入

Trie.prototype.insert = function(word) {
    let children = this.children;
    for(let w of word) {
        const index = w.charCodeAt()-'a'.charCodeAt();
        if(children[index] == void 0) {
            children[index] = new Trie();
        }
        children = children[index].children;
    }
    children.isEnd = true;
};

题解

var Trie = function() {
    this.data = {};
};

/** 
 * @param {string} word
 * @return {void}
 */
Trie.prototype.insert = function(word) {
    let r = this.data;
    for (const w of word) {
        if (r[w] === void 0) {
            r[w] = {};
        }
        r = r[w];
    }
    r.isEnd = true;
};

/** 
 * @param {string} word
 * @return {boolean}
 */
Trie.prototype.search = function(word) {
    const t = this.startsWith(word);
    if(t && t.isEnd) {
        return true;
    } 
    return false;
};

/** 
 * @param {string} prefix
 * @return {boolean}
 */
Trie.prototype.startsWith = function(prefix) {
    let r = this.data;
    for(let w of prefix) {
        if(r[w] == void 0) {
            return false;
        }
        r = r[w];
    }
    return r;
};

微信截图_20220818003657.png