前缀树(字典树)应用——实现 Trie (前缀树)、添加与搜索单词

版权声明:转载请注明出处!谢谢! https://blog.csdn.net/qq_28114615/article/details/86633509

目录

1 前缀树原理简介

2 实现前缀树

2.1 题目描述

2.2 题目分析

2.3 代码实现

3 添加与搜索单词

3.1 题目描述

3.2 题目分析

3.3 代码实现

4 总结


1 前缀树原理简介

      先来简单介绍一下前缀树是什么。前缀树也叫字典树,常用语字符串的查找,为什么又叫前缀树呢?我们先来看看前缀树长什么样子:

                         

        如图所示,我们把"app"、“and”、“bad”以及“ban”放在树中,每个字符串都是从root开始的,然后根据第一个元素的不同分成了"a"开头与"b"开头两组,然后接着往下走,"a"开头的又分开了,而“b”开头的两个字符串还没有分开,再往下走,“b”开头的两个字符串也分开了。从整个树的结构可以看得出来,字符串两两之间的相同前缀部分是重合的,一旦分开就不会再组合了,因此在一组字符串中查找某一个字符串的时候可以避免很多不必要的查找。

        好了,简单介绍了前缀树之后,还有几点也必须考虑:既然前缀树也是树,那么每个结点的数据类型应当是什么呢?首先,根据前缀树的结构可以看出来,每个树的结点都是需要指向子结点的,那么子结点应该有几个呢?子结点的个数我们是无法确定的,就像图中的结点,有的结点有两个子结点,有的结点只有一个子结点,那该怎么办呢?

       实际上也很简单,如果前缀树中只可能出现多少种结点情况那就定义多少个子结点!比如说这里如果前缀树中的都是小写字母的话,那就直接定义26个子结点就好了。有了子结点,那每个结点需不需要再定义一个value来存放到底是'a'还是'b'呢?实际上根本就不需要,因为既然前面定义了26个子结点,那么每个子结点就已经确定好了,无需再定义一个value。现在,有了子结点,查找的时候就可以顺着子结点往下搜索了,那我什么时候才叫搜索到什么时候结束呢?比如说我要查找“and”,那就应该到'd'结束,那我怎么知道结束了呢?将26个结点全部遍历一次全为空?如果这样的话,如果树中还包含数字之类的话显然效率就很低了,因此,我们直接再给每个结点定义一个末尾标记变量,如果这个结点是其所在的这条线的最后一个结点,那就标记为末尾,那又怎么去标记呢?我们下面就用例题来说了。先来看看每个结点的数据类型:

struct TrieNode{
    TrieNode* child[26]; //子结点
    bool endFlag; //末尾标记,是则true,否则false

    TrieNode():endFlag(false)
    {
        for(auto &c:child)c=NULL; //初始化末尾标记为false,每个子结点为NULL
    }
};

2 实现前缀树

2.1 题目描述

实现一个 Trie (前缀树),包含 insertsearch, 和 startsWith 这三个操作。

示例:

Trie trie = new Trie();

trie.insert("apple");
trie.search("apple");   // 返回 true
trie.search("app");     // 返回 false
trie.startsWith("app"); // 返回 true
trie.insert("app");   
trie.search("app");     // 返回 true

说明:

  • 你可以假设所有的输入都是由小写字母 a-z 构成的。
  • 保证所有输入均为非空字符串。

2.2 题目分析

       题目就是要求实现一个前缀树,先来分析一下三个功能:

       ①insert。意思就是像前缀树中插入字符串。我们先来想一想插入的过程,由于前缀树的每个结点代表一个字母,那么其插入的过程肯定是一个一个字母进行插入的。那就对整个字符串进行遍历,如果当前字母所在的子结点为空,说明当前结点的下面还没有串入该字母,那就直接将该字母所在的子结点处开辟一个新的结点;如果当前字符所对应的子结点不为空,那就迭代到该子结点,继续遍历下一个字母……当字符串遍历结束后,那最后一个字母所在的结点自然而然就是末尾结点了,那就将其endFlag置为true;该段代码如下:

void insert(string word) {
        TrieTree *p=root;
        
        for(auto c:word)  //遍历字符串所有字符
        {
            if(p->child[c-'a']==NULL)p->child[c-'a']=new TrieTree(); //如果当前字符所对应的子结点为空,就开辟一个新的结点
            p=p->child[c-'a'];  //迭代至当前字符所对应的子结点
        }
        p->endFlag=true;  //末尾结点置为true
        return ;
    }

     ②search。意思就是查找当前树中是否存在某一字符串。其实根据上面插入的过程,也不难想到查找的方式了,依然是根据字符串每个字符来迭代至相应结点,如果该字符串中某一字符所对应的结点为空,那就说明这个字符串不存在了。如果均不为空,那是不是说明这个字符串就在树中呢?不一定,比如说在前面的树的结构图中查找“an”,实际上这个字符串是没有的,但是它的每个结点又都存在,因此,我们还需要引入另一个判断条件,即是这个字符串的末尾字符对应的结点是否也是末尾结点,如果也是末尾结点,那么就说明字符串存在了,否则就不存在。该段代码如下:

bool search(string word) {
        TrieTree* p=root;
        
        for(auto c:word)
        {
            if(p->child[c-'a']==NULL)return false;  //只要有一个字符对应的结点为空,就证明不存在
            p=p->child[c-'a']; //迭代至该字符对应结点
        }
        return p->endFlag;  //返回末尾字符对应结点是否为末尾结点
        
    }

     ③startsWith。简单的讲,就是判断树中是否有该前缀,比如说在前面的树的结构图中查找“an”,很明显“an”是一个前缀,因此就返回true了。这样说的话,startsWith的实现还比search更简单,不用判断末尾字符对应结点是否为末尾结点了。对应代码如下:

bool startsWith(string prefix) {
        
        TrieTree* p=root;
 
        for(auto c:prefix)
        {
            if(p->child[c-'a']==NULL)return false;
            p=p->child[c-'a'];
        }
        return true;  //如果整个字符串都遍历完了,说明就存在了。
    }

2.3 代码实现

class Trie {
public:
    
    struct TrieTree   //结点结构体
    {
        TrieTree* child[26];
        bool endFlag;
        
        TrieTree():endFlag(false)
        {
            for(auto &a:child)a=NULL;  //TrieTree结构体构造函数,初始化各子结点为NULL
        }
        
    };
    
    TrieTree* root;
    /** Initialize your data structure here. */
    Trie() {
        root=new TrieTree();   //类构造函数,开辟空间给root
    }
    
    /** Inserts a word into the trie. */
    void insert(string word) {
        TrieTree *p=root;
        
        for(auto c:word)
        {
            if(p->child[c-'a']==NULL)p->child[c-'a']=new TrieTree();
            p=p->child[c-'a'];
        }
        p->endFlag=true;
        return ;
    }
    
    /** Returns if the word is in the trie. */
    bool search(string word) {
        TrieTree* p=root;

        for(auto c:word)
        {
            if(p->child[c-'a']==NULL)return false;
            p=p->child[c-'a'];
        }
        return p->endFlag;
    }

    /** Returns if there is any word in the trie that starts with the given prefix. */
    bool startsWith(string prefix) {
        
        TrieTree* p=root;
 
        for(auto c:prefix)
        {
            if(p->child[c-'a']==NULL)return false;
            p=p->child[c-'a'];
        }
        return true;
    }
    
};

/**
 * Your Trie object will be instantiated and called as such:
 * Trie obj = new Trie();
 * obj.insert(word);
 * bool param_2 = obj.search(word);
 * bool param_3 = obj.startsWith(prefix);
 */

添加与搜索单词

3.1 题目描述

设计一个支持以下两种操作的数据结构:

void addWord(word)
bool search(word)

search(word) 可以搜索文字或正则表达式字符串,字符串只包含字母 . 或 a-z 。 . 可以表示任何一个字母。

示例:

addWord("bad")
addWord("dad")
addWord("mad")
search("pad") -> false
search("bad") -> true
search(".ad") -> true
search("b..") -> true

说明:

你可以假设所有单词都是由小写字母 a-z 组成的。

3.2 题目分析

       光从题目需求来说,addWord的功能和前面的insert功能是相同的,而search功能就变复杂了一些,多了一个'.'用于模糊匹配,即是说如果字符串中某个字符是'.',那这个字符是可以表示任何一个字母的,那该怎么做呢?实际上,和前面一道题的search差别也不大,前面一个search遍历字符串时,只迭代到字符串当前字符所对应的子结点,而这里如果遍历到了'.'字符,那就说明26个子结点都是符合要求的,那么就应该遍历26个子结点进行相同的操作了,很显然,这里就需要用到递归了。其他的就不多说了,直接贴出代码:

bool search(string word) {
        
        return search(word,root,0,word.size());   //对search的重载函数开始递归

    }
    
    bool search(string &word,TrieNode* root,int index,int len)
    { 
        if(!root)return false;  //如果当前字符对应子结点为空,说明不存在该字符串
        
        if(index==len)return root->endFlag;
        
        if(word[index]=='.')   //如果当前字符为'.'
        {
            for(auto a:root->child)  //就依次递归26个子结点
            {
                if(search(word,a,index+1,len))return true; //如果其中某一次递归查找命中,那么就直接返回true
            }
            return false; //如果26个子结点都递归完了都没有命中的,说明不存在该字符串
        }

        return search(word,root->child[word[index]-'a'],index+1,len); //如果当前字符不是'.',那么就递归下一个字符
        
    }

3.3 代码实现

class WordDictionary {
    
public:
    
    struct TrieNode
    {
        TrieNode *child[26];

        bool endFlag;
        
        TrieNode():endFlag(false)
        {
            for(auto &c:child)c=NULL;
        }
        
    };
    /** Initialize your data structure here. */
    WordDictionary() {
        root=new TrieNode();
    }
    
    /** Adds a word into the data structure. */
    void addWord(string word) {
        
        TrieNode* p=root;
        
        for(auto c:word)
        {
            if(!p->child[c-'a'])p->child[c-'a']=new TrieNode();
            p=p->child[c-'a'];
        }
        p->endFlag=true;
        return ;
    }
    
    /** Returns if the word is in the data structure. A word could contain the dot character '.' to represent any one letter. */

    bool search(string word) {
        
        return search(word,root,0,word.size());

    }
    
    bool search(string &word,TrieNode* root,int index,int len)
    { 
        if(!root)return false;  //如果当前字符对应子结点为空,说明不存在该字符串
        
        if(index==len)return root->endFlag;
        
        if(word[index]=='.')   //如果当前字符为'.'
        {
            for(auto a:root->child)  //就依次递归26个子结点
            {
                if(search(word,a,index+1,len))return true; //如果其中某一次递归查找命中,那么就直接返回true
            }
            return false; //如果26个子结点都递归完了都没有命中的,说明不存在该字符串
        }

        return search(word,root->child[word[index]-'a'],index+1,len); //如果当前字符不是'.',那么就递归下一个字符
        
    }
    private:
    
    TrieNode* root;
};

/**
 * Your WordDictionary object will be instantiated and called as such:
 * WordDictionary obj = new WordDictionary();
 * obj.addWord(word);
 * bool param_2 = obj.search(word);
 */

4 总结

      根据以上两个例子可以看出,前缀树的方法用来查找字符串是十分方便的,每一个不管是插入还是查找,其时间复杂度均为O(M),其中M为参数字符串的长度。但是前缀树也是有缺点的,虽然从树的结构图上看只有几个结点,实际上每个结点都有26个子结点,只不过隐藏了其他部分结点,这对内存来说无疑是不友好的。因此,前缀树的方法是很典型的用空间换取时间的例子。

猜你喜欢

转载自blog.csdn.net/qq_28114615/article/details/86633509