剑指Offer(专项突破版)刷题笔记 | 第十章 前缀树

203 阅读7分钟

基础知识

  • 多叉树
  • 若两单词前缀相同,那么它们在前缀树中对应路径前面的节点是重合的。
  • 路径不一定终止于叶节点
  • 字符串最后一个字符对应的节点有特殊的标识

Q62:实现前缀树

题目(中等):Trie(发音类似 "try")或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。

请你实现 Trie 类:

  • Trie() 初始化前缀树对象。
  • void insert(String word) 向前缀树中插入字符串 word 。
  • boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false 。
  • boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true ;否则,返回 false 。
/** Inserts a word into the trie. */
public void insert(String word) {
    TrieNode node = root;
    for(char ch:word.toCharArray()){
        if(node.children[ch - 'a'] == null){
            node.children[ch - 'a'] = new TrieNode();
        }
        node = node.children[ch - 'a'];//到下一节点
    }
    node.isWord = true;
}

/** Returns if the word is in the trie. */
public boolean search(String word) {
    TrieNode node = root;
    for (char ch:word.toCharArray()){
        if(node.children[ch - 'a'] == null){
            return false;
        }
        node = node.children[ch - 'a'];
    }
    return node.isWord;
}

/** Returns if there is any word in the trie that starts with the given prefix. */
public boolean startsWith(String prefix) {
    TrieNode node = root;
    for(char ch : prefix.toCharArray()){
        if(node.children[ch - 'a'] == null){
            return false;
        }
        node = node.children[ch - 'a'];
    }
    return true;
}

前缀树的应用

主要用来解决与字符串查找相关的问题

Q63:替换单词

题目(中等):在英语中,有一个叫做 词根(root) 的概念,它可以跟着其他一些词组成另一个较长的单词——我们称这个词为 继承词(successor)。例如,词根an,跟随着单词 other(其他),可以形成新的单词 another(另一个)。现在,给定一个由许多词根组成的词典和一个句子,需要将句子中的所有继承词用词根替换掉。如果继承词有许多可以形成它的词根,则用最短的词根替换它。需要输出替换之后的句子。

示例 1:

输入:dictionary = ["cat","bat","rat"], sentence = "the cattle was rattled by the battery"
输出:"the cat was rat by the bat"

示例 2:

输入:dictionary = ["a","b","c"], sentence = "aadsfasf absbs bbab cadsfafs"
输出:"a a b c"

示例 3:

输入:dictionary = ["a", "aa", "aaa", "aaaa"], sentence = "a aa a aaaa aaa aaa aaa aaaaaa bbb baba ababa"
输出:"a a a a a a a a bbb baba a"

示例 4:

输入:dictionary = ["catt","cat","bat","rat"], sentence = "the cattle was rattled by the battery"
输出:"the cat was rat by the bat"

示例 5:

输入:dictionary = ["ac","ab"], sentence = "it is abnormal that this solution is accepted"
输出:"it is ab that this solution is ac"
class Solution {
    static class TrieNode{
        public TrieNode children[];
        public boolean isWord;

        public TrieNode(){
            children = new TrieNode[26];
        }
    } 

    private TrieNode buildTrie(List<String> dictionary){
        TrieNode root = new TrieNode();
        for (String word : dictionary){
            TrieNode node = root;
            for(char ch : word.toCharArray()){
                if(node.children[ch - 'a'] == null){
                    node.children[ch - 'a'] = new TrieNode();
                }
                node = node.children[ch - 'a'];
            }
            node.isWord = true;
        }
        return root;
    }

    private String findPrefix(TrieNode root,String word){
        TrieNode node = root;
        StringBuilder builder = new StringBuilder();
        for(char ch : word.toCharArray()){
            if(node.isWord || node.children[ch - 'a'] == null){//已经找到词了或者出现不一致时退出
                break;
            }
            builder.append(ch);
            node = node.children[ch - 'a'];
        }
        return node.isWord ? builder.toString() : "";//有对应前缀就取前缀,否则就返回空字符
    }

    public String replaceWords(List<String> dictionary, String sentence) {
        TrieNode root = buildTrie(dictionary);
        StringBuilder builder = new StringBuilder();

        String [] words = sentence.split(" ");
        for(int i = 0;i < words.length;i++){
            String prefix = findPrefix(root,words[i]);
            if(!prefix.isEmpty()){
                words[i] = prefix;
            }
        }
        return String.join(" ",words);
    }
}

Q64:神奇的字典

题目(中等):设计一个使用单词列表进行初始化的数据结构,单词列表中的单词互不相同。 如果给出一个单词,请判定能否只将这个单词中一个字母换成另一个字母,使得所形成的新单词存在于已构建的神奇字典中。

实现 MagicDictionary 类:

  • MagicDictionary() 初始化对象
  • void buildDict(String[] dictionary) 使用字符串数组 dictionary 设定该数据结构,dictionary 中的字符串互不相同
  • bool search(String searchWord) 给定一个字符串 searchWord ,判定能否只将字符串中 一个 字母换成另一个字母,使得所形成的新字符串能够与字典中的任一字符串匹配。如果可以,返回 true ;否则,返回 false 。

示例:

输入
inputs = ["MagicDictionary", "buildDict", "search", "search", "search", "search"]
inputs = [[], [["hello", "leetcode"]], ["hello"], ["hhllo"], ["hell"], ["leetcoded"]]
输出
[null, null, false, true, false, false]

解释
MagicDictionary magicDictionary = new MagicDictionary();
magicDictionary.buildDict(["hello", "leetcode"]);
magicDictionary.search("hello"); // 返回 False
magicDictionary.search("hhllo"); // 将第二个 'h' 替换为 'e' 可以匹配 "hello" ,所以返回 True
magicDictionary.search("hell"); // 返回 False
magicDictionary.search("leetcoded"); // 返回 False
class MagicDictionary {
    static class TrieNode{
        public TrieNode children[];
        public boolean isWord;

        public TrieNode(){
            children = new TrieNode[26];
        }
    }
    public TrieNode root;
    /** Initialize your data structure here. */
    public MagicDictionary() {
        root = new TrieNode();
    }
    
    public void buildDict(String[] dictionary) {
        for(String word : dictionary){
            TrieNode node = root;
            for(char ch : word.toCharArray()){
                if(node.children[ch - 'a'] == null){
                    node.children[ch - 'a'] = new TrieNode();
                }
                node = node.children[ch - 'a'];
            }
            node.isWord = true;
        }
    }
    //利用深度优先遍历查找
    public boolean search(String searchWord) {
        return dfs(root,searchWord,0,0);
    }

    private boolean dfs(TrieNode root,String searchWord,int i,int edit){//i为当前正在核对字母位置
        //字典为空,直接返回false
        if(root == null){
            return false;
        }
        //如果查完词且查到到词,且不同的字母数只有1个,返回true
        if(root.isWord && i == searchWord.length() && edit == 1){
            return true;
        }

        if(i < searchWord.length() && edit <= 1){
            boolean found = false;
            for(int j = 0;j < 26 && !found;j++){
                int next = j == searchWord.charAt(i) - 'a' ? edit : edit+1;//判断词的i位是否一致
                found = dfs(root.children[j],searchWord,i+1,next);
            }
            return found;
        }
        return false;
    }
}

Q65:最短的单词编码

题目(中等):单词数组 words 的有效编码由任意助记字符串 s 和下标数组 indices 组成,且满足:

  • words.length == indices.length
  • 助记字符串 s 以 '#' 字符结尾
  • 对于每个下标 indices[i] ,s 的一个从 indices[i] 开始、到下一个 '#' 字符结束(但不包括 '#')的 子字符串 恰好与 words[i] 相等 给定一个单词数组 words ,返回成功对 words 进行编码的最小助记字符串 s 的长度。

示例 1:

输入:words = ["time", "me", "bell"]
输出:10
解释:一组有效编码为 s = "time#bell#" 和 indices = [0, 2, 5]words[0] = "time" ,s 开始于 indices[0] = 0 到下一个 '#' 结束的子字符串,如加粗部分所示 "time#bell#"
words[1] = "me" ,s 开始于 indices[1] = 2 到下一个 '#' 结束的子字符串,如加粗部分所示 "time#bell#"
words[2] = "bell" ,s 开始于 indices[2] = 5 到下一个 '#' 结束的子字符串,如加粗部分所示 "time#bell#"

示例 2:

输入:words = ["t"]
输出:2
解释:一组有效编码为 s = "t#" 和 indices = [0] 。

解题思路

反向建树可以免去重合词的计算

static class TrieNode{
    public TrieNode children[];

    public TrieNode(){
        children = new TrieNode[26];
    }
}

private TrieNode buildTrie(String[] words){
    TrieNode root = new TrieNode();
    for(String word : words){
        TrieNode node = root;
        //是倒着建树的
        for(int i = word.length() - 1;i >= 0;i--){
            if(node.children[word.charAt(i) - 'a'] == null){
                node.children[word.charAt(i) - 'a'] = new TrieNode();
            }
            node = node.children[word.charAt(i) - 'a'];
        }
    }
    return root;
}

private void dfs(TrieNode root,int length,int [] total){
    boolean isLeaf = true;//是否是叶节点的标志
    for(TrieNode child:root.children){
        if(child != null){
            isLeaf = false;
            dfs(child,length + 1,total);
        }
    }
    if (isLeaf){//只有叶节点才计算长度
        total[0] += length;
    }
}

public int minimumLengthEncoding(String[] words) {
    TrieNode root = buildTrie(words);
    int[] total = {0}; 
    dfs(root,1,total);//length初始为1,因为每个词以“#”结尾
    return total[0];
}

Q66:单词之和

题目(中等):实现一个 MapSum 类,支持两个方法,insert 和 sum:

  • MapSum() 初始化 MapSum 对象
  • void insert(String key, int val) 插入 key-val 键值对,字符串表示键 key ,整数表示值 val 。如果键 key 已经存在,那么原来的键值对将被替代成新的键值对。
  • int sum(string prefix) 返回所有以该前缀 prefix 开头的键 key 的值的总和。   示例:
输入:
inputs = ["MapSum", "insert", "sum", "insert", "sum"]
inputs = [[], ["apple", 3], ["ap"], ["app", 2], ["ap"]]
输出:
[null, null, 3, null, 5]

解释:
MapSum mapSum = new MapSum();
mapSum.insert("apple", 3);  
mapSum.sum("ap");           // return 3 (apple = 3)
mapSum.insert("app", 2);    
mapSum.sum("ap");           // return 5 (apple + app = 3 + 2 = 5)

解题思路

int sum(string prefix):先找到prefix的最后一个字母的那一个节点,然后计算下面所有叶节点的键值和

class MapSum {
    static class TrieNode{
        public TrieNode children [];
        public int val;

        public TrieNode(){
            children = new TrieNode[26];
        }
    }
    private TrieNode root;
    /** Initialize your data structure here. */
    public MapSum() {
        root = new TrieNode();
    }
    
    public void insert(String key, int val) {
        TrieNode node = root;
        for(char ch : key.toCharArray()){
            if(node.children[ch - 'a'] == null){
                node.children[ch - 'a'] = new TrieNode();
            }
            node = node.children[ch - 'a'];
        }
        node.val = val;//到词的最后一个子节点时添加对应的键值
    }
    
    public int sum(String prefix) {
        TrieNode node = root;
        for(int i = 0;i < prefix.length();i++){
            if(node.children[prefix.charAt(i) - 'a'] == null){
                return 0;
            }
            node = node.children[prefix.charAt(i) - 'a'];
        }//退出遍历时,node对应前缀的最后一个字母
        return getSum(node);
    }

    private int getSum(TrieNode node){
        if(node == null) return 0;
        int result = node.val;
        for(TrieNode child : node.children){
            result += getSum(child);
        }
        return result;
    }
}

Q67:最大的异或

题目(中等):给定一个整数数组 nums ,返回 nums[i] XOR nums[j] 的最大运算结果,其中 0 ≤ i ≤ j < n 。

示例 1:

输入:nums = [3,10,5,25,2,8]
输出:28
解释:最大运算结果是 5 XOR 25 = 28

示例 2:

输入:nums = [0]
输出:0

示例 3:

输入:nums = [2,4]
输出:6

示例 4:

输入:nums = [8,10,2]
输出:10

示例 5:

输入:nums = [14,70,53,83,49,91,36,80,92,51,66,70]
输出:127

解题思路

static class TrieNode{
    public TrieNode[] children;

    public TrieNode(){
        children = new TrieNode[2];
    }
}

private TrieNode root;

private TrieNode buildTrie(int[] nums){
    root = new TrieNode();
    for(int num : nums){
        TrieNode node = root;
        for(int i = 31;i >= 0;i--){
            int bit = num >> i & 1;//从左往右取
            if(node.children[bit] == null){
                node.children[bit] = new TrieNode();
            }
            node = node.children[bit];
        }
    }
    return root;
}
//优先选择相反数位的
public int findMaximumXOR(int[] nums) {
    TrieNode root = buildTrie(nums);
    int max = 0;
    for(int num : nums){
        TrieNode node = root;
        int xor = 0;
        for(int i = 31;i >= 0;i--){
            int bit = num >> i & 1;
            if(node.children[1-bit] != null){
                xor = (xor << 1) + 1;//存在不同分支,异或记为1
                node = node.children[1-bit];
            }else{
                xor = xor << 1;
                node = node.children[bit];
            }
        }
        max = Math.max(max,xor);
    }
    return max;
}

小结

  • 前缀树结构
static class TrieNode{
    TrieNode children[];
    boolean isWord;

    public TrieNode(){
        children = new TrieNode[26];
    }
} 
  • 与哈希表相比,既可以找出所有以某个前缀开头的所有单词,也可以找出修改了一个(或多个)字符的字符串
  • 解决问题一般包括两步
    • 创建前缀树
    • 在前缀树中查找