Trie树(字典树)从入门到精通

       

       作者:hsez_yyh

       链接: https://blog.csdn.net/yyh_getAC/article/details/125791269

       来源:湖北省黄石二中信息竞赛组
       著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

        Trie树,也叫字典树,字母树,单词查找树或键树,其实为一类前缀树。为什么叫它字典树呢?顾名思义,其拥有像字典一样的功能,通过trie树,我们能像查字典一样快速查阅许多信息。相信很多人以前会觉得在用度娘查英语单词时,你打到一半就会发现想查的单词弹出来,在学 trie树 之前,本蒟蒻一直觉得那是一件神奇的事情,肯定有复杂的算法,但是,学了 trie树 后,你会发现,那个其实挺简单的

        trie树 常用于统计,排序和保存大量的字符串(但不仅限于字符串,后面可持久化时就清楚了),所以经常被搜索引擎系统用于文本词频统计。trie树 的优点是什么呢? 很显然,那就是速度快、方便查询、代码简单 。下面我们来看一棵 trie树 :

Trie树(字典树)从入门到精通_第1张图片

         以上就是一棵只包含小写字母的 trie树。 其中每个节点之间的边上的小写字母是我们储存单词的信息,每次查询,我们就可以沿着树边向下搜索,这样就能查询树中储存的信息,比如说,上树中我们从树根沿最右边的一条边查询,一直到达叶子结点,就能得到 ljzfw 这个信息,当然这个信息储存的过程后面会讲。        实际上,我们代码实现的 trie树 跟上图的树长得不太一样,但是大概的树形还是一样的,在代码实现中图中的字母更像是打在节点上,而不是树边上,而且代码实现的 trie树 一般不会特意去写一个根节点。值得一提的是,图中 trie树 上节点里的数字一般无特殊含义,对我们的 trie树 也无太大影响,每个节点的数值是由向树中插入单词的顺序决定的,而且数值只是像一个指针一样方便我们跳转到 trie树 中的下一个节点,所以一般是乱序的,没有什么影响。

        于上图,我们将 1号节点认为是根,那么我们的一种可能的单词插入的顺序是: ai、aky、ljh、lzjfw、akhy、akha 。不知道各位大佬看到这组数据再对比上图是否有什么感触。当初 ljz 大佬看到插入单词和插入完单词的 trie树 后直接开悟,然后自己推出了 trie树 模板,在我还在看别人写的代码时,他就已经A掉三四道 trie树的题了(直到他遇上了可持久化)。好吧,我们还是手模一下单词的插入过程,假如我们要按顺序插入 aky、akhy这两个单词,请看图:

Trie树(字典树)从入门到精通_第2张图片

Trie树(字典树)从入门到精通_第3张图片

Trie树(字典树)从入门到精通_第4张图片

Trie树(字典树)从入门到精通_第5张图片

Trie树(字典树)从入门到精通_第6张图片

Trie树(字典树)从入门到精通_第7张图片

Trie树(字典树)从入门到精通_第8张图片

Trie树(字典树)从入门到精通_第9张图片

Trie树(字典树)从入门到精通_第10张图片

Trie树(字典树)从入门到精通_第11张图片

         以上就是我们 trie树插入单词的过程,大家尽量的理解,数据结构这种东西非常抽象,就凭概念去想很难理解,最好的方法还是手模一下,配合着代码我们就很好理解啦—— ljz表示不屑

        现在我们就给出 trie树 插入单词的代码:

string s;
int idx;//就是之前图里动态开点时的编号
void push_in()
{
	int p=0;//p开始指向“根”
    //输入省略,在主函数里
	int len=s.size();
	for(int i=0;i

        以上就是实现代码,对照之前插入单词的方式手摸一下,很快就能理解的

        现在我们先来看道简单题目 : [NOI2000] 单词查找树 - 洛谷

        很显然这是一道trie树的模板题,我们只需要插入单词,然后把节点数返回一下即可(记得加上根,虽然根是虚节点)。  下面是代码:

#include
using namespace std;
const int N=5e4+10;
int son[N][27],cnt[N],idx;
string s;
int n;
void push_in()
{
	int p=0;
	int len=s.size();
	for(int i=0;i>s)
		push_in();
	printf("%d\n",idx+1);
	return 0;
}

        上面这道题是不是so easy? 那好,我们再来一道简单题:于是他错误的点名开始了 - 洛谷

        好吧,很显然,这又是一道板子题,好吧,那我们再来水一发:

#include
using namespace std;
const int N=5e5+10;
int son[N][27],cnt[N];
bool st[N];
int n,m,idx;
string s;
void push_in()
{
	int p=0;
	int len=s.size();
	for(int i=0;i>n;
	for(int i=1;i<=n;i++)
	{
		cin>>s;
		push_in();
	}
	cin>>m;
	for(int i=1;i<=m;i++)
	{
		cin>>s;
		get_ans();
	}
	return 0;
}

        恭喜,到现在,你已经成功入门了!! Orz~

        既然要精通,那我们再来看一道稍微难一点点的题: 最长异或路径 - 洛谷

这道题乍一看是真的想不到和 trie树 有什么关系,本蒟蒻第一次看到这道题,就直接往树剖、lct上想了,没想到竟是个 trie树的题。首先我们要从异或入手:对于两个数,我们把它化成二进制的形式,那么这两个数的异或就是对应二进制位上的数进行异或 ,因为 1^1==0 , 0^1==1, 0^0 ==0 ;我们可以发现,只要某位上有两个相同的数进行异或,那么结果肯定是 0 ;我们为了让某条路径的异或和最大,那我们首先就要保证所有的数异或后得到的二进制数的最高位最大:0111显然小于1000 ,所以,我们可以构建一棵 0,1, trie树,用来存储每条边的权值转化成二进制数的形式,然后,我们从根节点出发,每次对于我们当前位的 trie树 ,分两个方向跑,一个按我们的原来的边跑,一个按相反的方向跑,就是说第二个方向:如果当前 节点的子节点中有与原边当前二进制位上的数(0或1)相反的节点的话,就往相反的节点跑,如果实在没有,就往相同数值(0或1)的节点上跑。这样跑到底就能得到最大值啦。当然,在跑之前要预处理一下。这样的抽象着讲不易理解,看看代码实现就能轻松理解啦:

#include
using namespace std;
const int N=2e6+10,M=1e5+10;
int son[N][3],cnt[N],idx;
int n;
vector  g[M],d[M];
int dist[M];
void dfs(int root,int fa,int ss)
{
	int len=g[root].size();
	for(int i=0;i0;i>>=1)
	{
		if(v&i)
		u=1;
		else
		u=0;
		if(!son[p][u])
		son[p][u]=++idx;
		p=son[p][u];
	}
	cnt[p]++;
}
long long get_max(int v)
{
	int p=0;
	int op=0;
	int u=0;
	for(int i=(1<<30);i>0;i>>=1)
	{
		if(v&i)
		u=1;
		else
		u=0;
		if(son[p][u^1])
		{
			op=op+i;
			p=son[p][u^1];
		}
		else
		p=son[p][u];
	}
	return op;
}
int main()
{
	scanf("%d",&n);
	for(int i=1;i

        好吧,这道题其实就是锻炼 trie树思维的,而且此题能让我们认识到 trie树的新的用途:构造成二进制下的 01trie树 ,这样就能轻松解决 异或 问题啦。

        为了提升自己,下面推荐几道 trie树比较好的题目:

[USACO08DEC]Secret Message G - 洛谷

[USACO12DEC]First! G - 洛谷

[JSOI2009] 电子字典 - 洛谷

        机房大佬 ljz 说这些题简单爆了(),一定要先做题再看下面内容。

        希望你能从那几道题中成功来到这里,下面我们就再介绍一下 trie树 的神奇功能——可持久化。

        没想到吧,trie树也可以持久化:我们回忆一下之前 trie树的插入操作。我们插入一个aky和akhy,发现,其中在插入akhy时,前两个字母我们根本不需要再开辟新的节点,直接在之前有的节点的基础上跑就行了。好好想想看,在原有的基础,这个原有的基础肯定存在于某个历史的版本,而我们从一个历史版本到当前版本,是有些节点不用改变或新建的——我们新插入单词所新增的节点只是这个单词路径上的节点(还不包括原来有的),所以,每次我们插入一个单词所要改变的trie树的形态而新建的节点数是非常少的,而且当前版本的 trie树跟历史版本的 trie树 也是有许多部分是重合的。这些显然都满足我们可持久化数据结构的性质。 下面看一个图:

Trie树(字典树)从入门到精通_第12张图片

               现在问题来了,如果我们要求第l~r次插入的字符串,该怎么去查询呢? 可以参考 主席树的做法 我们从第r次插入字符串得到的版本开始遍历,然后我们对于每个叶子节点,引入一个新的数组 ver[N],对于某个叶子节点,其ver值代表其是第几次插入的,而非叶子节点的ver值则储存其所在子树中ver值得最大值,在访问时,一旦有节点得ver值< l 得话,那么其子树中所有得节点肯定是在第 l 次插入之前插入的(这里得思想有点近似于线段树),那么我们直接返回就好,就不用继续查询了,而我们的ver值可以在新字符串插入时沿途更新,非常那方便的。下面是可持久化 trie树的插入操作代码:

void push_in(int t,int len,int pre,int now)
{
	if(len>=strlen(s[t]))
	{
		ver[now]=t;
		return;
	}
	int ch=s[t][len]-'a'+1;
	if(pre)
	{
		for(int i=1;i<=26;i++) 
		son[now][i]=son[pre][i];//复制上一个节点
	}
	son[now][ch]=++tot;
	push_in(t,len+1,son[pre][ch],son[now][ch]);
	for(int i=1;i<=26;i++)
	ver[now]=max(ver[son[now][i]],ver[now]);
}

        还是非常之简单的

        其实,可持久化 trie树的经典应用还是在 01trie树上,我们就来看到例题:

        P4735 最大异或和 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

        其实也很简单 ,我们按照上面的可持久化插入法,综合上述的最长异或路径这道题(没做的赶快去做),构建一棵 可持久化01trie树。然后参照最长异或路径那道题的思想:如果同一位上的存在不同的数,那么异或原理贪心选择不同的路径,不然只能选择相同的路径,那么这条路径就是最大值。然后直接暴力跑完每一个版本的 01trie树即可:

#include
using namespace std;
const int N=6e5+10;
int tr[N*25][2],a[N*25];
int n,m,idx;
int rt[N],s[N];
void insert(int pos,int pre,int ner)
{
	a[ner]=pos;
	for(int i=23;i>=0;i--)
    {
        int val=s[pos]>>i&1;
        if(pre)tr[ner][val^1]=tr[pre][val^1];
        tr[ner][val]=++idx;
        a[tr[ner][val]]=pos;
        ner=tr[ner][val];
		pre=tr[pre][val];
    }
}
int query(int pos,int l, int u)
{
    for(int i=23;i>=0;i--)
    {
        int val=u>>i&1;
        if(a[tr[pos][val^1]]>=l) pos=tr[pos][val^1];
        else pos=tr[pos][val];
    }
    return u^s[a[pos]];
}
int main()
{
	a[0]=-1;
	rt[0]=++idx;
	insert(0,0,rt[0]);
	scanf("%d %d",&n,&m);
	for(int i=1;i<=n;i++)
	{
		int xx;
		scanf("%d",&xx);
		rt[i]=++idx;
		s[i]=s[i-1]^xx;
		insert(i,rt[i-1],rt[i]);
	}
	char op[5];
	while(m--)
	{
		scanf("%s",op);
		if(op[0]=='A')
		{
			int xx;
			scanf("%d",&xx);
			n++;
			rt[n]=++idx;
			s[n]=s[n-1]^xx;
			insert(n,rt[n-1],rt[n]);
		 } 
		else
		{
			int l,r,xx;
			scanf("%d %d %d",&l,&r,&xx);
			printf("%d\n",query(rt[r-1],l-1,s[n]^xx));
		}
	}
	return 0;
}

        好了,trie树的一些基本的知识我们就介绍到这了,你现在也基本上掌握了 trie树的一些巧妙功能,可以去爆切水题了!!!        当然,某些 trie树的玄学优化有兴趣的可以自行翻阅资料,本蒟蒻一概运气不好,就不信什么玄学了。

        为了能让大家有更好的 trie 树体验,在此推荐几道水题:P5283 [十二省联考 2019] 异或粽子 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

[TJOI2018]异或 - 洛谷

[SCOI2016]美味 - 洛谷

加油A掉这几道题你就是最棒的啦!!!

你可能感兴趣的:(算法分析,冲击NOI,c++,字符串,算法,数据结构)