字典树详解及其应用

综合两篇博文的闪光点!
Trie树详解及其应用
字典树

一、知识简介

最近在看字符串算法了,其中字典树、AC自动机和后缀树的应用是最广泛的了,下面将会重点介绍下这几个算法的应用。
字典树(Trie)可以保存一些字符串->值的对应关系。基本上,它跟 Java 的 HashMap 功能相同,都是 key-value 映射,只不过 Trie 的 key 只能是字符串。
  Trie 的强大之处就在于它的时间复杂度。它的插入和查询时间复杂度都为 O(k) ,其中 k 为 key 的长度,与 Trie 中保存了多少个元素无关。Hash 表号称是 O(1) 的,但在计算 hash 的时候就肯定会是 O(k) ,而且还有碰撞之类的问题;Trie 的缺点是空间消耗很高。
  至于Trie树的实现,可以用数组,也可以用指针动态分配,我做题时为了方便就用了数组,静态分配空间。
Trie树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
Trie树的基本性质可以归纳为:
(1)根节点不包含字符,除根节点意外每个节点只包含一个字符。
(2)从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应的字符串。
(3)每个节点的所有子节点包含的字符串不相同。
Trie树有一些特性:
1)根节点不包含字符,除根节点外每一个节点都只包含一个字符。
2)从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
3)每个节点的所有子节点包含的字符都不相同。
4)如果字符的种数为n,则每个结点的出度为n,这也是空间换时间的体现,浪费了很多的空间。
5)插入查找的复杂度为O(n),n为字符串长度。
基本思想(以字母树为例):
1、插入过程
对于一个单词,从根开始,沿着单词的各个字母所对应的树中的节点分支向下走,直到单词遍历完,将最后的节点标记为红色,表示该单词已插入Trie树。
2、查询过程
同样的,从根开始按照单词的字母顺序向下遍历trie树,一旦发现某个节点标记不存在或者单词遍历完成而最后的节点未标记为红色,则表示该单词不存在,若最后的节点标记为红色,表示该单词存在。

二、字典树的数据结构:

利用串构建一个字典树,这个字典树保存了串的公共前缀信息,因此可以降低查询操作的复杂度。
下面以英文单词构建的字典树为例,这棵Trie树中每个结点包括26个孩子结点,因为总共有26个英文字母(假设单词都是小写字母组成)。
则可声明包含Trie树的结点信息的结构体:

typedef struct Trie_node  
{  
    int count;                    // 统计单词前缀出现的次数  
    struct Trie_node* next[26];   // 指向各个子树的指针  
    bool exist;                   // 标记该结点处是否构成单词    
}TrieNode , *Trie;  

其中next是一个指针数组,存放着指向各个孩子结点的指针。
如给出字符串”abc”,”ab”,”bd”,”dda”,根据该字符串序列构建一棵Trie树。则构建的树如下:
这里写图片描述

Trie树的根结点不包含任何信息,第一个字符串为”abc”,第一个字母为’a’,因此根结点中数组next下标为’a’-97的值不为NULL,其他同理,构建的Trie树如图所示,红色结点表示在该处可以构成一个单词。很显然,如果要查找单词”abc”是否存在,查找长度则为O(len),len为要查找的字符串的长度。而若采用一般的逐个匹配查找,则查找长度为O(len*n),n为字符串的个数。显然基于Trie树的查找效率要高很多。
如上图中:Trie树中存在的就是abc、ab、bd、dda四个单词。在实际的问题中可以将标记颜色的标志位改为数量count等其他符合题目要求的变量。
已知n个由小写字母构成的平均长度为10的单词,判断其中是否存在某个串为另一个串的前缀子串。下面对比3种方法:

1、 最容易想到的:即从字符串集中从头往后搜,看每个字符串是否为字符串集中某个字符串的前缀,复杂度为O(n^2)。

2、 使用hash:我们用hash存下所有字符串的所有的前缀子串。建立存有子串hash的复杂度为O(n*len)。查询的复杂度为O(n)* O(1)= O(n)。

3、 使用Trie:因为当查询如字符串abc是否为某个字符串的前缀时,显然以b、c、d....等不是以a开头的字符串就不用查找了,这样迅速缩小查找的范围和提高查找的针对性。所以建立Trie的复杂度为O(n*len),而建立+查询在trie中是可以同时执行的,建立的过程也就可以成为查询的过程,hash就不能实现这个功能。所以总的复杂度为O(n*len),实际查询的复杂度只是O(len)。

三、Trie树的操作

在Trie树中主要有3个操作,插入、查找和删除。一般情况下Trie树中很少存在删除单独某个结点的情况,因此只考虑删除整棵树。
1、插入
假设存在字符串str,Trie树的根结点为root。i=0,p=root。
1)取str[i],判断p->next[str[i]-97]是否为空,若为空,则建立结点temp,并将p->next[str[i]-97]指向temp,然后p指向temp;
若不为空,则p=p->next[str[i]-97];
2)i++,继续取str[i],循环1)中的操作,直到遇到结束符’\0’,此时将当前结点p中的 exist置为true。
2、查找
假设要查找的字符串为str,Trie树的根结点为root,i=0,p=root
1)取str[i],判断判断p->next[str[i]-97]是否为空,若为空,则返回false;若不为空,则p=p->next[str[i]-97],继续取字符。
2)重复1)中的操作直到遇到结束符’\0’,若当前结点p不为空并且 exist 为true,则返回true,否则返回false。
3、删除
删除可以以递归的形式进行删除。

前缀查询的典型应用:
http://acm.hdu.edu.cn/showproblem.php?pid=1251
不知为何G++提交会MLE,而C++提交能过

#include<iostream>
#include<string>
using namespace std;

struct trieNode{
    int count;//统计单词前缀出现的次数
    trieNode* next[26];//指向各子树的指针
    bool exit;//标记该结点处是否构成单词

    trieNode():count(0),exit(false){
        for (int i = 0; i < 26; i++){
            next[i] = NULL;
        }
    }
};


void trieInsert(trieNode* root, string &word){
    trieNode *node = root;
    int id;
    int len = word.size();
    int i = 0;
    while (i < len){
        id = word[i]-'a';
        if (node->next[id] == NULL){
            node->next[id] = new trieNode();
        }

        node = node->next[id];
        node->count += 1;

        i++;
    }
    node->exit = true;//单词结束,可以构成一个单词
}

int trieSearch(trieNode*root, string &word){

    trieNode* node = root;
    int len = word.size();
    int i = 0;
    while (i < len){
        int id = word[i] - 'a';
        if (node->next[id] != NULL){
            node = node->next[id];
            i++;
        }
        else{
            return 0;
        }
    }
    return node->count;
}
int main()
{
    trieNode *root = new trieNode();

    string word;
    int flag = false;
    while (getline(cin, word)&&word.compare("")!=0){
                trieInsert(root, word);
    }
     while(cin>>word)
            cout << trieSearch(root, word) << endl;
    return 0;
}

字典树的查找
http://acm.hdu.edu.cn/showproblem.php?pid=1075

#include<iostream>
#include<string>
using namespace std;

struct trieNode{
    int count;//统计单词前缀出现的次数
    trieNode* next[26];//指向各子树的指针
    bool exit;//标记该结点处是否构成单词
    string traslate;

    trieNode() :count(0), exit(false){
        for (int i = 0; i < 26; i++){
            next[i] = NULL;
        }
    }
};


void trieInsert(trieNode* root, string &word, string &eng){
    trieNode *node = root;
    int id;
    int len = word.size();
    int i = 0;
    while (i < len){
        id = word[i] - 'a';
        if (node->next[id] == NULL){
            node->next[id] = new trieNode();
        }

        node = node->next[id];
        node->count += 1;

        i++;
    }
    node->exit = true;//单词结束,可以构成一个单词
    node->traslate = eng;
}

string trieSearch(trieNode*root, string &word){

    trieNode* node = root;
    int len = word.size();
    int i = 0;
    while (i < len){
        int id = word[i] - 'a';
        if (node->next[id] != NULL){
            node = node->next[id];
            i++;
        }
        else{
            return "";
        }
    }

    string res;
    if (node->exit){
        res = node->traslate;
    }
    else{
        res = "";
    }

    return res;
}


int main()
{
    trieNode *root = new trieNode();

    string eng;
    string word;
    cin >> eng;
    while (cin >> eng && eng != "END"){
        cin >> word;
        trieInsert(root, word, eng);
    }

    cin.get();//去掉上一行的换行符

    while (getline(cin, word)){

        if (word == "START"){
            continue;
        }

        if (word == "END"){
            break;
        }
        string tmpWord = "";
        int len = word.size();

        for (int i = 0; i < len; i++){
            if (word[i] >= 'a' && word[i] <= 'z'){
                tmpWord += word[i];
            }
            else{
                string res = trieSearch(root, tmpWord);
                if (!res.empty()){
                    cout << res;
                }
                else{
                    cout << tmpWord;
                }
                cout << word[i];
                tmpWord = "";
            }
        }

        cout << endl;
    }

    return 0;
}

三、Trie树的应用

  1. 字符串检索
    检索、查询功能是Trie树最原始功能,思路就是从根节点开始一个一个字符进行比较。
    如果沿路比较,发现不同的字符,则表示该字符串在集合中不存在。
    如果所有的字符全部比较并且完全相同,还需要判断最后一个节点标识位(标记该节点是否为一个关键字)。

  2. 词频统计
    Trie树常被搜索引擎用于文本词频统计。
    思路:为了实现词频统计,我们修改了节点结构,用一个整型变量count来计数。对每一个关键字执行插入操作,若已存在,计数加1,若不存在,插入后count置 1。
    (1. 2. 都可以用hash table做)

  3. 字符串排序
    Trie树可以对大量字符串按字典序进行排序,思路也很简单:遍历一次所有关键字,将它们全部插入trie树,树的每个结点的所有儿子很显然地按照字母表排序,然后先序遍历输出Trie树中所有关键字即可。

  4. 前缀匹配
    例如:找出一个字符串集合中所有以ab开头的字符串。我们只需要用所有字符串构造一个trie树,然后输出以a->b->开头的路径上的关键字即可。 trie树前缀匹配常用于搜索提示。如当输入一个网址,可以自动搜索出可能的选择。当没有完全匹配的搜索结果,可以返回前缀最相似的可能。

  5. 作为辅助结构
    如后缀树,AC自动机
    有穷自动机 参考资料:http://blog.csdn.net/yukuninfoaxiom/article/details/6057736

  6. 与哈希表相比
    优点:

    1. trie数据查找与不完美哈希表(链表实现)在最坏情况下更快;对于trie树,最差为O(m),m为查找字符串的长度;对于不完美哈希表,会有键值冲突(不同键哈希相同),最坏为O(N),N为全部字符产生的个数。典型情况是O(m)用于哈希计算,O(1)用于数据查找。

    2. trie中不同键没有冲突

    3. trie的桶与哈希表用于存储键冲突的桶类似,仅在单个键与多个值关联时需要

    4. 当更多的键加入到trie中,无需提供hash方法或改变hash方法

    5. trie通过键为条目提供字母顺序
      缺点:

    6. trie数据查找在某些情况下(磁盘或随机访问时间远远高于主存)比哈希表慢

    7. 当键值为某些类型(如浮点型),前缀链很长且前缀不是特别有意义。

    8. 一些trie会比hash表更消耗内存。对于trie,每个字符串的每个字符都要分配内存;对于大多数hash,只需要为整个条目分配一块内存。

  7. 与二叉搜索树相比
    二叉搜索树,又称二叉排序树,它满足:

    1. 任意节点如果左子树不为空,左子树所有节点的值都小于根节点的值;
    2. 任意节点如果右子树不为空,右子树所有节点的值都大于根节点的值;
    3. 树也都是二叉搜索树;
    4. 所有节点的值都不相同。

其实二叉搜索树的优势已经在与查找、插入的时间复杂度上了,通常只有O(log n),很多集合都是通过它来实现的。在进行插入的时候,实质上是给树添加新的叶子节点,避免了节点移动,搜索、插入和删除的复杂度等于树的高度,属于O(log n),最坏情况下整棵树所有的节点都只有一个子节点,完全变成一个线性表,复杂度是O(n)。

Trie树在最坏情况下查找要快过二叉搜索树,如果搜索字符串长度用m来表示的话,它只有O(m),通常情况(树的节点个数要远大于搜索字符串的长度)下要远小于O(n)。

这里写图片描述

posted @ 2018-03-19 20:10  Bryce1010  阅读(189)  评论(0编辑  收藏  举报