【树】力扣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

基础知识

来源:https://leetcode.cn/problems/implement-trie-prefix-tree/solution/fu-xue-ming-zhu-cong-er-cha-shu-shuo-qi-628gs/

常见的二叉树结构:

class TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
}

image
每个结点包含了三个元素:该结点本身的值,左子树的指针,右子树的指针。也就是说二叉树的每个结点只有两个孩子 left 和 right。

如果每个结点有多个孩子呢,就形成了多叉树。多叉树的子结点数目一般不是固定的,所以会用变长数组来保存所有的子结点的指针。

多叉树结构:

class TreeNode {
    int val;
    vector<TreeNode*> children;
}

image
对于普通的多叉树,每个结点的所有子结点可能是没有任何规律的。

而本题讨论的「前缀树」Trie 是每个结点的 children 有规律的多叉树。Trie 中一般都含有大量的空链接,因此在绘制一棵单词查找树时一般会忽略空链接。

Trie

(只保存小写字符的)「前缀树」是一种特殊的多叉树,它的 TrieNode 中 chidren 是一个大小为 26 的一维数组,分别对应了26个英文字符 'a' ~ 'z',也就是说形成了一棵 26 叉树。

前缀树的结构存储两个信息:

  • isVal 表示从根结点到当前结点为止,该路径是否形成了一个有效的字符串

  • children 是该结点的所有子结点

字典树的应用:

生活中就有很多应用。

比如常见的手机拨号键盘,当输入一些数字的时候,后面会自动提示以输入数字为开头的所有号码

比如输入法,当输入半个词汇的时候,输入法上面会自动联想和补全后面可能的词汇。

1. 前缀树(字典树)的初始化

  • 最初,只有一个根结点 root,子树都都还没有初始化。且 根结点不保存任何信息

  • 关键词放到「前缀树」时,需要把它拆成各个字符,每个字符按照其在 'a' ~ 'z' 的序号,放在对应的 children 里面。下一个字符是当前字符的子结点

  • 一个输入字符串构建「前缀树」结束的时候,需要把该结点的 isVal 标记为 true,说明从根结点到当前结点的路径可以构成一个关键词。因此 isVal 应初始化为 false

image
这棵「前缀树」保存了 {"am", "an", "as", "b", "c", "cv"} 这些关键词。图中红色表示 isValr 为 true。注意:

  • 所有以相同字符开头的字符串,会聚合到同一个子树上。比如

  • 并不一定是到达叶子结点才形成了一个关键词,只要 isVal 为 true,那么从根结点到当前结点的路径就是关键词。比如

有些题解把字符画在了结点中,我认为是不准确的。因为前缀树是根据 字符在 children 中的位置确定子树,而不真正在树中存储了 'a' ~ 'z' 这些字符。树中每个结点存储的 isVal 表示的是从根结点到当前结点的路径是否构成了一个关键词。

2. 插入字符串

从字典树的根开始插入字符串。对于当前字符对应的子结点,有两种情况:

  • 子结点存在:沿着指针移动到子结点,继续处理下一个字符

  • 子结点不存在:创建一个新的子结点,记录在 children 数组的对应位置上,然后沿着指针移动到子结点,继续搜索下一个字符

重复以上步骤,直到处理字符串的最后一个字符,然后将当前结点标记为字符串的结尾。

3. 查询

从字典树的根开始查找。对于当前字符对应的子结点,有两种情况:

  • 子结点存在:沿着指针移动到子结点,继续搜索下一个字符

  • 子结点不存在:查找路径中断,说明字典树中不包含该前缀,返回空指针。比如在上面的前缀树图中寻找 "d" 或者 "ar" 或者 "any" ,由于树中没有构建对应的结点,那就查找不到这些关键词

重复以上步骤,直到 返回空指针 或 搜索完字符串的最后一个字符

  • 若搜索到了字符串的末尾,首先说明字典树中可以有该字符串

    • 若前缀末尾对应结点的 isVal 为 true,则说明字典树中存在该字符串。比如上面前缀树图中的 "am" , "cv" 等

    • 若前缀末尾对应结点的 isVal 为 false,则说明字典树中不存在该字符串。比如在上面的前缀树图中寻找 "a"

新建一个类的方法解决问题

# 定义新类
class Node(object):
    def __init__(self):
        self.children = collections.defaultdict(Node) # 使用 字典 保存 children,保存的结构是 {字符:Node} ,所以可以直接通过 children['a'] 来获取当前结点的 'a' 子树
        self.isVal = False

class Trie(object):

    def __init__(self):
        self.root = Node()

    def insert(self, word: str) -> None:
        curr = self.root
        for char in word:
            curr = curr.children[char]
        curr.isVal = True

    def search(self, word: str) -> bool:
        curr = self.root
        for char in word:
            curr = curr.children.get(char)
            if curr == None:
                return False
        return curr.isVal

    def startsWith(self, prefix: str) -> bool:
        curr = self.root
        for char in prefix:
            curr = curr.children.get(char)
            if curr == None:
                return False
        return True

直接解决问题

class Trie:

    def __init__(self):
        self.root = {} # 初始化一个字典作为结点,相当于根结点

    def insert(self, word: str) -> None:
        cur = self.root  # 指向当前结点的指针
        '''
        从键与值的关系具体理解
        for char in word:
            node = cur.get(char) # 键为 char ,值为 node
            if node is None:
                node = {}
                cur[char] = node
            cur = node
        '''
        for char in word:
            if char not in cur:
                cur[char] = {} # 初始化子结点就是新建一个键为 char 的字典,即 cur[char] = dict()
            cur = cur[char] # 把指针移到下一个字符所在的结点
        cur["#"] = "#" # 加上单词结束标志,给结尾做个标记,代表这是一个完整的字符串,也可以写为 cur["#"] = None

    def search(self, word: str) -> bool:
        cur = self.root
        '''
        for char in word:
            node = cur.get(char)
            if node is None:
                return False
        '''
        for char in word:
            if char not in cur:
                return False
            cur = cur[char] # 结点下移
        '''
        如果有单词结束标志,说明查询成功,返回true
        if "#" in tree:
            return True
        return False
        '''
        return "#" in cur

    def startsWith(self, prefix: str) -> bool:
        cur = self.root
        '''
        for char in prefix:
            node = cur.get(char)
            if node is None:
                return False
        '''
        for char in prefix:
            if char not in cur:
                return False
            cur = cur[char] # 结点下移
        return True # 不需要判断最后一个字符结点的 isVal,因为既然能匹配到最后一个字符,那后面一定有单词是以它为前缀的


作者:powcai
链接:https://leetcode.cn/problems/implement-trie-prefix-tree/solution/pythonjian-dan-shi-xian-by-powcai/

时间复杂度:初始化为 O(1),其余操作为 O(|S|),其中 |S| 是每次插入或查询的字符串的长度,插入和查询操作需要遍历一次字符串。

空间复杂度:O(|T|⋅Σ),其中 |T| 为所有插入字符串的长度之和,Σ 为字符集的大小,本题 Σ=26。

Trie 树常用于自动补全、拼写检查、单词搜索、高频统计等领域,一般都是这个模板再加个次数。

posted @   Vonos  阅读(106)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示