字典树

第一部分、Trie树
什么是Trie树

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

Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

它有3个基本性质:

  1. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
  2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
  3. 每个节点的所有子节点包含的字符都不相同。
举例

举个在网上流传颇广的例子,如下:

题目:给你100000个长度不超过10的单词。对于每一个单词,我们要判断他出没出现过,如果出现了,求第一次出现在第几个位置。
分析:这题当然可以用hash来解决,但是本文重点介绍的是trie树,因为在某些方面它的用途更大。比如说对于某一个单词,我们要询问它的前缀是否出现过。这样hash就不好搞了,而用trie还是很简单。
现在回到例子中,如果我们用最傻的方法,对于每一个单词,我们都要去查找它前面的单词中是否有它。那么这个算法的复杂度就是O(n^2)。显然对于100000的范围难以接受。现在我们换个思路想。假设我要查询的单词是abcd,那么在他前面的单词中,以b,c,d,f之类开头的我显然不必考虑。而只要找以a开头的中是否存在abcd就可以了。同样的,在以a开头中的单词中,我们只要考虑以b作为第二个字母的,一次次缩小范围和提高针对性,这样一个树的模型就渐渐清晰了。
好比假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,我们构建的树就是如下图这样的:

(图义:当时第一次看到这幅图的时候,便立马感到此树之不凡构造了。单单从上幅图便可窥知一二,好比大海搜人,立马就能确定东南西北中的到底哪个方位,如此迅速缩小查找的范围和提高查找的针对性,不失为一创举)
对于每一个节点,从根遍历到他的过程就是一个单词,如果这个节点被标记为红色,就表示这个单词存在,否则不存在。
那么,对于一个单词,我只要顺着他从跟走到对应的节点,再看这个节点是否被标记为红色就可以知道它是否出现过了。把这个节点标记为红色,就相当于插入了这个单词。
这样一来我们查询和插入可以一起完成(重点体会这个查询和插入是如何一起完成的,稍后,下文具体解释),所用时间仅仅为单词长度,在这一个样例,便是10。
我们可以看到,trie树每一层的节点数是26^i级别的。所以为了节省空间。我们用动态链表,或者用数组来模拟动态。空间的花费,不会超过单词数×单词长度。

小结

ok,下面,咱们再总结一下上述问题:

已知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)。

解释下上述方法3中所说的为什么hash不能将建立与查询同时执行,而Trie树却可以:

  • 在hash中,例如有串:911,911456输入,如果要同时执行建立与查询,过程就是查询911,没有,然后存入9、91、911;查询911456,没有然后存入9114、91145、911456,而程序没有记忆功能,并不知道911在输入数据中出现过。所以用hash必须先存入所有子串,然后for循环查询。
  • 而trie树中,存入911后,已经记录911为出现的字符串,在存入911456的过程中就能发现而输出答案;倒过来亦可以,先存入911456,在存入911时,当指针指向最后一个1时,程序会发现这个1已经存在,说明911必定是某个字符串的前缀。

至于,有关Trie树的查找,插入等操作的实现代码,网上遍地开花且千篇一律,诸君尽可参考,想必不用我再做多余费神。

查询

Trie树是简单但实用的数据结构,通常用于实现字典查询。我们做即时响应用户输入的AJAX搜索框时,就是Trie开始。本质上,Trie是一颗存储多个字符串的树。相邻节点间的边代表一个字符,这样树的每条分支代表一则子串,而树的叶节点则代表完整的字符串。和普通树不同的地方是,相同的字符串前缀共享同一条分支。下面,再举一个例子。给出一组单词,inn, int, at, age, adv, ant, 我们可以得到下面的Trie:

可以看出:

  • 每条边对应一个字母。
  • 每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。
  • 单词inn与单词int有共同的前缀“in”, 因此他们共享左边的一条分支,root->i->in。同理,ate, age, adv, 和ant共享前缀"a",所以他们共享从根节点到节点"a"的边。

查询操纵非常简单。比如要查找int,顺着路径i -> in -> int就找到了。

搭建Trie的基本算法也很简单,无非是逐一把每则单词的每个字母插入Trie。插入前先看前缀是否存在。如果存在,就共享,否则创建对应的节点和边。比如要插入单词add,就有下面几步:

  1. 考察前缀"a",发现边a已经存在。于是顺着边a走到节点a。
  2. 考察剩下的字符串"dd"的前缀"d",发现从节点a出发,已经有边d存在。于是顺着边d走到节点ad
  3. 考察最后一个字符"d",这下从节点ad出发没有边d了,于是创建节点ad的子节点add,并把边ad->add标记为d。

Trie的数据结构定义:

复制代码
#define  MAX 26
typedef 
struct  Trie   
{   
    Trie 
* next[MAX];   //此加点的26个子节点
    
int  v;    // 根据需要变化 ,一般表示此字母出现的次数,比如abc和abd,b节点中的v=2;表示b字母出现了两次
};           //即有两个字符串是以ab为前缀的.
 
Trie 
* root;
复制代码

 

next是表示每层有多少种类的数,如果只是小写字母,则26即可,若改为大小写字母,则是52,若再加上数字,则是62了,这里根据题意来确定。
v可以表示一个字典树到此有多少相同前缀的数目,这里根据需要应当学会自由变化。

Trie的查找(最主要的操作):
(1) 每次从根结点开始一次搜索;
(2) 取得要查找关键词的第一个字母,并根据该字母选择对应的子树并转到该子树继续进行检索;   (3) 在相应的子树上,取得要查找关键词的第二个字母,并进一步选择对应的子树进行检索。   
(4) 迭代过程……   
(5) 在某个结点处,关键词的所有字母已被取出,则读取附在该结点上的信息,即完成查找。

这里给出生成字典树和查找的模版
生成字典树:

 

 

复制代码
void  createTrie( char   * str)
{
    
int  len  =  strlen(str);
    Trie 
* =  root,  * q;
    
for ( int  i = 0 ; i < len;  ++ i)
    {
        
int  id  =  str[i] - ' 0 ' ;  //把字母转换为1~26
        
if (p -> next[id]  ==  NULL)    //如果字典树里没有这个字母
        {
            q 
=  (Trie  * )malloc( sizeof (Trie));  //创建一个节点存这个字母
            q
-> =   1 ;     // 初始v==1  ,表示出现了一次次字母
             for ( int  j = 0 ; j < MAX;  ++ j)  //此字母的子节点初始化
                q
-> next[j]  =  NULL;
            p
-> next[id]  =  q;      //给此字母 的节点赋值
            p 
=  p -> next[id];     //继续向下插入
        }
        
else
        {
            p
-> next[id] -> v ++ ;  //若有这个字母,则v++ ,表示又出现了一次此字母
            p 
=  p -> next[id];
        }
    }
    p
-> =   - 1 ;    // 若为结尾,则将v改成-1表示
}
复制代码

 

 

接下来是查找的过程了:

 

复制代码
int  findTrie( char   * str)
{
    
int  len  =  strlen(str);
    Trie 
* =  root;
    
for ( int  i = 0 ; i < len;  ++ i)
    {
        
int  id  =  str[i] - ' 0 ' ;
        p 
=  p -> next[id];
        
if (p  ==  NULL)    // 若为空集,表示不存以此为前缀的串
             return   0 ;
        
if (p -> ==   - 1 )    // 字符集中已有串是此串的前缀
             return   - 1 ;
    }
    
return   - 1 ;    // 此串是字符集中某串的前缀
}
复制代码

对于上述动态字典树,有时会超内存,比如 HDOJ 1671 Phone List,这是就要记得释放空间了:

 

复制代码
int  dealTrie(Trie *  T)
{
    
int  i;
    
if (T == NULL)
        
return   0 ;
    
for (i = 0 ;i < MAX;i ++ )
    {
        
if (T -> next[i] != NULL)
            deal(T
-> next[i]);
    }
    free(T);
    
return   0 ;
}
复制代码

Phone List

Time Limit: 3000/1000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/Others)
Total Submission(s): 6855    Accepted Submission(s): 2334


Problem Description
Given a list of phone numbers, determine if it is consistent in the sense that no number is the prefix of another. Let’s say the phone catalogue listed these numbers:
1. Emergency 911
2. Alice 97 625 999
3. Bob 91 12 54 26
In this case, it’s not possible to call Bob, because the central would direct your call to the emergency line as soon as you had dialled the first three digits of Bob’s phone number. So this list would not be consistent.
 

Input
The first line of input gives a single integer, 1 <= t <= 40, the number of test cases. Each test case starts with n, the number of phone numbers, on a separate line, 1 <= n <= 10000. Then follows n lines with one unique phone number on each line. A phone number is a sequence of at most ten digits.
 

Output
For each test case, output “YES” if the list is consistent, or “NO” otherwise.
 

Sample Input
   
   
2 3 911 97625999 91125426 5 113 12340 123440 12345 98346
 

Sample Output
   
   
NO YES

#include<cstdio>
#include<cstring>
#include<cmath>
#include<cstdlib>
#include<queue>
#include<iostream>
#include<algorithm>
using namespace std ;
#define MAX 10


 struct Trie
{
Trie *next[MAX];
bool v ;
int sum;  
};
Trie *root ;
char str[10005][10];


void creatTrie(char *str)      
{
int len=strlen(str);
Trie *p=root , *q ;
for(int i = 0 ; i < len ; i++)
{
int id=str[i]-'0';
if(p->next[id]==NULL)
{
q = (Trie *) malloc(sizeof(Trie));
q->sum=1;
for(int j =0;j<MAX;j++)
q->next[j]=NULL; 
p->next[id]=q;
p=p->next[id];
}
else
{
p->next[id]->sum++;
p=p->next[id];
}
}
p->v=1;
}


int findTrie(char *str)
{
int len=strlen(str);
Trie *p=root;
for(int i = 0 ; i < len ; i++)
{
int id=str[i]-'0';
if(p->next[id]==NULL)
  return 0;
p=p->next[id];
}
return p->sum ;       //此时,这个字典树里存在这个字符串,p->sum的值为以这个字符串为前缀的个数,
}                      //若p->sum=1;说明只有自己本身若等于2,说明有两个,即这个字符串是某个字符串的前缀.


void freedom(Trie *p)           //递归释放指针,不然会爆内存
{
for(int i=0;i<MAX;i++)
  if(p->next[i]!=NULL)
     freedom(p->next[i]);
free(p);
}


int main()
{
int T,n;
scanf("%d",&T);
while(T--)
{
int flag=1;
scanf("%d",&n);
root = new Trie ;
root->v=0;
root->sum=0;
 for(int i = 0; i < MAX; ++i)
        {
            root-> next[i] = NULL;
        }
for(int i=0;i<n;i++)
{
scanf("%s",str[i]);
creatTrie(str[i]);
}
for(int i=0;i<n;i++)
{
int m=findTrie(str[i]);
if(m>1)   //如果存在某个号码是另一个号码的前缀,即p->sum大于1 ;则输出NO;
{
flag=0;
break;
}
}
if(!flag)
  printf("NO\n");
else
  printf("YES\n"); 
freedom(root);      
}
return 0;
}


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值