• 【算法】字典树


    一、算法理解

    参考:字典树(前缀树)_越看越喜欢啊-CSDN博客_字典树

    字典树,叫前缀树可能更好理解。
    image

    Trie又被称为前缀树、字典树,所以当然是一棵树。

    上面这棵Trie树包含的字符串集合是{in, inn, int, tea, ten, to}。每个节点的编号是我们为了描述方便加上去的。树中的每一条边上都标识有一个字符。这些字符可以是任意一个字符集中的字符。比如对于都是小写字母的字符串,字符集就是’a’-‘z’;对于都是数字的字符串,字符集就是’0’-‘9’;对于二进制字符串,字符集就是0和1。

    比如上图中3号节点对应的路径0123上的字符串是inn,8号节点对应的路径0568上的字符串是ten。终结点与集合中的字符串是一一对应的。

    【原理】:
    下面我们来讲一下对于给定的字符串集合{W1, W2, W3, … WN},如何创建对应的Trie树。

    其实上Trie树的创建是从只有根节点开始,通过依次将W1, W2, W3, … WN插入Trie中实现的。所以关键就是之前提到的Trie的插入操作

    具体来说,Trie一般支持两个操作:

    1. Trie.insert(W):第一个操作是插入操作,就是将一个 字符串W 加入到集合中。
    2. Trie.search(S):第二个操作是查询操作,就是查询一个 字符串S 是不是在集合中。

    1、插入操作

    (1)假设我们要插入字符串”in”。

    我们一开始位于根,也就是0号节点,我们用P=0表示。我们先看P是不是有一条标识着i的连向子节点的。没有这条边,于是我们就新建一个节点,也就是1号节点,然后把1号节点设置为P也就是0号节点的子节点,并且将边标识为i。最后我们移动到1号节点,也就是令P=1
    image
    这样我们就把”in”的i字符插入到Trie中了。

    然后我们再插入字符n,也是先找P也就是1号节点有没有标记为n的边。还是没有,于是再新建一个节点2,设置为P也就是1号节点的子节点,并且把边标识为n。最后再移动到P=2。这样我们就把n也插入了。由于n是”in”的最后一个字符,所以我们还需要将P=2这个节点标记为终结点
    image

    (2)现在我们再插入字符串”inn”

    过程也是一样的,从P=0开始找标识为i的边,这次找到1号节点。于是我们就不用创建新节点了,直接移动到1号节点,也就是令P=1。再插入字符n,也是有2号节点存在,所以移动到2号节点,P=2。最后再插入字符n这时P没有标识为n的边了,所以新建3号节点作为2号节点的子节点,边标识为n,同时将3号节点标记为终结点:
    image

    (3)将后面的字符串int tea ten to都插入之后,就得到了我们一开始给出的Trie:
    image

    【总结】:

    插入操作伪码如下:
    image

    2、查询操作

    下面我们再讲一下如何查询Trie树中是不是包含字符串S,也就是之前提到的查找操作。

    查找其实比较简单。我们只要从根节点开始,沿着标识着S[1] -> S[2] -> S[3] … -> S[S.len]的边移动,如果最后成功到达一个终结点,就说明S在Trie树中;如果最后无路可走,或者到达一个不是终结点的节点,就说明S不在Trie树中。
    image

    如果是查找”te”,就会从0开始经过5最后到达6。但是6不是终结点,所以te没在Trie树中。如果查找的是”too”,就会从0开始经过5和9,然后发现之后无路可走:9号节点没有标记为o的边连出去。所以too也不在Trie中。

    查找操作伪码:
    image

    3、Trie树的代码实现参考

    数组方式实现
    要写代码实现一个Trie首先就要确定如何存储一个Trie结构。这里用一个二维数组来存储:
    int trie[MAX_NODE]/[CHARSET];
    int k;

    • 其中MAX_NODE是trie中最大能存储的节点数目
    • CHARSET是字符集的大小
    • k是当前trie中包含有多少个节点。
    • Trie[i]/[j]的值是0表示trie树中i号节点,并有一条连出去的边,边上的字符是字符集中第j个字符(从0开始);
    • trie[i]/[j]的值是正整数x,表示trie树中i号节点,有一条连出去的边,边上的字符是字符集中第j个字符,并且这条边的终点是x号节点

    PS:Tire树需要保存的值:
    1)边的起节点
    2)边的终节点
    3)边的字符。

    如上采用数组方式,非0的[i]/[j],其值表示边连接的下一个节点,i表示当前节点,j表示边的字符。

    #include<cstdio>
    #include<iostream>
    #include<cstring>
    using namespace std;
    const int MAX_NODE = 1000000 + 10;
    const int CHARSET = 26;
    int trie[MAX_NODE][CHARSET] = {0};
    int color[MAX_NODE] = {0};    //记录是不是树的叶子节点
    int k = 1;
    
    void insert(char *w){
        int len = strlen(w);
        int p = 0;
        for(int i=0; i<len; i++){
            int c = w[i] - 'a';
            if(!trie[p][c]){
                trie[p][c] = k;
                k++;
            }
            p = trie[p][c];
        }
        color[p] = 1;
    }
    
    int search(char *s){
        int len = strlen(s);
        int p = 0;
        for(int i=0; i<len; i++){
            int c = s[i] - 'a';
            if(!trie[p][c]) return 0;
            p = trie[p][c];
        }
        return color[p] == 1;
    }
    
    int main(){
        int t,q;
        char s[20];
        scanf("%d%d", &t,&q);
        while(t--){
            scanf("%s", s);
            insert(s);
        }
        while(q--){
            scanf("%s", s);
            if(search(s)) printf("YES\n");
            else printf("NO\n");
        }
        return 0;
    }
    

    二、适用场景

    三、注意事项

    【个人理解】

    1. 当然Java中也可以采用这种方式(个人理解)。节点通过Node { Map<Character, Node>; xxxx}表示:key表示字符,value表示重点Node索引, xxx表示节点需要记录的其它属性(如:节点所在树深度)。如果所有节点需要遍历,可以考虑同时使用ArrayList记录节点全集。
    2. 如果需要计算叶子节点相关,则最好每个Node中保存一个Count,记录当前Node的边数。(参考单词压缩编码的例子)。Node节点,包含Count和Node[],[]索引表示边、值表示下个Node。

    【字典树关键操作】

    1)Insert(String),单词插入树
    2)search(String),判断单词是否存在指定单词。(需到叶子节点)
    3)searchWith(String),判断字典树中是否有单词是指定前缀

    四、使用案例

    1)单词压缩编码(leetCode)

    给定义一组单词words,按照如下规则进行编码,编码后结果由:助记字符串 s 和 下标数组 indices 组成。输出最小 助记字符串 长度。
    如:
    1)单词组是words = ["time", "me", "bell"]
    2)编码后结果为s = "time#bell#" 和 indices = [0, 2, 5]

    如上:单词组words和编码结果的关系如下:
    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#"

    【思路一】存储后缀
    如果单词 X 是 Y 的后缀,那么单词 X 就不需要考虑了,因为编码 Y 的时候就同时将 X 编码了。例如,如果 words 中同时有 "me" 和 "time",我们就可以在不改变答案的情况下不考虑 "me"。

    如果单词 Y 不在任何别的单词 X 的后缀中出现,那么 Y 一定是编码字符串的一部分。
    因此,目标就是保留所有不是其他单词后缀的单词,最后的结果就是这些单词长度加一的总和,因为每个单词编码后后面还需要跟一个 # 符号。

    代码参考:

    class Solution {
        /*
        *
        * 方式一:逐个单词遍历其后缀节点,删除为其后缀的单词
         */
        public int compressWords(String[] words) {
            if (words == null || words.length == 0)
            {
                return 0;
            }
    
            ArrayList<String> wordsList = new ArrayList<>(Arrays.asList(words));
    
            wordsList.sort((i, j) -> j.length() - i.length());
            for (String word: words) {
                for (int i=1; i<word.length();i++) {
                    wordsList.remove(word.substring(i));
                }
            }
    
            int sum = 0;
            for (String word2: wordsList) {
                sum += (word2.length() + 1);
            }
    
            return sum;
        }
    }
    

    【思路二】字典树
    如方法一所说,目标就是保留所有不是其他单词后缀的单词

    去找到是否不同的单词具有相同的后缀,我们可以将其反序之后插入字典树中。例如,我们有 "time" 和 "me",可以将 "emit" 和 "em" 插入字典树中。

    然后,字典树的叶子节点(没有孩子的节点)就代表没有后缀的单词,统计叶子节点代表的单词长度加一的和即为我们要的答案。

    代码参考:

    字典树结构:
    class Solution {
        public int minimumLengthEncoding(String[] words) {
            TrieNode trie = new TrieNode();//根节点
            
            Map<TrieNode, Integer> nodes = new HashMap<TrieNode, Integer>();
    
            for (int i = 0; i < words.length; ++i) {
                String word = words[i];
                TrieNode cur = trie;
                for (int j = word.length() - 1; j >= 0; --j) {
                    cur = cur.get(word.charAt(j));
                }
                nodes.put(cur, i);   //记录单词索引---单词终结节点
            }
    
            int ans = 0;
            for (TrieNode node: nodes.keySet()) {
                if (node.count == 0) {   //终结节点 的单词长度之和
                    ans += words[nodes.get(node)].length() + 1;
                }
            }
            return ans;
    
        }
    }
    
    //节点的边对应的下个节点,数组下标和'a'的差值表示边
    class TrieNode {
        TrieNode[] children;
        int count;
    
        TrieNode() {
            children = new TrieNode[26];
            count = 0;
        }
    
        public TrieNode get(char c) {
            if (children[c - 'a'] == null) {
                children[c - 'a'] = new TrieNode();
                count++;
            }
            return children[c - 'a'];
        }
    }
    
    字典树结构2:
    class Solution {
        // 定义节点结构,通过字符关联下一个节点。
        class Node {
            HashMap<Character, Node> relationMap = new HashMap<Character, Node>();
            Integer deep;
        }
    
        /*
        * 方式二,采用字典树方式
        *
         */
        public int compressWords2(String[] words) {
            if (words.length == 0) {
                return 0;
            }
    
            // 创建根节点
            ArrayList<Node> nodeList = new ArrayList<Node>();
            nodeList.add(new Node());
    
            //排序,字典树中先插入最长的单词
            ArrayList<String> wordsList = new ArrayList<>(Arrays.asList(words));
            wordsList.sort((i, j) -> j.length() - i.length());
    
            for(String word: words) {
                if(!search(nodeList, word)) {
                    insert(nodeList, word);
                }
            }
    
            int sum = 0;
            for (Node node: nodeList) {
                if (node.relationMap.size() == 0) {
                    //单词长度 + '#'
                    sum += node.deep + 1;
                }
            }
            return sum;
        }
    
        private void insert(ArrayList<Node> nodeList, String word) {
            if (word == null || word.length() == 0 || nodeList.size() == 0) {
                return;
            }
    
            Node curNode = nodeList.get(0);
            for (int i= word.length()-1; i>=0; i--) {
                Character charac = word.charAt(i);
                HashMap<Character, Node> nextMap = curNode.relationMap;
                if(nextMap.get(charac) == null) {
                    Node tmpNode =  new Node();
                    tmpNode.deep = word.length() - i;
    
                    nextMap.put(charac, tmpNode);
                    nodeList.add(tmpNode);
    
                    curNode = tmpNode;
                }
                else {
                    curNode = nextMap.get(charac);
                }
            }
    
            return;
        }
    
        private boolean search(ArrayList<Node> nodeList, String word) {
            if (word == null || word.length() == 0 || nodeList.size() == 0) {
                return false;
            }
    
            Node curNode = nodeList.get(0);
            for (int i = word.length() - 1; i >= 0; i--) {
                HashMap<Character, Node> nextMap = curNode.relationMap;
                if (nextMap.get(word.charAt(i)) == null) {
                    return false;
                } else {
                    curNode = nextMap.get(word.charAt(i));
                }
            }
    
            return true;
        }
    }
    
  • 相关阅读:
    springboot2.1.3+jacoco检测代码覆盖率
    springboot2.1.3使用mvn site遇到的坑及解决方案
    docker安装mysql5.6(win10家庭版)
    Windows10家庭版安装docker攻略
    python 实例方法,类方法,静态方法的调用
    python超继承
    python 测试生成Htmltestrunner报告几个参数
    Python3 中类的静态方法、普通方法、类方法
    python 操作文件
    1
  • 原文地址:https://www.cnblogs.com/yickel/p/14830343.html
Copyright © 2020-2023  润新知