tire树(字典树) 模板

 

经典问题:

看以下几个题:

1、给出n个单词和m个询问,每次询问一个单词,回答这个单词是否在单词表中出现过。

答:简单!map,短小精悍。

好。下一个

2、给出n个单词和m个询问,每次询问一个前缀,回答询问是多少个单词的前缀。

答:map,把每个单词拆开。

judge:n<=200000,TLE!

这就需要一种高级数据结构——Trie树(字典树)

 

一、适用的问题

 

字典树最能够天然处理的,是这两种问题:

1:构建字典,并查找指定的“完整字符串”

2:构建字典,并查找指定的“前缀”(包括:这个前缀是否出现过,出现了多少次等等)

 

其他问题,如后缀问题、子串问题,都只能是在以上两种的基础上变形。

其中,又属问题2最适用字典树,如果是问题1,可以有其他很多替代方法(比如map)

 

二、字典树的原理

字典树,就是一棵存储多个字符串的树

其中,根绝点不包含字符,只是作为把所有字符串第一个字符串起来的导引

对于其他的每一个结点,从根遍历到他的过程就是一个单词

如果这个节点的is_end被标记为大于1的数,就表示这个单词存在,否则不存在

相同的字符串前缀共享同一条分支。

例如:

给出一组单词,inn, int, at, age, adv, ant, 我们可以得到下面的Trie:

 

  • 每条边对应一个字母。
  • 每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。
  • 单词inn与单词int有共同的前缀“in”, 因此他们共享左边的一条分支,root->i->in。

 

三、字典树的数据结构实现(单个节点的数据结构)

struct node{
    int next[26];
    int cnt;
    int is_end
};
node arr[1000000]; //内存池
int used;
  • next [26]:相当于,指向下面一个节点的指针。

    例如,next[i]存储的是:对于所有下一个字母是i的字符串,它们下个节点所在的位置的下标(在总空间arr中);

    也就是说,如果该字符串下一个字母是i,那么它的下一个结点所在的位置是:arr[this.next[i]]

  • cnt:计数器,它的数值代表:这个节点对应的前缀的出现次数;也就是:经过这条路径的次数。
  • is_end:字符串结尾标记。is_end只要大于0,就说明这个节点是某个字符串的结尾,也说明:当前路径的这个单词存在。is_end的具体数值,代表:这个单词在字典中出现的次数
  • arr[1000000]:内存池。是预先分配出来的总空间,用于模拟动态分配,字典树所有节点所需要的空间都从这里面取
  • used:用来标记,现在内存池arr数组被用到哪里了。

 

其实上,这个是完整版,并不是所有问题,都需要所有这些成员,比如并不是每次都需要cnt。但反正方便打板,每次都直接写这个完整版了

 

四、字典树的构造 (build函数)

 

原理:

调用一次build函数,就可以把一个单词插入字典树。

其实就把,这个单词里的每个字母,逐一插入到Trie里

注意:

插入前,先看对应的分支是否已经存在。如果存在,就共享;不存在,就新建对应结点和边(从内存池中分配新的空间给它)。

如何判断是否存在:如果next指针等于0,就说明这条分支还没有被建立,不存在。因为下一个结点在arr中的所在位置显然不可能是00是根节点的位置

 

void build(char* str)
{
    int pos = 0;  //每次都从字典树的根节点开始找
    int i = 0;
    while(str[i]!=0)
    {
        int tmp = str[i]-'a';
        if(arr[pos].next[tmp] == 0)//如果该分支还不存在,就创建新结点。经过这个if分支,就可以保证,下一个结点一定存在了(不存在的已经创建好了)
        {
            used++;
            arr[pos].next[tmp] = used;
        }
        pos = arr[pos].next[tmp];//先移动到下一个结点
        arr[pos].cnt++;         //再改变cnt(改变的是,现在站在的这个节点的cnt)
        i++;            //别忘了移动str字符串
    }

    arr[pos].is_end++;   //字符串str结束后,在str末尾结点,打上标记

注意代码的细节:

拿字符串“012”的构建来举例:

1:跳出while循环的时候,末尾字符“2”所在的结点,已经被处理过了,它的cnt已经被加过了。

    因为while在遇到结尾符'\0'才跳出,而遇到末尾字符“2”,会进入循环进行处理

2:最后的 is_end 标记,是打在了,末尾字符“2”所在的结点上。因为跳出while之后,pos还没后移,pos还指向“2”所在节点。

     也就是说,is_end 标记,永远都会被打在,字符串最后一个“有效字符”所在的节点上。

 

 

 

五、字典树的查找(大致分为两类问题:查找前缀,和查找完整的给定字符串)

 

例子1:查找字典中是否存在指定的完整字符串(专题一1007)

int find(char* str)
{
    int i = 0;
    int pos = 0;
    while(str[i]!=0)
    {
        int tmp = str[i]-'a';
        if(arr[pos].next[tmp] == 0)//一旦中途发现,有一个边,字典树根本没有,那就直接肯定不存在,返回
        {
            return 0;
        }
        pos = arr[pos].next[tmp];
        arr[pos].cnt++;
        i++;
    }
    if(arr[pos].is_end)
        return 1;
    else
        return 0;
}
View Code

注意:

1:在移动pos前,一般都会先判断想要移动到的目标节点arr[pos].next[i]是否已经存在。这大概是个习惯了吧

2:因为是查找完整的字符串,所以最后的最后,一定要看到明确那个is_end标记,才算找到。

 

 

例子2:判断某个字符串,是否以字典内另一个字符串为前缀(专题一1001)

void find(string &str,int* flag)
{
    int pos = 0;
    int i = 0;
    while(str[i+1]!=0)     //注意这里,一定要是i+1。下面有解释
    {
        int tmp = str[i]-'0';
        
        if(arr[pos].next[tmp] == 0)
            return;
        pos = arr[pos].next[tmp];
        
        if(arr[pos].is_end)
        {
            *flag = 1;
            break;
        }
        i++;
    }
    return;
}
View Code

对上面那个的解释:因为is_end记号是加在“01”中的“1”上,而不是末尾结束符号上;任何一个字符串到了最后一个字符,都会有is_end=1.所以不要看到最后一个字符,看到倒数第二个字符就可以停了。

 

 

例子3:查询前缀出现的次数(专题一1002)

 

int find(char* str)
{
    int pos = 0;
    int i = 0;
    while(str[i]!=0)
    {
        int tmp = str[i]-'a';
        if(arr[pos].next[tmp] == 0)//一旦发现有一个边,字典树根本没有,那就直接肯定,这个前缀根本不存在,直接返回0
        {
            return 0;
        }
        pos = arr[pos].next[tmp];
        i++;
    }
    return arr[pos].cnt; //已经移动到了末尾,那么当前节点cnt的意义就是,当前子串总共出现的次数
}
View Code

 

 

 

posted on 2018-05-18 20:52  _isolated  阅读(454)  评论(0编辑  收藏  举报