leetcode刷题——Trie

前言

在之前的多叉树学习中,我们学会了基本的遍历与搜索,今天来看一个N叉树的经典应用——前缀树。


什么是字典树

在面试中,我们经常会看到这样的题目:

  1. 说一下搜索框中的自动补全技术是如何实现的?
  2. Word中的单词检查是怎样做到的?
  3. IP路由是什么原理(最长前缀匹配)?

这些题目都涉及到了前缀的匹配问题,以及查找到数据集中同一前缀的单词问题(1)。我们最开始的思路是使用哈希表的技术,将所有值hash到内存中,直接匹配后即可。

但是当数据量达到几十亿,内存装不下时候怎么办?而且当哈希表过大,再插入值时频繁的出现冲撞,查询时间复杂度从O(1)下降到O(N)怎么办?

这还只是使用哈希表时候复杂度的问题,但是如何找到同一前缀的全部键值呢?遇到这类前缀问题,哈希表无法将值在O(1)时间内找到,只能按照字符串长度N,从头开始匹配前缀N*O(1)?

这个时候就涉及到一个核心问题:

前缀的重复性。

因为hash表的hash函数无法保证同一前缀的两个单词,与不同前缀的两个单词的计算方法有什么不同。这也就是说两个单词即使只有最有一个字符不同,它们哈希计算后还是会映射到不同的内存空间,前缀的重复性没有体现出来。

这个时候我们可以想N叉树结构,如果同一前缀的两个单词,相同前缀使用根节点记录,最后一个不同的字符使用两个叶节点存储,那么这样,至少从内存(空间复杂度)的使用上,要比哈希表的存储要少了一半(也就是共同前缀)。我们把单词数量扩大到1亿个(无重复)呢?哈希表的空间复杂度会线性增长O(N),时间复杂度会从O(1)增加到O(N)。假如使用N叉树,存储相同前缀,那么空间复杂度为O(M),M为1亿个单词中最长单词的长度。这样看,一亿个单词中相同前缀的字符只在N叉树的根节点存储了一次,空间复杂度要小于哈希表。那么查找的时间复杂度呢,也是O(M),查找的速度一定小于一亿个单词中最长的单词的字符数量。

因此这种N叉树的前缀存储思想,对比于哈希表解法,就是用时间复杂度换空间复杂度。在处理大数据量时候,空间复杂度能在一个比较理想的范围内。同时相比于哈希表解法,它使用树来存储前缀,也能较快找到同一前缀的数据(同一前缀的树节点下),使用于我们举得例子1搜索引擎联想功能、3ip路由问题。

这个时候我们就要介绍这种使用“N叉树存储单词前缀的思想”:

字典树。


字典树的节点定义

字典树的根节点为空,每条路径指向下一个字符。

leetcode刷题——Trie_第1张图片

比如我们查找字符串“bad”,按照根节点——》b——》a——》d的顺序查找到。

如果我们要查找以"b"为前缀的字符串,只需要搜索到b的路径,然后进行深度优先搜索获得接下来的路径即可,时间复杂度为O(m)m为树的高度,比hash表对比所有键值O(N)要快很多。

接下来我们看下字典树的节点数据结构表示:

  1. 列表法。使用列表存储下一个字符的地址。列表的索引作为有序字符串集合的偏移量,列表值保存下一节点的内存地址。
  2. 字典法。使用字典,key保存下一个字符,value表示下一个节点的内存地址。

列表法稍微复杂些,比如我们说字符串集合是小写字母(a-z),那么偏移量就是0-25,如果字符串是ip地址(0-9.)那么偏移量就是0-10,所以这样定义字典树的节点时候,要考虑列表长度问题与脏字符的问题。

字典法相对简单,无论是字符串集合,还是数字集合,都可以直接添加。我们直接使用字典法表示前缀树的节点:

class Node:
    def __init__(self,value=None):
        """
        isWord判断从根节点到当前节点是否为单词。
        next存储下一个字符以及对应的内存地址
        value为业务中需要的值,这里只是举例
        """
        self.isWord = False
        self.next = dict()
        self.value = value

isWord字段就是判断根节点到当前节点是否为一个单词。

之后我们再看下字典树的数据结构定义:


class Trie:

    def __init__(self):
        """
        根节点
        """
        self.root = Node()

    def insert(self, word: str) -> None:
        """
        将单词遍历,插入到下一节点,如果下一节点是当前字符,那么继续遍历。
        否则新建节点,插入当前字符。
        """
        node = self.root
        for i in word:
            if not node.next.get(i):
                node.next[i] = Node()
            node = node.next[i]
        node.isWord = True
        

    def search(self, word: str) -> bool:
        """
        遍历单词,如果下一节点没有存储当前字符,结束,返回没有搜索到。
        如果遍历完成,查看当前节点的isword标志,之前是否出现过。
        """
        node = self.root
        for i in word:
            if not node.next.get(i):
                return False
            node = node.next[i]
        return node.isWord

    def startsWith(self, prefix: str) -> bool:
        """
        与search方法差不多,只不过如果字符串遍历结束,直接返回True
        """
        node = self.root
        for i in prefix:
            if not node.next.get(i):
                return False
            node = node.next[i]
        return True
        


# Your Trie object will be instantiated and called as such:
# obj = Trie()
# obj.insert(word)
# param_2 = obj.search(word)
# param_3 = obj.startsWith(prefix)

字典树最关键的方法是插入和查找方法。

插入每个字符,根据一系列插入操作获得一颗字典树。查询的时候也是从根节点开始查找。查询时候遍历,结束后看是否存在节点。

字典树背后的意义

我认为字典树构建的基础在于一个有限集合字符集(如字母集合a-z,数字集合0-9),按照排列顺序,构成序列(单词或ip),对这些序列进行插入、查找、求前缀操作。

这个序列的排序最好大概率是有规律的,比如英文单词中会有词根的概念,ip中分为4个地址域,包括省市区的分级。因为当这些序列前缀大概率重合的情况下,字典树优于哈希表的空间性能才能体现出来。

这些序列如果用哈希表直接进行hash运算的话,会丧失前缀性的特性,hash到不同的内存地址,体现不出前缀性的优越性。

哈希表固然是很多题解的万能解法,但是当数据量上升到千万亿的时候,我们就要转变思维,找到数据本身的规律,去掉重复性,扩大差异性,尽量使用差异性来解决问题。比如布隆过滤器,从思想上也是将大量的数据通过映射函数映射到一个固定长度的二进制向量上,节省了内存,根据01位和01位的组合来区分数据的差异性。


字典树的经典题目

这里列一些字典树的经典题目,字典树非常适用于人类文本、ip地址这种有限字符集组成大概率有规律的序列形式的数据查找问题:

Map Sum Pairs字符求和

单词替换

添加和搜索单词(类正则)


结语

在完成二叉树、二叉搜索树、N叉树、字典树的学习后,我们对树的认识又进了一步,接下来的学习会以AVL树、红黑树、B树为主,探索更高阶的树结构,并结合数据库技术,文件索引进行学习。

ref:

[1]:https://leetcode-cn.com/problems/implement-trie-prefix-tree/solution/shi-xian-trie-qian-zhui-shu-by-leetcode/

你可能感兴趣的:(算法与数据结构)