• 深入了解前缀树(超详细图文解释,含完整代码实现)


    什么是前缀树?


    前缀树N叉树的一种特殊形式。通常来说,一个前缀树是用来存储字符串的。前缀树的每一个节点代表一个字符串前缀)。每一个节点会有多个子节点,通往不同子节点的路径上有着不同的字符。子节点代表的字符串是由节点本身的原始字符串,以及通往该子节点路径上所有的字符组成的。

    下面是前缀树的一个例子:

    img

    在上图示例中,我们在节点中标记的值是该节点对应表示的字符串。例如,我们从根节点开始,选择第二条路径 ‘b’,然后选择它的第一个子节点 ‘a’,接下来继续选择子节点 ‘d’,我们最终会到达叶节点 “bad”。节点的值是由从根节点开始,与其经过的路径中的字符按顺序形成的。

    值得注意的是,根节点表示空字符串

    前缀树的一个重要的特性是,节点所有的后代都与该节点相关的字符串有着共同的前缀。这就是前缀树名称的由来。

    我们再来看这个例子。例如,以节点 “b” 为根的子树中的节点表示的字符串,都具有共同的前缀 “b”。反之亦然,具有公共前缀 “b” 的字符串,全部位于以 “b” 为根的子树中,并且具有不同前缀的字符串来自不同的分支。

    前缀树有着广泛的应用,例如自动补全,拼写检查等等。我将在后面的章节中介绍实际应用场景。

    如何表示一个前缀树?


    在前面的文章中,我们介绍了前缀树的概念。在这篇文章中,我们将讨论如何用代码表示这个数据结构。

    在阅读一下内容前,请简要回顾N叉树的节点结构。

     //Definition for a Node.
    class Node {
        public int val;
        public List<Node> children;
    
        public Node() {}
    
        public Node(int _val) {
            val = _val;
        }
    
        public Node(int _val, List<Node> _children) {
            val = _val;
            children = _children;
        }
    };
    
    

    前缀树的特别之处在于字符和子节点之间的对应关系。有许多不同的表示前缀树节点的方法,这里我们只介绍其中的两种方法。

    方法一 - 数组


    第一种方法是用数组存储子节点。

    例如,如果我们只存储含有字母 az 的字符串,我们可以在每个节点中声明一个大小为26的数组来存储其子节点。对于特定字符 c,我们可以使用 c - 'a' 作为索引来查找数组中相应的子节点。

    class TrieNode {
        // change this value to adapt to different cases
        public static final int N = 26;
        public TrieNode[] children = new TrieNode[N];
        
        // you might need some extra values according to different cases
    };
    
    /** Usage:
     *  Initialization: TrieNode root = new TrieNode();
     *  Return a specific child node with char c: root.children[c - 'a']
     */
    

    访问子节点十分快捷。访问一个特定的子节点比较容易,因为在大多数情况下,我们很容易将一个字符转换为索引。但并非所有的子节点都需要这样的操作,所以这可能会导致空间的浪费

    方法二 - Map


    第二种方法是使用 Hashmap 来存储子节点。

    我们可以在每个节点中声明一个Hashmap。Hashmap的键是字符,值是相对应的子节点。

    class TrieNode {
        public Map<Character, TrieNode> children = new HashMap<>();
        
        // you might need some extra values according to different cases
    };
    
    /** Usage:
     *  Initialization: TrieNode root = new TrieNode();
     *  Return a specific child node with char c: root.children.get(c)
     */
    
    

    通过相应的字符来访问特定的子节点更为容易。但它可能比使用数组稍慢一些。但是,由于我们只存储我们需要的子节点,因此节省了空间。这个方法也更加灵活,因为我们不受到固定长度和固定范围的限制。

    补充


    我们已经提到过如何表示前缀树中的子节点。除此之外,我们也需要用到一些其他的值。

    例如,我们知道,前缀树的每个节点表示一个字符串,但并不是所有由前缀树表示的字符串都是有意义的。如果我们只想在前缀树中存储单词,那么我们可能需要在每个节点中声明一个布尔值(Boolean)作为标志,来表明该节点所表示的字符串是否为一个单词。

    前缀树插入

    我们已经在另一篇文章中讨论了 (如何在二叉搜索树中实现插入操作)。

    提问:

    你还记得如何在二叉搜索树中插入一个新的节点吗?

    当我们在二叉搜索树中插入目标值时,在每个节点中,我们都需要根据 节点值目标值 之间的关系,来确定目标值需要去往哪个子节点。同样地,当我们向前缀树中插入一个目标值时,我们也需要根据插入的 目标值 来决定我们的路径。

    更具体地说,如果我们在前缀树中插入一个字符串 S,我们要从根节点开始。 我们将根据 S[0](S中的第一个字符),选择一个子节点或添加一个新的子节点。然后到达第二个节点,并根据 S[1] 做出选择。 再到第三个节点,以此类推。 最后,我们依次遍历 S 中的所有字符并到达末尾。 末端节点将是表示字符串 S 的节点。

    下面是一个例子:

    下载
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    我们来用伪代码总结一下以上策略:

    1. Initialize: cur = root
    2. for each char c in target string S:
    3.      if cur does not have a child c:
    4.          cur.children[c] = new Trie node
    5.      cur = cur.children[c]
    6. cur is the node which represents the string S
    

    通常情况情况下,你需要自己构建前缀树。构建前缀树实际上就是多次调用插入函数。但请记住在插入字符串之前要 初始化根节点

    前缀树搜索

    搜索前缀


    正如我们在前缀树的简介中提到的,所有节点的后代都与该节点相对应字符串的有着共同前缀。因此,很容易搜索以特定前缀开头的任何单词。

    同样地,我们可以根据给定的前缀沿着树形结构搜索下去。一旦我们找不到我们想要的子节点,搜索就以失败终止。否则,搜索成功。

    我们来用伪代码总结一下以上策略:

    1. Initialize: cur = root
    2. for each char c in target string S:
    3.      if cur does not have a child c:
    4.          search fails
    5.      cur = cur.children[c]
    6. search successes
    

    搜索单词


    你可能还想知道如何搜索特定的单词,而不是前缀。我们可以将这个词作为前缀,并同样按照上述同样的方法进行搜索。

    1. 如果搜索失败,那么意味着没有单词以目标单词开头,那么目标单词绝对不会存在于前缀树中。
    2. 如果搜索成功,我们需要检查目标单词是否是前缀树中单词的前缀,或者它本身就是一个单词。为了进一步解决这个问题,你可能需要稍对节点的结构做出修改。

    提示:往每个节点中加入布尔值可能会有效地帮助你解决这个问题。

    应用

    Trie (发音为 “try”) 或前缀树是一种树数据结构,用于检索字符串数据集中的键。这一高效的数据结构有多种应用:

    1. 自动补全

    无效的图片地址

    图 1. 谷歌的搜索建议

    2. 拼写检查

    image.png

    图2. 文字处理软件中的拼写检查

    3. IP 路由 (最长前缀匹配)

    无效的图片地址

    图 3. 使用Trie树的最长前缀匹配算法,Internet 协议(IP)路由中利用转发表选择路径。

    4. T9 (九宫格) 打字预测

    无效的图片地址

    图 4. T9(九宫格输入),在 20 世纪 90 年代常用于手机输入

    5. 单词游戏

    image.png

    图 5. Trie 树可通过剪枝搜索空间来高效解决 Boggle 单词游戏

    还有其他的数据结构,如平衡树和哈希表,使我们能够在字符串数据集中搜索单词。为什么我们还需要 Trie 树呢?尽管哈希表可以在 O(1)时间内寻找键值,却无法高效的完成以下操作:

    • 找到具有同一前缀的全部键值。
    • 按词典序枚举字符串的数据集。

    Trie 树优于哈希表的另一个理由是,随着哈希表大小增加,会出现大量的冲突,时间复杂度可能增加到 O(n),其中 n 是插入的键的数量。与哈希表相比,Trie 树在存储多个具有相同前缀的键时可以使用较少的空间。此时 Trie 树只需要 O(m) 的时间复杂度,其中 mm 为键长。而在平衡树中查找键值需要 O*(mlog*n) 时间复杂度。

    完整实现

    Trie 树的结点结构

    Trie 树是一个有根的树,其结点具有以下字段:。

    • 最多 R 个指向子结点的链接,其中每个链接对应字母表数据集中的一个字母。
      本文中假定 R 为 26,小写拉丁字母的数量。
    • 布尔字段,以指定节点是对应键的结尾还是只是键前缀。

    无效的图片地址

    图 6. 单词 “leet” 在 Trie 树中的表示

    class TrieNode {
    
        // R links to node children
        private TrieNode[] links;
    
        private final int R = 26;
    
        private boolean isEnd;
    
        public TrieNode() {
            links = new TrieNode[R];
        }
    
        public boolean containsKey(char ch) {
            return links[ch -'a'] != null;
        }
        public TrieNode get(char ch) {
            return links[ch -'a'];
        }
        public void put(char ch, TrieNode node) {
            links[ch -'a'] = node;
        }
        public void setEnd() {
            isEnd = true;
        }
        public boolean isEnd() {
            return isEnd;
        }
    }
    

    向 Trie 树中插入键

    我们通过搜索 Trie 树来插入一个键。我们从根开始搜索它对应于第一个键字符的链接。有两种情况:

    • 链接存在。沿着链接移动到树的下一个子层。算法继续搜索下一个键字符。
    • 链接不存在。创建一个新的节点,并将它与父节点的链接相连,该链接与当前的键字符相匹配。

    重复以上步骤,直到到达键的最后一个字符,然后将当前节点标记为结束节点,算法完成。

    无效的图片地址

    图 7. 向 Trie 树中插入键

    class Trie {
        private TrieNode root;
    
        public Trie() {
            root = new TrieNode();
        }
    
        // Inserts a word into the trie.
        public void insert(String word) {
            TrieNode node = root;
            for (int i = 0; i < word.length(); i++) {
                char currentChar = word.charAt(i);
                if (!node.containsKey(currentChar)) {
                    node.put(currentChar, new TrieNode());
                }
                node = node.get(currentChar);
            }
            node.setEnd();
        }
    }
    

    复杂度分析

    • 时间复杂度:O(m),其中 mm 为键长。在算法的每次迭代中,我们要么检查要么创建一个节点,直到到达键尾。只需要 mm 次操作。
    • 空间复杂度:O(m)。最坏的情况下,新插入的键和 Trie 树中已有的键没有公共前缀。此时需要添加 m个结点,使用 O(m)空间。

    在 Trie 树中查找键

    每个键在 trie 中表示为从根到内部节点或叶的路径。我们用第一个键字符从根开始,。检查当前节点中与键字符对应的链接。有两种情况:

    • 存在链接。我们移动到该链接后面路径中的下一个节点,并继续搜索下一个键字符。

    • 不存在链接。若已无键字符,且当前结点标记为isEnd

      ,则返回 true。否则有两种可能,均返回 false :

      • 还有键字符剩余,但无法跟随 Trie 树的键路径,找不到键。
      • 没有键字符剩余,但当前结点没有标记为 isEnd。也就是说,待查找键只是Trie树中另一个键的前缀。

    image.png

    图 8. 在 Trie 树中查找键

    class Trie {
        ...
    
        // search a prefix or whole key in trie and
        // returns the node where search ends
        private TrieNode searchPrefix(String word) {
            TrieNode node = root;
            for (int i = 0; i < word.length(); i++) {
               char curLetter = word.charAt(i);
               if (node.containsKey(curLetter)) {
                   node = node.get(curLetter);
               } else {
                   return null;
               }
            }
            return node;
        }
    
        // Returns if the word is in the trie.
        public boolean search(String word) {
           TrieNode node = searchPrefix(word);
           return node != null && node.isEnd();
        }
    }
    

    复杂度分析

    • 时间复杂度 : O(m)。算法的每一步均搜索下一个键字符。最坏的情况下需要 m 次操作。
    • 空间复杂度 : O(1)。

    查找 Trie 树中的键前缀

    该方法与在 Trie 树中搜索键时使用的方法非常相似。我们从根遍历 Trie 树,直到键前缀中没有字符,或者无法用当前的键字符继续 Trie 中的路径。与上面提到的“搜索键”算法唯一的区别是,到达键前缀的末尾时,总是返回 true。我们不需要考虑当前 Trie 节点是否用 “isend” 标记,因为我们搜索的是键的前缀,而不是整个键。

    image.png

    图 9. 查找 Trie 树中的键前缀

    class Trie {
        ...
    
        // Returns if there is any word in the trie
        // that starts with the given prefix.
        public boolean startsWith(String prefix) {
            TrieNode node = searchPrefix(prefix);
            return node != null;
        }
    }
    

    复杂度分析

    • 时间复杂度 : O(m)。
    • 空间复杂度 : O(1)。

    完整代码

    class Trie {
        private final int ALPHABET_SIZE = 26;
        private Trie[] children = new Trie[ALPHABET_SIZE];
        boolean isEndOfWord = false;
        public Trie() {}
        
        public void insert(String word) {
            Trie tmp = this;
            for (char i : word.toCharArray()) {
                if (tmp.children[i-'a'] == null) {
                    tmp.children[i-'a'] = new Trie();
                }
                tmp = tmp.children[i-'a'];
            }
            tmp.isEndOfWord = true;
        }
        
        public boolean search(String word) {
            Trie tmp = this;
            for (char i : word.toCharArray()) {
                if (tmp.children[i-'a'] == null) {
                    return false;
                }
                tmp = tmp.children[i-'a'];
            }
            return tmp.isEndOfWord ? true : false;
        }
        
        public boolean startsWith(String prefix) {
            Trie tmp = this;
            for (char i : prefix.toCharArray()) {
                if (tmp.children[i-'a'] == null) {
                    return false;
                }
                tmp = tmp.children[i-'a'];
            }
            return true;
        }
    }
    
  • 相关阅读:
    【KVM系列 virt-v2v】virt-v2v过程中的报错
    大机与超级计算机的区别
    多路径 on linux
    ansible 管控 windows
    Linux启动盘
    RHCA 环境命令
    Xshell 4 连接 Ubuntu/Kali 报错 "找不到匹配的key exchange算法"
    security group & ACL
    windows powercfg
    OKD
  • 原文地址:https://www.cnblogs.com/hzcya1995/p/13308017.html
Copyright © 2020-2023  润新知