听风是风

学或不学,知识都在那里,只增不减。

导航

JS Leetcode 208. 实现 Trie (前缀树) 题解分析,第一次了解前缀树(字典树)

壹 ❀ 引

本题来自LeetCode 208. 实现 Trie (前缀树),难度中等,题目描述如下:

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 * 104 次

让我们提取下题目信息,简单分析题意,然后实现它。

贰 ❀ 借用数组API的暴力做法

题目本意是想让我们通过类实现出一种名叫前缀树的数据结构,并通过类附带的一些方法,实现字符串插入,字符串搜索,以及字符串前缀检查相关功能,综合来说就是下面三个方法(都需要你自己来实现它):

  1. 前缀树会附带一个insert方法,调用此方法可以像前缀树结构中插入一个字符。
  2. 前缀树会附带一个search方法,调用此方法可以在前缀树中检索是否存在某个字符。
  3. 前缀树会附带一个startWith方法,调用此方法可以检索前缀树中是否有某个字符串的开头等于一个提供的字符串。

需要注意的是第三条,题目原话是如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true,我理解的之前已经插入是最后插入的单词,其实题目指的是之前所有插入过的单词,然后查找有没有符合条件的字符串。

OK,我们分析完题目要求,老实说,我第一想到的是借用数组API,比如插入我们可以在数组头部插入,查找可以使用indexOf,而检索字符串前缀同样可以借用startsWith方法,直接上代码:

/**
 * Initialize your data structure here.
 */
var Trie = function () {
    this.trie = [];
};

/**
 * Inserts a word into the trie. 
 * @param {string} word
 * @return {void}
 */
Trie.prototype.insert = function (word) {
    this.trie.unshift(word)
};

/**
 * Returns if the word is in the trie. 
 * @param {string} word
 * @return {boolean}
 */
Trie.prototype.search = function (word) {
    return this.trie.indexOf(word) > -1;
};

/**
 * Returns if there is any word in the trie that starts with the given prefix. 
 * @param {string} prefix
 * @return {boolean}
 */
Trie.prototype.startsWith = function (prefix) {
    if (this.trie.length > 0) {
        return this.trie.some(s => s.startsWith(prefix))
    };
    return false;
};

这里的代码就不解释什么了,因为比较简单,直接按照题目的意思去调用对应API即可。虽然可以实现,但很显然,这并不是什么好的做法。

贰 ❀ 字典树(前缀树)

根据题意来说,前缀树是一种高效存储和检索字符串的数据结构,但对于数组而言,按下标访问时间复杂度为O(1),但如果不知道下标是去访问想要的元素,时间复杂度直接到了O(n)了,因为运气不好可能最后一个元素才是我们想要的,自然不适合来实现前缀树。

那么既然要实现前缀树,我们总得知道这是个啥玩意,比如我之前完全就没听说过....

又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。

它有3个基本性质:

根节点不包含字符,除根节点外到每个节点的路径都只包含一个字符; 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串; 每个节点的所有子节点包含的字符都不相同。-----百度百科

我们提炼下信息,根节点不包含字符,从根节点到子节点的边都只包含一个字符,每个节点紧挨着的子节点的边所包含的字符都不相同(相同就复用了)。其次,前缀树的节点还用于记录是否为一个字符串的结尾。

比如上图中存在的字符串有hoosbbchehelheg,红色表示一个单词的结尾,也就是说从根节点到这个红色节点的边是一个完整的需要记录的字符串,比如像hehel它们部分前缀相同,这里就起到了复用的目的。

很明显,如果我们记录的是小写字符串,从根节点出发的第一条路径就有a-z一共26种可能,之后每个节点如果还有子节点同理,当然如果我们记录的是数字那就是0-910中可能。

抽象点来理解,当我们要记录一个字符串,第一个字符可能是26个字母中的其中一个,如果当前路径没创建,我们就创建好这条路径,如果这个字符串恰好就只有一个字母,那我们还得在这个子节点上做个标记,表示到这为止有个完整的字符串。

而当记录其它字符串时,我们还是一样的操作,第一个字母我们得看看之前有创建相同的路径吗?如果有就不要反复创建,如果没有就额外单独创建一条,大概就是这么个思路了。

OK,为了达到更高效的访问查找效率,所以这里肯定是得借助字典(对象)以Key(a-z)的形式来记录字符。比如我们如果要记录一个单词是app,那么它的结果应该是这样:

let dic = {
    a:{
        p:{
            p:{
                end:true
            }
        }
    }
}

让我们来尝试实现它,这段逻辑会比较绕,可能需要大家自己断点理解下:

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

Trie.prototype.insert = function(word) {
    // 这里比较巧妙,耐心点看
    // 浅拷贝
    var dic = this.dic
    // 遍历要插入的字符串的每个字符
    for (var w of word) {
        // 如果之前没创建过,那就以此字符创建成一个空对象
        if(!dic[w]){
            // 为对象添加属性,因为是浅拷贝,所以也会影响到this.dic
            dic[w] = {}
        };
        // 取到这个字符的属性,为下一个字符做准备,注意这里是直接修改dic的引用,相当于被重新赋值了
        dic = dic[w]

    }
    dic.end = true;
};

Trie.prototype.prefix = function(word) {
    var dic = this.dic
    for (var w of word) {
        if (!dic[w]){
            // 如果有一个字符直接没访问到,那就中断查找,说明输入的字符不存在,且不会是某个字符串的开头
            return false
        };
        dic = dic[w] 
    };
    // 相当于一只找到了最后一个字符的对象, 如果它是一个完整单词的结果,那么一定会有end属性
    return dic;
}

Trie.prototype.search = function(word, h) {
    var res = this.prefix(word);
  	// 可能返回的是一个空对象,所以需要效验这个对象的end属性是不是true,注意不要写成了return res && res.end
  	// 因为即便一个一个空对象也是能访问end属性的,只是值为undefined,我就踩了这个坑提交挂了
    return res && res.end === true;
};

Trie.prototype.startsWith = function(prefix) {
    return this.prefix(prefix);
};

那么本文就到这里了。

posted on 2021-04-18 02:07  听风是风  阅读(340)  评论(0编辑  收藏  举报