最优雅的数据结构之一——字典树Trie(Java)

什么是Trie?

又称单词查找树;

又叫前缀树(prefix tree);

Trie树,是一种树形结构,是一种哈希树的变种。

作用:

  • 用以较快速地进行单词或前缀查询;
  • 用于快速检索、统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。

性质:

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

基本操作:

     增、删、查。(本文实现的操作)

实现:

Trie.java

package trie;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Trie {
    /*
    根节点
     */
    private final Node root;
    /*
    字典树包含单词个数
     */
    private int size;
    /*
    回溯删除节点的标志位
     */
    private int DeleteFlag;


    public Trie() {
        root = new Node("");
    }

    public int size() {
        return size;
    }

    /**
     * 添加一个单词
     *
     * @param word 单词
     */
    public void add(String word) {
        if (word == null || word.length() == 0)
            throw new RuntimeException("非法参数!");
        Node node = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);
            if (node.next.get(c) == null)
                node.next.put(c, new Node(node.value + c));
            node = node.next.get(c);
        }
        if (!node.isWorld) {
            node.isWorld = true;
            ++size;
        }
    }

    /**
     * 查询一个单词是否存在
     *
     * @return true表示该单词存在
     */
    public boolean contains(String word) {
        if (word == null || word.length() == 0)
            throw new RuntimeException("非法参数!");
        Node node = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);
            if (node.next.get(c) == null)
                return false;
            node = node.next.get(c);
        }
        return node.isWorld;
    }

    /**
     * 检查trie中前缀prefix是否存在
     *
     * @return true表示存在该前缀
     */
    public boolean hasPrefix(String prefix) {
        if (prefix == null || prefix.length() == 0)
            return false;
        Node node = root;
        for (int i = 0; i < prefix.length(); i++) {
            char c = prefix.charAt(i);
            if (node.next.get(c) == null)
                return false;
            node = node.next.get(c);
        }
        return true;
    }

    /**
     * 以node作为深度优先遍历的起始节点及根节点,遍历以node为根节点的树的所有节点,找到所有单词加入words集合中
     *
     * @param words 保存单词的集合
     * @param node  根节点和遍历起始点
     */
    private void DFS(List<String> words, Node node) {
        if (node == null)
            return;
        if (node.isWorld)
            words.add(node.value);
        /*
        遍历余下所有分支
         */
        if (node.next != null)
            node.next.forEach((k, v) -> DFS(words, v));
    }

    /**
     * 获取匹配前缀字符串的所有单词;
     * 基于这种方法可以实现很多种不同的查询模式(模糊查找,贪婪匹配等等);
     * 以后有空可以一一实现
     *
     * @param prefix 前缀字符串,只输入"*"表示匹配所有单词
     * @return 所有单词结果的集合
     */
    public List<String> getWordsByPrefix(String prefix) {
        List<String> words = new ArrayList<>();
        if (prefix == null || prefix.length() == 0)
            return words;
        /*
        查询所有
         */
        if (prefix.equals("*")) {
            /*
            深度优先遍历
             */
            DFS(words, root);
            return words;
        }
        Node node = root;
        for (int i = 0; i < prefix.length(); i++) {
            char c = prefix.charAt(i);
            if (node.next.get(c) == null)
                return words;
            node = node.next.get(c);
        }
        DFS(words, node);
        return words;
    }

    /**
     * 深度优先遍历的变体,用以辅助删除trie中的单词和单词对应的分支
     *
     * @return true表示删除成功,未找到该单词会返回false
     */
    private boolean removeByDFS(Node node, String word, int i) {
        if (node == null)//递归中未找到单词的某一个节点,说明该单词不存在,直接结束递归
            return false;
        if (i == word.length()) {//递归到达保存该单词的节点
            if (node.isWorld) {//判断该节点是否有单词
                if (!node.next.isEmpty()) {//该节点下仍有单词
                    node.isWorld = false;//直接置isWorld标志位为false表明已经删除该单词
                    --size;//维护trie大小
                } else {//表明该节点是该分支下最终节点,置DeleteFlag=1表明回溯时清理该分支,删除无用节点
                    DeleteFlag = 1;
                    --size;
                }
                return true;//无论如何都表明已找到该单词,并且要删除该单词
            }
            /*
            该单词没有被保存,置DeleteFlag标志位为0,表示回溯时不需要清理分支
             */
            DeleteFlag = 0;
            return false;
        }
        //if条件成立,进入回溯
        if (removeByDFS(node.next.get(word.charAt(i)), word, i + 1)) {
            if (DeleteFlag == 1) {//是否要清理分支
                /*
                表示该节点分支超过1个,说明从该节点起有其它单词在使用该节点,不能再删除该节点及以上的节点,
                删除最后一个分支节点后,直接置DeleteFlag = 0停止回溯中清理分支操作
                 */
                if (node.next.size() > 1) {
                    node.next.remove(word.charAt(i));
                    DeleteFlag = 0;
                    return true;
                }
                /*
                确保该分支是一个单分支的情况下,仍然可以成功删除该分支
                 */
                if (node.equals(root))
                    node.next.remove(word.charAt(i));
            }
            return true;
        }
        return false;//if条件不成立,单词不存在,直接回溯false
    }

    /**
     * 删除trie中的word单词
     *
     * @return true表示删除成功,false表示单词不存在
     */
    public boolean remove(String word) {
        if (word == null || word.length() == 0)
            throw new RuntimeException("非法参数!");
        DeleteFlag = -1;
        return removeByDFS(root, word, 0);
    }

    /**
     * trie中的节点类
     */
    private static class Node {
        /*
       保存该节点到根节点构成的字符串,
       这么做会极其浪费空间,大部分情况下根本没必要保存,这里我为啥使用?为了方便!
       只要能有高效的时间效率,空间是值得牺牲的,空间是可以扩展的嘛,时间可不等人。。。
         */
        private final String value;
        /*
        是否表示单词
         */
        boolean isWorld;
        /*
        保存分支结点
        HashMap很nice!
         */
        Map<Character, Node> next;

        public Node(String value, boolean isWorld) {
            this.value = value;
            this.isWorld = isWorld;
            this.next = new HashMap<>();
        }

        public Node(String value) {
            this(value, false);
        }
    }
}

Test.java

package trie;


public class Test {
    public static void main(String[] args) {
        final Trie trie = new Trie();
        trie.add("abc");
        trie.add("acd");
        trie.add("bdf");
        trie.add("abcde");
        trie.add("abcdef");
        trie.add("abcfr");
        trie.add("abcbe");
        System.out.println(trie.size());
        System.out.println(trie.contains("abcd"));
        System.out.println(trie.hasPrefix("abc"));
        System.out.println(trie.getWordsByPrefix("*"));
        System.out.println(trie.getWordsByPrefix("b"));
        System.out.println(trie.getWordsByPrefix("B"));
        System.out.println(trie.getWordsByPrefix("a"));
        System.out.println(trie.remove("abcde"));
        System.out.println(trie.size());
        System.out.println(trie.getWordsByPrefix("*"));
        System.out.println(trie.remove("bdf"));
        System.out.println(trie.getWordsByPrefix("*"));
    }
}

结果:

扩展(这很重要!)

内容来源于liuyubobobo老师的课程。

1、trie是我认为最优雅的数据结构之一(还有一个是并查集,树这种数据结构总是能演变出很多优美的变体结构)。

2、压缩字典树(Compressed Trie)

- 优点:节省空间

- 缺点:结构更难维护,代码量增加,逻辑更复杂

3、三分搜索树(Ternary Search Trie)

- 每个节点只有三个分支:左子树存起始比根节点小的串,中子树......,右子树......(自己看图);

- 搜索可能更耗时,但分支更少。

4、更多的字符串模式识别(后缀树)

5、更多的字符串问题

-  子串查询(KMP算法、Boyer-Moore算法、Rabin-Karp算法、......)

- 文件压缩(本质:仍然是字符串压缩算法,例如:哈夫曼编码)

- 模式匹配(正则表达式的引擎)

- 编译原理(编译器,字符串解析)

- 其他领域(生物科学,DNA:4种碱基组成的“字符串”)​​​​​​​

猜你喜欢

转载自blog.csdn.net/qq_40100414/article/details/118099012