Trie树的基本原理及实现

前言

在作用户 query 理解的过程当中,有许多须要使用词典来"识别"的过程。在此期间,就避免不了使用 Trie 树这一数据结构。java

所以今天咱们来深刻的学习一下 Trie 树相关的理论知识,而且动手编码实现。node

理论知识

什么是 Trie 树

下面的定义引自维基百科。后端

在计算机科学中,trie,又称前缀树或字典树,是一种有序树,用于保存关联数组,其中的键一般是字符串。与二叉查找树不一样,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的全部子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。通常状况下,不是全部的节点都有对应的值,只有叶子节点和部份内部节点所对应的键才有相关的值。

一个简单的 Trie 结构以下图所示:数组

2019-12-06-19-20-04

从上面的图中,咱们能够发现一些 Trie 的特性。微信

  • 根节点不包含字符,除根节点外的每个子节点都包含一个字符。
  • 从根节点到某一节点,路径上通过的字符链接起来,就是该节点对应的字符串。
  • 每一个单词的公共前缀做为一个字符节点保存。

一般在实现的时候,会在节点结构中设置一个标志,用来标记该结点处是否构成一个单词(关键字), 或者存储一些其余相关的值。数据结构

能够看出,Trie 树的关键字通常都是字符串,并且 Trie 树把每一个关键字保存在一条路径上,而不是一个结点中。另外,两个有公共前缀的关键字,在 Trie 树中前缀部分的路径相同,因此 Trie 树又叫作前缀树(Prefix Tree)。ide

Trie 树的每一个节点的子节点,是一堆单字符的集合,咱们能够很方便的进行对全部字符串进行字典序的排序工做。只须要将字典序先序输出,输出全部子节点时按照字典序遍历便可。因此 Trie 树又叫作字典树。函数

Trie 的优劣势

Trie 树的核心思想就是:用空间来换时间,利用字符串的公共前缀来下降查询时间的开销以达到提升效率的目的。学习

固然,在大数据量的状况下,Trie 树的空间也未必会大于哈希表。只要经过共享前缀节省的空间可以 Cover 对象的额外开销。

Trie 的强大之处就在于它的时间复杂度,插入和查询的效率很高,都为O(N),其中 N 是待插入/查询的字符串的长度,而与 Trie 中保存了多少个元素无关。

关于查询,会有人说 hash 表时间复杂度是O(1)不是更快?可是,哈希搜索的效率一般取决于 hash 函数的好坏,若一个坏的 hash 函数致使不少的冲突,效率并不必定比 Trie 树高。

而 Trie 树中不一样的关键字就不会产生冲突。它只有在容许一个关键字关联多个值的状况下才有相似 hash 碰撞发生。

此外,Trie 树不用求 hash 值,对短字符串有更快的速度。由于一般,求 hash 值也是须要遍历字符串的。

也就是说,从理论上来说,Trie 树的时间复杂度是稳定的,而 hash 表的时间复杂度是不稳定的,取决于 hash 函数的好坏,也和存储的字符串集有关系。

而从工业应用上来说,我的推荐:若是你不须要用到 Trie 树前缀匹配的特性,直接用 hash 表便可。

缘由有如下几点:

  1. hash 表实现极其简单,且大多数语言都有完善的内部库。使用方便。
  2. 大部分时间就 K-V 存储而言,hash 是因为 Trie 树的,尤为是 语言库中通过各方大佬优化的 hash 表。
  3. Trie 树要本身实现,且要通过各类逻辑上的测试,保证覆盖率,还要压测等等才能投入使用,成本过高。

Trie 的应用场景

做为一个工程师,我学习一个东西最重要的地方就是了解他的应用场景,全部只存在于书本上而没有成熟应用的技术,我都浅尝辄止。

在学习 Trie 树时,我也花了不少时间来查找,记录它的应用场景,列举在此处,若是各位同窗有其余的应用场景,不妨留言你们讨论。

K-V 存储及检索

这是 Trie 树嘴原始朴素的使用方法,也就是须要和 hash 表进行竞争的地方。

词频统计

咱们能够修改 Trie 树的实现,将每一个节点的 是否在此构成单词标志位改为此处构成的单词数量. 这样咱们能够用它进行搜索场景常见的词频统计。

固然这个需求 hash 表也是能够实现的。

字典序排序

将全部待排序集合逐个加入到 Trie 树中,而后按照先序遍历输出全部值。在遍历某个节点的全部子节点的时候,按照字典序进行输出便可。

前缀匹配

例如:找出一个字符串集合中全部以 ab 开头的字符串。咱们只须要用全部字符串构造一个 trie 树,而后输出以$a->b->$开头的路径上的关键字便可。

trie 树前缀匹配经常使用于搜索提示。好比各类搜索引擎上的 自动联想后半段功能。

2019-12-06-23-13-00

最长公共前缀
查找一组字符串的最长公共前缀,只须要将这组字符串构建成 Trie 树,而后从跟节点开始遍历,直到出现多个节点为止(即出现分叉)。

做为辅助结构
做为其余数据结构的辅助结构,如后缀树,AC 自动机等

编码实现

首先实现 Trie 树的节点:

package com.huyan.trie;

import java.util.*;

/**
 * Created by pfliu on 2019/12/06.
 */
public class TNode {
    /**
     * 当前节点字符
     */
    private char c;
    /**
     * 当前 节点对应数字
     */
    int count = 0;

    private TNode[] children;

    private static int hash(char c) {
        return c;
    }

    @Override
    public String toString() {
        return "TNode{" +
                "c=" + c +
                ", count=" + count +
                ", children=" + Arrays.toString(children) +
                '}';
    }

    TNode(char c) {
        this.c = c;
    }

    /**
     * 将 给定字符  添加到给定列表中。
     * @param nodes 给定的 node 列表
     * @param c 给定字符
     * @return 插入后的节点
     */
    private static TNode add(final TNode[] nodes, char c) {
        int hash = hash(c);
        int mask = nodes.length - 1;

        for (int i = hash; i < hash + mask + 1; i++) {
            int idx = i & mask;
            if (nodes[idx] == null) {
                TNode node = new TNode(c);
                nodes[idx] = node;
                return node;
            } else if (nodes[idx].c == c) {
                return nodes[idx];
            }
        }
        return null;
    }

    /**
     * 将 当前节点 放入到给定的 节点列表中。
     * 用于 resize 的时候转移节点列表
     * @param nodes 节点列表
     * @param node 给定节点
     */
    private static void add(final TNode[] nodes, TNode node) {
        int hash = hash(node.c);
        int len = nodes.length - 1;

        for (int i = hash; i < hash + len + 1; i++) {
            int idx = i & len;
            if (nodes[idx] == null) {
                nodes[idx] = node;
                return;
            } else if (nodes[idx].c == node.c) {
                throw new IllegalStateException("Node not expected for " + node.c);
            }
        }
        throw new IllegalStateException("Node not added");
    }

    /**
     * 将  给定字符 插入到当前节点的子节点中。
     * @param c 给定字符
     * @return 插入后的节点
     */
    TNode addChild(char c) {
        // 初始化子节点列表
        if (children == null) {
            children = new TNode[2];
        }

        // 尝试插入
        TNode node = add(children, c);
        if (node != null)
            return node;

        // resize
        // 转移节点列表到新的子节点列表中
        TNode[] tmp = new TNode[children.length * 2];
        for (TNode child : children) {
            if (child != null) {
                add(tmp, child);
            }
        }

        children = tmp;
        return add(children, c);
    }

    /**
     * 查找当前节点的子节点列表中,char 等于给定字符的节点
     * @param c 给定 char
     * @return 对应的节点
     */
    TNode findChild(char c) {
        final TNode[] nodes = children;
        if (nodes == null) return null;

        int hash = hash(c);
        int len = nodes.length - 1;

        for (int i = hash; i < hash + len + 1; i++) {
            int idx = i & len;
            TNode node = nodes[idx];
            if (node == null) {
                return null;
            } else if (node.c == c) {
                return node;
            }
        }
        return null;
    }
}

而后实现 Trie 树。

package com.huyan.trie;

import java.util.*;

/**
 * Created by pfliu on 2019/12/06.
 */
public class Trie {

    /**
     * 根节点
     */
    final private TNode root = new TNode('\0');

    /**
     * 添加一个词到 Trie
     *
     * @param word  待添加词
     * @param value 对应 value
     */
    public void addWord(String word, int value) {
        if (word == null || word.length() == 0) return;
        TNode node = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);
            // 当前 char 添加到 trie 中,并拿到当前 char 对应的那个节点
            node = node.addChild(c);
        }
        node.count = value;
    }

    /**
     * 查找 word 对应的 int 值。
     *
     * @param word 给定 word
     * @return 最后一个节点上存储的 int.
     */
    public int get(String word) {
        TNode node = root;
        for (int i = 0; i < word.length(); i++) {
            node = node.findChild(word.charAt(i));
            if (node == null) {
                return 0;
            }
        }
        return node.count;
    }

    private int get(char[] buffer, int offset, int length) {
        TNode node = root;
        for (int i = 0; i < length; i++) {
            node = node.findChild(buffer[offset + i]);
            if (node == null) {
                return 0;
            }
        }

        return node.count;
    }

    /**
     * 从给定字符串的 offset 开始。
     * 查找最大匹配的第一个 int 值。
     *
     * @param str    给定字符串
     * @param offset 开始查找的偏移量
     * @return 第一个匹配的字符串德最后一个节点的 int 值。
     */
    public String maxMatch(String str, int offset) {
        TNode node = root;
        int lastMatchIdx = offset;

        for (int i = offset; i < str.length(); i++) {
            char c = str.charAt(i);
            node = node.findChild(c);
            if (node == null) {
                break;
            } else if (node.count != 0) {
                lastMatchIdx = i;
            }
        }
        return lastMatchIdx == offset ? null : str.substring(offset, lastMatchIdx + 1);
    }

    /**
     * 从给定字符串的 offset <b>反向</b>开始。
     * 查找最大匹配的第一个 int 值。
     *
     * @param str    给定字符串
     * @param offset 开始查找的偏移量
     * @return 第一个匹配的字符串德最后一个节点的 int 值。
     */
    public int maxMatchBack(String str, int offset) {
        TNode node = root;
        int lastMatchIdx = offset;

        for (int i = offset; i >= 0; i--) {
            char c = str.charAt(i);
            node = node.findChild(c);
            if (node == null) {
                break;
            } else if (node.count != 0) {
                lastMatchIdx = i;
            }
        }
        return offset - lastMatchIdx + 1;
    }

    /**
     * 从给定字符串的 offset 开始。检查 length 长度。
     * 查找最大匹配的第一个 int 值。
     *
     * @param buffer 给定字符串
     * @param offset 开始查找的偏移量
     * @return 第一个匹配的字符串德最后一个节点的 int 值。
     */
    public int maxMatch(char[] buffer, int offset, int length) {
        TNode node = root;
        int lastMatchIdx = offset;

        for (int i = offset; i < offset + length; i++) {
            char c = buffer[i];
            node = node.findChild(c);
            if (node == null) {
                break;
            } else if (node.count != 0) {
                lastMatchIdx = i;
            }
        }
        return lastMatchIdx - offset + 1;
    }

    public static void main(String[] args) {
        Trie trie = new Trie();

        for (String s : Arrays.asList("呼延", "呼延二十")) {
            trie.addWord(s, 1);
        }

        String input = "延十在写文章";

        System.out.println(trie.maxMatch(input, 0));

    }

}

代码中基本上实现了 Trie 的基本功能,可是对 trie 的应用方法有不少,好比匹配前缀,好比求最长匹配前缀的长度等。这些就不一一实现了。

参考文章

https://www.cnblogs.com/huang...

https://zh.wikipedia.org/wiki...


完。

联系我

最后,欢迎关注个人我的公众号【 呼延十 】,会不按期更新不少后端工程师的学习笔记。
也欢迎直接公众号私信或者邮箱联系我,必定知无不言,言无不尽。



<h4>ChangeLog</h4>
2019-05-19 完成

以上皆为我的所思所得,若有错误欢迎评论区指正。

欢迎转载,烦请署名并保留原文连接。

联系邮箱:huyanshi2580@gmail.com

更多学习笔记见我的博客或关注微信公众号 < 呼延十 >------>呼延十

相关文章
相关标签/搜索