题目1 : Trie树
-
5 babaab babbbaaaa abba aaaaabaa babaababb 5 babb baabaaa bab bb bbabbaab
样例输出
-
1 0 3 0 0
描述
小Hi和小Ho是一对好朋友,出生在信息化社会的他们对编程产生了莫大的兴趣,他们约定好互相帮助,在编程的学习道路上一同前进。
这一天,他们遇到了一本词典,于是小Hi就向小Ho提出了那个经典的问题:“小Ho,你能不能对于每一个我给出的字符串,都在这个词典里面找到以这个字符串开头的所有单词呢?”
身经百战的小Ho答道:“怎么会不能呢!你每给我一个字符串,我就依次遍历词典里的所有单词,检查你给我的字符串是不是这个单词的前缀不就是了?”
小Hi笑道:“你啊,还是太年轻了!~假设这本词典里有10万个单词,我询问你一万次,你得要算到哪年哪月去?”
小Ho低头算了一算,看着那一堆堆的0,顿时感觉自己这辈子都要花在上面了...
小Hi看着小Ho的囧样,也是继续笑道:“让我来提高一下你的知识水平吧~你知道树这样一种数据结构么?”
小Ho想了想,说道:“知道~它是一种基础的数据结构,就像这里说的一样!”
小Hi满意的点了点头,说道:“那你知道我怎么样用一棵树来表示整个词典么?”
小Ho摇摇头表示自己不清楚。
“你看,我们现在得到了这样一棵树,那么你看,如果我给你一个字符串ap,你要怎么找到所有以ap开头的单词呢?”小Hi又开始考校小Ho。
“唔...一个个遍历所有的单词?”小Ho还是不忘自己最开始提出来的算法。
“笨!这棵树难道就白构建了!”小Hi教训完小Ho,继续道:“看好了!”
“那么现在!赶紧去用代码实现吧!”小Hi如是说道
输入
输入的第一行为一个正整数n,表示词典的大小,其后n行,每一行一个单词(不保证是英文单词,也有可能是火星文单词哦),单词由不超过10个的小写英文字母组成,可能存在相同的单词,此时应将其视作不同的单词。接下来的一行为一个正整数m,表示小Hi询问的次数,其后m行,每一行一个字符串,该字符串由不超过10个的小写英文字母组成,表示小Hi的一个询问。
在20%的数据中n, m<=10,词典的字母表大小<=2.
在60%的数据中n, m<=1000,词典的字母表大小<=5.
在100%的数据中n, m<=100000,词典的字母表大小<=26.
本题按通过的数据量排名哦~
输出
对于小Hi的每一个询问,输出一个整数Ans,表示词典中以小Hi给出的字符串为前缀的单词的个数。
提示一:Trie树的建立
小Hi于是在纸上画了一会,递给小Ho,道:“你看这棵树和这个词典有什么关系?”
小Ho盯着手里的纸想了一会道:“我知道了!对于从树的根节点走到每一个黑色节点所经过的路径,如果将路径上的字母都连起来的话,就都对应着词典中的一个单词呢!”
小Hi说道:“那你知道如何根据一个词典构建这样一棵树么?”
“不造!”
“想你也不知道,我来告诉你吧~”小Hi摆出一副老师的样子,说道:“你先这么想,如果我已经有了这样的一个词典和对应的一棵树,我要添加一个新的单词apart,我应该怎么做?”
“让我想想……”小Ho又开始苦思冥想:“首先我要先看看已经能走到哪一步了对吧?比如我从1号节点走"a"这一条边就可以走到2号节点,然后从2号节点走"p"这一条边可以走到3号节点,然后……就没路可走了!这时候我就需要添加一条从3号节点出发且标记为"p"的边才可以接着往下走……最后就是这样了!然后我把最后到达的这个结点标记为黑色就可以了。”
小Hi说道:“真聪明~那你不妨再算算如果是一个有10W个单词的词典,每个单词的长度不超过10的话,这棵树会有多大?”
小Ho于是掏出笔来,一边画一遍念叨:“假设我已经将前三个单词构成了这样一棵树,那么我要添加一个新的单词的时候,最坏情况是这个单词和之前的三个单词都没有公共前缀,那么这个新的单词的长度如果是5的话,我就至少要添加5个结点到树中才能够继续表示这个词典!”
“而如果每次都是最坏情况的话,这棵树最多也就100W个结点这么大!更何况最坏情况是不可能次次都发生的!毕竟字母表也才26个字母呢!”小Ho继续说道。
“嗯~这样我们是不是就可以用(单词个数*单词长度)个结点来表示一个词典了呢?小Hi问道。
“是的呢!”小Ho道:“但是这样一棵树又有什么用呢?”
“可别小看了它,它就是传说中的Trie树哦~至于他有什么用,一会你就知道了!”小Hi笑嘻嘻的回答道。
提示二:如何使用Trie树
提示二:如何使用Trie树
小Hi在树上用绿色标出了一个节点,递给小Ho。
“这个结点……是从根节点先走"a"然后走"p"到达的结点呢!哦~~我知道了,以这个结点为根的子树里所有标记结点都是以"ap"为前缀的单词呢!而且所有以"ap"为前缀的单词都在以这个节点为根的子树里~”小Ho惊喜道。
“是的呢~那你对怎么解决我的问题有想法了么?”小Hi追问道。
“唔...那就是每次拿到你的字符串之后,我在树上找到其对应的那个结点,然后统计这个节点中有多少个标记节点?”小Ho不是很确定的答道:“但是这样...似乎在最坏情况,也就是你每次给个字符串都很短的时候,我还是要扫描这棵树的很大一部分呢?也就是说虽然平均时间复杂度降低了,但是最坏情况时间复杂度还是很高的样子!”
小Hi笑嘻嘻道:”没想到你自己看出来了呢~我还以为又要教训你了!~那你有什么好的解决方法么?”
“没呢!小Hi你就别卖关子了,赶紧告诉我吧!”被折磨的够呛的小Ho开始求饶。
“好吧!就帮你这一回~”
提示三:在建立Trie树时同时进行统计!
提示三:在建立Trie树时同时进行统计!
“小Ho你有没有想过这样一个问题?不妨称以T为根的子树中标记节点的个数为L[T],既然我要统计某个L[T1],,而这个结点是不确定的,我有没有办法一次性把所有结点的L[T]求出来呢?”小Hi整理了下思绪,问道。
“似乎是有的,老师以前说过,递归什么的。。”小Ho答道。
“递归太复杂了!我们可以之后再说,你这么想,在你构建Trie树的时候,当你经过一个结点的时候,说明了什么?”小Hi撇了撇头,继续问道。
“我想想,经过一个结点……标记结点……说明了以这个结点为根的子树中将要多出来一个标记结点?”
“没错!那你有没有什么办法来记录这个改变呢?”
“我想想,我在最开始置所有L[T]=0,然后每次添加一个新的单词的时候,都将它经过的所有结点的L[T]全部+1,这样我构建完这棵Trie树的时候,我也就能够同时统计到所有L[T]了,对么?”小Ho开心道。
字典树数组实现模版:
#include<cstdio>
#include<cstring>
#include<iostream>
using namespace std;
struct node //字典树结构体
{
int next[26]; //每个节点对应26个子节点(本题26个字母)
int cnt; //以当前节点终止作为为前缀的字符串个数
void init()
{
cnt=0; //新节点刚建立时前缀字符串数为0
memset(next,-1,sizeof(next)); //初始化子节点,-1表示不存在
}
}T[1000000]; //开一个大的结构体数组,其实是作为整体的索引地址
int tot; //表示当前节点建立的位置
char str[20]; //临时读入字符串,进行插入、查询等后续操作
void insert(char *s) //插入字符串操作
{
int i=0,p=0; //i表示字符串中字符s[i],p表示当前处理在T数组中的位置
while(s[i])
{
int x=s[i]-'a'; //这样处理巧妙地将char型变为int型
if(T[p].next[x]==-1) //如果当前字符在字典树中不存在
{
T[tot].init(); //在tot位置建立新的树并且初始化
T[p].next[x]=tot++; //标记该字符(前缀)已经存在,同时tot自增,便于下一课新树的建立
}
p=T[p].next[x]; //将p位置移动到新节点,因为要处理后面的字符了
T[p].cnt++; //对应位置的前缀标记量+1
i++; //处理字符串中下一个字符
}
}
void query(char *s) //查询字符串操作,同插入操作解释
{
int i=0,p=0;
while(s[i])
{
int x=s[i]-'a';
if(T[p].next[x]==-1)
{
puts("0");
return ;
}
p=T[p].next[x];
i++;
}
printf("%d\n",T[p].cnt);
}
int main()
{
int n,m;
while(~scanf("%d",&n))
{
tot=1; //字典树root的位置,tot=1
T[0].init();
while(n--)
{
scanf("%s",str);
insert(str);
}
scanf("%d",&m);
while(m--)
{
scanf("%s",str);
query(str);
}
}
return 0;
}
为了方便大家对这个数组字典树的理解,doge再多说几句。
抽象出来的字典树本应该是一层一层的树形状结构,但是使用数组显然无法直接实现。我们可以用链表的套路来理解。
所以数组字典树对于空间的要求很大,而且要事先知道你可能需要多大的内存空间来存放字符。它的深度最大只有2,只有父子、兄弟节点会出现在一个小型树中,而子节点如果作为了其它节点的父节点,它作为父节点的小型树的位置,就是通过自身为子节点时的存放的tot值确定的。查询时,在整个T[1...n]中,我知道doge的“d”在T[0](根节点),那么我在T[0]下找到T[0].next[d]指向了一个位置1,也就是d后面的字符在T[1].next[...]中去查询。我在d对应的T[1].next[o]得到一个新的位置值(假设为10),那么do的子节点就在T[10]。在T[10].next[g]的值是13,说明dog的下一个字符从T[13].next[...]去找。
关于位置值tot的分配,完全取决于插入过程中出现的先后顺序,如果你第一个单词输入的是“doge”,那么他们对应的位置就是T[1] T[2] T[3] T[4]了,但依旧是通过存放的tot值去寻址。那么我们再输入一个“dogg”,对应的是T[1] T[2] T[3]的cnt值会+1(前缀),由于dog的next[...]中g的对应int值没有指向一个tot,而是-1(表示在树中不存在),那么新分配的tot值为5,即是T[4].next[g]=5,然后对T[5] init初始化。
字典树指针实现模版:
如果使用指针,便很好理解层的概念了,相对于数组也不需要提前分配内存,而可以动态分配内存。
#include <iostream>
#include <string>
using namespace std;
typedef struct trie
{
char c;
int count;
struct trie* next[26];
} Trie;
int main()
{
Trie* root = new Trie;
for (unsigned i(0); i < 26; ++i)
root->next[i] = NULL;
int n, m;
string s;
cin >> n;
while (n--)
{
cin >> s;
Trie* p = root;
for (unsigned i(0); i < s.size(); ++i)
{
unsigned j = s[i] - 'a';
if (p->next[j])
{
++p->next[j]->count;
}
else
{
Trie* q = new Trie;
q->c = s[i];
q->count = 1;
for (unsigned k(0); k < 26; ++k)
q->next[k] = NULL;
p->next[j] = q;
}
p = p->next[j];
}
}
cin >> m;
while (m--)
{
cin >> s;
bool flag = false;
Trie* p = root;
for (unsigned i(0); i < s.size(); ++i)
{
unsigned j = s[i] - 'a';
if (!p->next[j])
{
flag = true;
break;
}
p = p->next[j];
}
if (flag) cout << '0' << endl;
else cout << p->count << endl;
}
return 0;
}