数据结构与算法之美笔记(18)Trie树

什么是"Trie"树

Trie树,是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

现在,我们先来看下,Trie树到底上什么样子。

假设我们有6个字符串,它们分别是:how,hi,her,hello,so,see。我们希望在里面多次查找某个字符串是否存在。如果每次查找,都是拿要查找的字符串跟这6个字符串依次进行字符串匹配,那效率就比较低,有没有更加高效的方法?

这个时候,我们就可以先对这6个字符串做一下预处理,组织成Trie树的结构,之后每次查找,都是在Trie树中进行匹配查找。Trie树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。最后就是这样:

其中,根节点不包含任何信息。每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串。(注意:红色节点并不都是叶子节点)。

构造过程的每一步,都相当于在Trie树中插入一个字符串。当所有字符串都插入完成之后,Trie树就构造好了。

 

 当我们在Trie树中查找一个字符串的时候,比如查找字符串“her”,那我们将要查找的字符串分割成单个的字符h,e,r,然后从Trie树的根节点开始匹配。绿色的路径及时在TRIE树中匹配的路径。

 如果我们要查找的是字符串“he”呢?我们还用上面同样的方法,从根节点开始,沿着某条路径来匹配,如图,绿色的路径,是字符串"he"匹配的路径。但是,路径的最后一个节点“e”并不是红色的。也就是说,“he”是某个字符串的前缀子串,但并不能完全匹配任何字符串。

如何实现一棵Trie树

从刚刚的介绍我们知道,Trie树主要有两个操作,一个是将字符串结合构造成Trie树。这个过程其实就是将字符串插入到Trie树的过程,另一个操作及时在Trie树中查询一个字符串。

从前面的图中,我们可以看出,Trie树是一个多叉树。我们知道,二叉树中,一个节点的左右子节点是通过两个指针来存储的,那对于多叉树,我们存储一个节点的所有子节点的指针呢?

经典的存储方式,是借用散列表来实现的。借助散列表的思想,我们通过一个下标与字符一一映射的数组,来存储子节点的指针。

 假设我们的字符串中只有从a到z这26个字符串,我们在数组中下标为0的位置,储存指向子节点a的指针,下标为1的位置存储指向子节点b的指针,此次类推,下标为25的位置,存储的是指向的子节点z的指针。如果某个字符的子节点不存在,我们就在对应的下标的位置存储null。

当我们在Trie树中查找字符串的时候,我们就可以通过字符串的ascii码减去'a'的ascii码,迅速找到匹配的子节点的指针。

这里给出代码的实现:

#include <iostream>
using namespace std;

class TrieNode{
public:
    char data;
    TrieNode** children = new TrieNode*[26]; // 数组中存储的是TrieNode指针
    bool isEndingChar = false;
    TrieNode(){
    }
};

class Trie{
private:
    TrieNode* root = new TrieNode;
public:
    Trie(){
        root->data = '/';
        root->isEndingChar = false;
        for(int i=0;i<26;++i){
            root->children[i] = NULL;
        }
    }
    void insert(char* text,int size){
        TrieNode* p = root;
        for(int i=0;i<size;++i){
            int index = text[i] - 'a';
            if(p->children[index] == NULL){
                TrieNode* NewNode = new TrieNode;
                NewNode->data = text[i];
                p->children[index] = NewNode;
            }
            p = p->children[index];
        }
        p->isEndingChar = true;
    }

    int find(char* text,int size){
        TrieNode* p = root;
        for(int i=0;i<size;++i){
            int index = text[i] - 'a';
            if(p->children[index] == NULL){
                return false;
            }
            p = p->children[index];
        }
        if(p->isEndingChar == false) return false;
        else return true;
    }
};

现在,我们来看下,在Trie树中,查找一个字符串的时间复杂度是多少?

如果要在一组字符串中,频繁地查询某些字符串,用Trie树会比较高效。构建Trie树的过程,需要扫描所有的字符串,时间复杂度是O(n),这里的n是字符串的长度。但是一旦构建成功,后续的查询操作会非常高效。每次查询时,如果要查询的字符串长度是K,那我们只需要比对k个节点,就能完成查询操作。跟原本那组字符串的长度和个数没有任何关系。所以,在构建完Trie树之后,在其中查找字符串的时间复杂度是O(k),k表示要查找的字符串的长度。

空间复杂度分析

关于Trie树,经常有这样一种说法:“Trie树是非常耗内存的,用的是一种空间换时间的思路”。为什么呢?

我们知道,我们刚刚使用数组来存储一个节点的子节点的数组。如果字符串中包含从a到z这26个字符,那每个节点都要存储一个长度为26的数组,,并且每个数组储存一个8字节的指针。而且,即便一个节点只有很少的子节点,远小于26个,比如3,4个,我们也要维护一个长度为26的数组。

我们前面讲过,Trie树的本质是避免重复存储一组字符串的相同前缀子串,但是现在每个字符(对应一个节点)的存储远远大于1一个字节。按照我们上面的例子,数组长度为26,每个元素是8字节,那每个节点就会额外需要26*8=208个字节。而且这还是只包含26个字符的情况。

如果字符串中不仅包含小写字母,还包含大写字母,数组,甚至中文,那需要存储的空间更多,也就是说,在某些情况下,Trie树不一定会节省空间,在重复的前缀不多的情况下,Trie树不但不能节省内存,还有可能会浪费更多的内存。

我们可以稍微牺牲一点查询的效率,将每个节点的数组换成其他数据结构,来存储一个节点的子节点指针。可以使用红黑树,散列表,跳表等。

假设我们用有序数组,数组中的指针按照所指向的子节点的字符的大小顺序排列。查询的时候,我们可以通过二分查找的方法,快速查找到某个字符应该匹配的子节点的指针。但是,在往Trie树中插入一个字符串的时候,我们为了维护数组中的有序性,就会稍微慢了一点。

Trie树与散列表、红黑树的比较

实际上,字符串的匹配问题,笼统上讲,其实就是数据的查找问题。对于支持动态数据高效操作的数据结构,如散列表,红黑树,跳表等。实际上,这些数据结构也可以实现一组字符串中查找字符串的功能。我们选了两种数据结构,散列表和红黑树与Trie树进行比较。

在刚刚讲的这个场景,在一组字符串中查找字符串,Trie树实际上表现并不好。它对要处理的字符串有严苛的要求。

  • 字符串中包含的字符集不能太大,否则储存空间太多。
  • 要求字符串的前缀重合比较多
  • 如果要用Trie树,那我们要自己从0开始实现一个Trie树,还要保证没有bug。
  • 我们知道,trie树是通过指针串起来的,对缓存不友好,性能会打个折扣。

实际上,Trie树只是不适合精确匹配查找,这种问题更适合散列表或者红黑树来解决。Trie树比较适合的是查找前缀匹配的字符串。

如何利用Trie树,实现搜索关键词的提示功能?

我们假设关键词库由用户的热门搜索关键词组成。我们将这个词库构建成一个Trie树,当用户输入其中某个单词的时候,把这个词作为一个前缀子串在Trie树中匹配。为了讲解方便,我们假设词库里只有Hello,her,hi,how,so,see,这6个关键词。当用户输入了字母h的时候,我们就把以H为前缀的hello,her,hi,how展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。

实际上,Trie树的这个应用可以扩展到更加广泛的一个应用上,就是自动输入补全,比如输入法自动补全功能,IDE代码编辑器自动补全功能、浏览器网址输入的自动补全功能。

猜你喜欢

转载自blog.csdn.net/weixin_42073553/article/details/88918711
今日推荐