[路飞]前端算法——数据结构篇(三、树): 字典树

515 阅读5分钟

前言

前端算法系列是我对算法学习的一个记录, 主要从常见算法数据结构算法思维常用技巧几个方面剖析学习算法知识, 通过LeetCode平台实现刻意练习, 通过掘金和B站的输出来实践费曼学习法, 我会在后续不断更新优质内容并同步更新到掘金、B站和Github, 以记录学习算法的完整过程, 欢迎大家多多交流点赞收藏, 让我们共同进步, daydayup👊

目录地址:目录篇

相关代码地址: Github

相关视频地址: 哔哩哔哩-百日算法系列

一、概念

又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。

字典树的本质就是一个 N叉树(其实叫前缀树更贴切), 它以单词的公共前缀作为, 以节点中的标记信息(flag属性)表示截止当前节点为止是否是一个完整的单词, 以 N叉树 的性质来降低字符串搜索的时间复杂度, 以 N叉树 的前序遍历来实现字符串的快速排序.

二、例题

假设我们有两个字符串 'aaa'、'aae', 如果我们要对其进行存储、查询等功能, 你会怎么实现呢?

正常情况下我们会采用数组来存储它们, 然后在需要查询的时候通过遍历数组对比其中的每一项来确认其是否存在.但是当我们要存储和查询的数据量比较大时, 这样的方法查询速度显然就不够高效了.

那么, 如果采用字典树的结构要怎么实现, 又有哪些区别呢?

三、实现

1、我们之前的二叉树, 通过 left、right 指针来指向其子节点, 在字典树里是怎么存储的?

我们通过一个指针数组来表示一个节点的子节点, 若我们的数据为纯小写的英文字母, 那么我们可以创建一个长度为 26 的指针数组来存储当前节点的子节点信息, 同时通过数组的下标来映射对应字符. 例如, 数组的第0位就表示公共前缀为字符a的边所对应的指针

2、我们怎么表示例如 'aaa' 和 'aa' 中这单词 'aa' 的存在与否?

我们在每个节点中会有一个属性来表示截止当前节点为止, 是否是一个完整的字符.

const Cnt = 26
const Char = new Map({
    'a': 0,
    'b': 1,
    'c': 2
    ...
})

function Node() {
    this.flag = false
    this.next = new Array(26)
    for(let i=0; i<Cnt; i++) {
        this.next[i] = null
    }
}

// 字典树
function Tree() {
    this.root = new Node()
}

// 插入
Tree.prototype.inster = function (word) {
    // ! 最后节点处理不熟
    let p = this.root
    for(let s of word) {
        let i = Char.get(s)
        if (p.next[i] === null) {
            p.next[i] = new Node()
        }
        p = p.next[i]
    }
    if (p.flag === true) return false
    p.flag = true
    return true
}

// 查询
Tree.prototype.search = function (word) {
    let p = this.root
    for(let s of word) {
        let i = Char.get(s)
        p = p.next[i]
        if (p === null) return false
    }
    return p.flag
}

// 清空
Tree.prototype.clear = function (root) {
    // ! 清空的 JS 写法好像不对
    if (root === null) return
    for(let i=0; i<Cnt; i++) {
        this.clear(root.next[i])
    }
    root = null
    return
}

// 排序
Tree.prototype.sort = function (root, s) {
    if (root === null) return
    if (root.falg === true) {
        console.log(s)
    }
    for(let i=0; i<Cnt; i++) {
        let word = s + Char.get(i)
        this.sort(root.next[i], word)
    }
}

四、其他版本

当我们需要快速的构建一个字典树时, 我们也可以不采用对象的形式每次new一个新的节点,

我们可以通过数组来实现简易版本的字典树.

const tree = new Array([new Array(26), new Array(26), ...])

具体的代码这里不进行赘述, 有兴趣的同学可以实现一下.

五、深入分析

复杂度

构建时间复杂度: O(n)

查询时间复杂度: O(len) len 为查询字符串长度

优点

优化存储空间

原本我们通过字符串来存储上面的两个字符串, 一个单词需要一个空间单位的话, 两个字符串就要六个单位, 而现在我们抽取了公共前缀, 则只需要 'a', 'a', 'a', 'e', 四个单位.

快速单词查找

当我们进行查找是, 可以利用 N叉树的性质沿路径查找, 极大的降低了查询的时间复杂度

字符串排序

因为我们在构建字典树的时候就是按照字符顺序进行构建的, 对字典树进行前序遍历后即可得到排序后的字符串数组.

缺点

空间复杂度高

我们在上面的代码中构建字典树式, 每个节点下面都有26个指针, 但并不是每个指针都被使用了, 这就造成了极大的空间浪费, 因此说字典树的空间复杂度是相比数组查询的方法要高的. 而实际上, 字典树本身就是一个典型的空间换时间的算法

那么针对这个缺陷我们是否有继续优化的方案呢?

当然是有的, 因为在算法中永远都不是单个的数据结构来解决问题的, 复合类型 才是究极解.

例如, 当我们是否可以使用链表来优化存储空间呢? 具体的解法欢迎在评论区交流~

对比哈希表、平衡查找树等

字典树对缓存不友好, 这点需要注意.

如果你的数据集前缀重叠率不高, 你不应该使用 字典树.

字典树有一个比较经典的案例就是百度的模糊搜索, 如果你搜索了一个单词, 百度会自动给你关联这个单词相关的词条, 这就是利用了公共前缀来快速的模糊查询(自动补完).

六、相关练习

208. 实现 Trie (前缀树)

1268. 搜索推荐系统

剑指 Offer II 067. 最大的异或