浅谈字典树

news/2025/9/21 20:37:40/文章来源:https://www.cnblogs.com/LcukyCat/p/19104092

本篇文章同步发表在洛谷博客。


字典树

什么是字典树?

字典树,顾名思义它是一棵类似于字典的树,用树的形态存储字符串集合。具体地,它有一个自定义的无意义的根节点(通常编号为 \(0\)\(1\)),所有存储进去的字符串都可以顺着根节点从某条固定的路线往下走并连接顶点上的所有字母得到。

字典树的插入、删除与查询

插入

插入,是字典树中特别常用的操作,其表示在一棵选定的字典树中插入某个字符串 \(s\)

假定要插入的字符串 \(s\) 仅由 \(26\) 个小写字母组成。

考虑使用 \(tree_{u,v}\) 表示树上的连边,即表示编号为 \(u\) 的节点是否有一个子节点,它上面的字符是 \(v\)(这里可将 \(\text{a} \sim \text{z}\)\(0 \sim 25\) 来表示)。显然 \(tree_{u,v}\) 存储的是 \(u\) 的子节点中字符为 \(v\) 的节点的编号。

具体插入的时候,用当前所处节点编号 \(now\) 往下看,有没有连向某个当前枚举到的 \(s\) 中的某个字符的一个节点。如果有,那么直接让 \(now\) 走;如果没有,那么就新开一个节点 \(Cnt+1\),然后连上这条边就好了。

示例代码(根节点定为 \(0\)):

void Add(string s){int now=0;for(char c:s){int x=c-'a';if(!tree[now][x])tree[now][x]=(++Cnt);now=tree[now][x];}return;
}

删除

删除其实很麻烦的,因为你只是一个字符串不可用了,但那个节点可能承载了不只一个字符串,这个时候你就要判断啦,如果除了当前这个字符串以外还有别的字符串要经过这个点,那你就不能删掉这个点。

为了实现以上这一步,咱也得先把 Add 函数改一下——毕竟现在得记录每个点承载了多少个字符串呀!

具体的修改是很简单的,加个 \(f\) 数组,用 \(f_u\) 表示编号为 \(u\) 的节点承载了多少个字符串。根节点就没必要统计了,哈哈。

所以把 Add 函数改成这样即可:

void Add(string s){int now=0;for(char c:s){int x=c-'a';if(!tree[now][x])tree[now][x]=(++Cnt);now=tree[now][x],f[now]++;}return;
}

但是呢,你也不可能真的去删掉一个节点,那样未免也太麻烦啦。

怎么办呢?其实并不需要打标记或者什么的,删除节点的时候只需要让 \(f_{now}\) 的值减少 \(1\) 就行了,只是说在查询的时候要注意判断一下,如果 \(f_u\) 有值,才能走去 \(u\) 这个节点哟。

所以 Del 函数也就简单明了了:

void Del(string s){int now=0;for(char c:s){int x=c-'a';now=tree[now][x],f[now]--;}return;
}

查询

查询分很多种,这里只讲一下最基本的,即判断字典树中目前是否存在某个指定字符串。

Add 函数很相似,但不同的是,如果没有连边就直接返回 \(\text{false}\) 了。如果到最后都能找到边那么返回 \(\text{true}\) 就好啦。

代码大概就是这样的吧:

bool Ask(string s){int now=0;for(char c:s){int x=c-'a';if(!tree[now][x]||!f[tree[now][x]])//这里考虑了存在删除操作的情况,//如果没有删除操作就不需要判断 f 数组了return false;now=tree[now][x];}return true;
}

例题一:CF1792D

很经典的题目。

容易发现其是一个显然的字典树,在 Ask 的时候动点手脚,不是判断能不能走到底,而是算出最多能够走多少——甚至都不需要 Del 函数!

然后你就非常光荣地,错在了样例上。

为什么?因为根据题目要求,我们不能直接拿着这一堆的数组来建字典树,而是得把它反过来——假设原来下标是 \(x\) 而数值是 \(y\),我们要创建新的数组,其中下标是 \(y\) 的位置数值时 \(x\)。然后再对这些数组来建字典树。

归根到底,还是因为题目中这种运算的设定。并且题目也是问的“对于所有 \(i\)”,如果问的是“对于所有 \(j\)”,那么显然就不需要这么麻烦了。

然后你就 A 了。代码实现的难度是很小的。

#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define pii pair<int,int>
#define fr first
#define se second
#define pb push_back
using namespace std;
const int N = 5e5+5;
int T,n,m,a[N][15],s[N][15],tree[N][15],Cnt;
int read(){int su=0,pp=1;char ch=getchar();while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}return su*pp;
}
void Init(){for(int i=0;i<=Cnt;i++)for(int j=0;j<=10;j++)tree[i][j]=0,s[i][j]=0;Cnt=0;return;
}
void Add(int id){int now=0;for(int i=1;i<=m;i++)cin>>a[id][i];for(int i=1;i<=m;i++)s[id][a[id][i]]=i;for(int i=1;i<=m;i++){int x=s[id][i];if(!tree[now][x])tree[now][x]=(++Cnt);now=tree[now][x];}return;
}
int Ask(int id){int now=0,ans=0;for(int i=1;i<=m;i++){int x=a[id][i];if(!tree[now][x])return ans;now=tree[now][x],ans++;}return ans;
}
int main(){T=read();while(T--){n=read(),m=read();Init();for(int i=1;i<=n;i++)Add(i);for(int i=1;i<=n;i++)cout<<Ask(i)<<" ";cout<<"\n";}return 0;
}

例题二:AT_agc047_b

为了简化描述,之后将字符串的变化情况视作 \(s \to t\)

题目有句话,说,当 \(s\) 的长度 \(\ge 2\) 的时候删除前两个字符中的一个。

我们来考虑一下这句话的意思。首先 \(t\) 肯定有个后缀,和 \(s\) 的后缀一模一样,哪怕长度为 \(0\)

并且呢,如果上一步删去了 \(s\) 的前两个字符中位置靠后的那个,那么现在这一步就只能选择,保存最前面的字符,或把最前面的字符删去了。即最终经过若干次变化后的 \(s^{\prime}\) 的第一个字符,与 \(s\) 的那个与 \(t\) 重合的后缀,在不包含的情况下必然在 \(s\) 中相邻!

那么上字典树就好了。

实现很简单,在 Ask 函数里多算点东西就好了。不过由于计算时需要 \(f\) 数组来辅助,因此虽然此题没有 Del 函数但还是要计算 \(f\) 数组哦。并且这个 \(f\) 的定义与之前所提到的略有偏差,它不是走过的个个位置都记,而是在结尾处记录。

具体……看代码吧。

#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define pii pair<int,int>
#define fr first
#define se second
#define pb push_back
using namespace std;
const int N = 1e6+5;
int n,Cnt,tree[N][30];
LL f[N],Ans,p[30];string s[N];
void Add(string str){int now=0;for(int k=str.size()-1;k>=0;k--){int x=str[k]-'a';if(!tree[now][x])tree[now][x]=(++Cnt);now=tree[now][x];}f[now]++;return;
}
LL Ask(string str){memset(p,0,sizeof(p));for(int k=0;k<str.size();k++)p[str[k]-'a']++;int now=0;LL res=0;for(int k=str.size()-1;k>0;k--){int x=str[k]-'a';for(int i=0;i<26;i++)if(p[i])res+=f[tree[now][i]];now=tree[now][x],p[x]--;}return res;
}
int main(){ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);cin>>n;for(int i=1;i<=n;i++){cin>>s[i];Add(s[i]);}for(int i=1;i<=n;i++)Ans+=Ask(s[i]);cout<<Ans<<"\n";return 0;
}

0/1 Trie

什么是 0/1 Tire?

0/1 Trie,即 0/1 字典树,是普通字典树的子问题之一。与普通字典树不同的,其只存储 \(0\)\(1\) 两个数,这也与它的名字相符。

其一般用来存储二进制下的一些数字,常用于解决有关位运算(尤其是异或)的题目。

0/1 Tire 的插入、删除与查询

与普通字典树类似,故不多赘述。

但是注意两个区别:

  • 普通字典树在插入、删除与查询的时候都是根据当前传参的长度灵活调整的,因为只需要固定起点即可,但由于 0/1 Tire 多解决位运算问题,为实现贪心思想必须倒着来,从 \(8\)\(4\)\(2\) 再到 \(1\),因此枚举的时候一般固定从某值倒着枚到 \(0\)。通常上界为 \(30/32/60/63/64\)
  • \(tree\) 数组可以省下不少空间,由于往下连的边最多只有两条(一个点权为 \(0\),一个点权为 \(1\)),因此这棵树肯定是一棵二叉树,显然 \(tree\) 的第二维只需要开 \(2\) 的空间就足够了。

具体的实现可以参见下面两个例题的代码。

例题三:CF665E

这个思路比较一眼,吧。

定义 \(s_i = a_1 \oplus a_2 \oplus \dots \oplus a_{i-1} \oplus a_i\),且特殊的,\(s_0 = 0\)

那么区间 \([l,r]\) 的异或和就可以转化为 \(s_r \oplus s_{l-1}\) 了。

对,你说得对,就是前缀和的一个形式,只不过是前缀异或和罢了。

然后建 Trie 跑就完了。具体细节不多说了。

#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define pii pair<int,int>
#define fr first
#define se second
#define pb push_back
using namespace std;
const int N = 1e6+5 , M = 3e7+5;
int n,tree[M][2],Cnt=1;
LL k,a[N],sum,f[M],Ans;
LL read(){LL su=0,pp=1;char ch=getchar();while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}return su*pp;
}
void Add(LL num){LL now=1;for(LL i=30;i>=0;i--){LL x=(num>>i)&1;if(!tree[now][x])tree[now][x]=(++Cnt);now=tree[now][x],f[now]++;}return;
}
LL Ask(LL A,LL B){LL now=1,res=0;for(LL i=30;i>=0;i--){LL x=(A>>i)&1,y=(B>>i)&1;if(y)now=tree[now][1-x];else res+=f[tree[now][1-x]],now=tree[now][x];}return res+f[now];
}
int main(){n=read(),k=read();for(LL i=1;i<=n;i++){a[i]=read();Add(sum),sum^=a[i];Ans+=Ask(sum,k);}cout<<Ans<<"\n";return 0;
}

例题四:CF1980G

这个题目比较复杂,我讲详细一点。

首先定义这棵树的根为节点 \(1\)

定义 \(dis_u\) 表示点 \(u\) 到根节点 \(1\) 的边权异或和,\(f_{u,v}\) 表示点 \(u\) 去往点 \(v\) 的唯一路径上的边权异或和。

易知 \(f_{u,v} = dis_u \oplus dis_v\),因此只需要维护 \(dis\) 数组,\(f\) 数组就可以一目了然了。

显然每次操作二的答案 \(ans = dis_u \oplus dis_v \oplus x\)

\(dis_u \oplus x\) 是定值。那么 \(v\) 就可以通过 0/1 Tire 来轻松确定了。

解决了吗?并没有,因为题目还有操作一,这很烦。

显然不能真的改,可以用类似于线段树的懒标记那种,全局开个变量,算的时候异或上。假设名字叫 \(Xor\)

只可惜这样的话,不同的 \(v\) 异或上的 \(Xor\) 的个数就不一样了,因为深度不同。

这样还怎么字典树啊!

但是容易发现,偶数层的那些 \(v\) 点是不会异或上任意一个 \(Xor\) 的,因为异或了偶数个,会抵消。同样的,奇数层的那些 \(v\) 点就是会异或上一个 \(Xor\) 了。

那么开两个 0/1 Tire 即可。一个维护偶数层,一个维护奇数层。奇数层的查询函数,传参的时候注意是 \(dis_u \oplus x \oplus Xor\) 而非 \(dis_u \oplus x\)

那么此题就结束了,代码实现还是有一些些难度的。

#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define fr first
#define se second
#define pb push_back
using namespace std;
const int N = 2e5+5;
struct line{LL v,w;};
int T,n,Q,Cnt[2],tree[2][N*35][2],f[2][N*35];
LL dis[N],Xor;bool p[N];vector<line> g[N];
LL read(){LL su=0,pp=1;char ch=getchar();while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}return su*pp;
}
void DFS(int u,int fa){p[u]=!p[fa];for(auto [v,w]:g[u])if(v^fa)dis[v]=dis[u]^w,DFS(v,u);return;
}
void Add(LL num,int opt){LL now=1;for(LL i=32;i>=0;i--){LL x=(num>>i)&1;if(!tree[opt][now][x])tree[opt][now][x]=(++Cnt[opt]);now=tree[opt][now][x],f[opt][now]++;}return;
}
void Del(LL num,int opt){LL now=1;for(LL i=32;i>=0;i--){LL x=(num>>i)&1;now=tree[opt][now][x],f[opt][now]--;}return;
}
LL Ask(LL num,int opt){LL now=1,res=0;for(LL i=32;i>=0;i--){LL x=(num>>i)&1;if(tree[opt][now][1-x]&&f[opt][tree[opt][now][1-x]])now=tree[opt][now][1-x],res+=(1<<i);else now=tree[opt][now][x];}return res;
}
int main(){T=read();while(T--){n=read(),Q=read(),Xor=0;for(int o=0;o<=1;o++)for(int i=1;i<=Cnt[o];i++)tree[o][i][0]=0,tree[o][i][1]=0,f[o][i]=0;Cnt[0]=1,Cnt[1]=1;for(int i=1;i<=n;i++)g[i].clear();for(int i=1;i<n;i++){int x=read(),y=read(),z=read();g[x].pb({y,z}),g[y].pb({x,z});}p[0]=1,DFS(1,0);for(int i=1;i<=n;i++)Add(dis[i],p[i]);while(Q--){char opt;cin>>opt;if(opt=='^'){Xor^=read();continue;}LL u=read(),x=read();Del(dis[u],p[u]);LL Ans=max(Ask(dis[u]^x,p[u]),Ask(dis[u]^x^Xor,!p[u]));cout<<Ans<<" ";Add(dis[u],p[u]);}cout<<"\n";}return 0;
}

总结

字典树一般用来处理数组、字符串问题,常见的有“判断前后缀”“判断是否存在”“判断包含个数”等。

0/1 Tire 则一般用来存储数字的二进制情况,并且通常解决的问题是位运算中的异或运算。

想要掌握它,得能够灵活运用它。

码这么多字也不容易,还麻烦各位留个赞支持一下,真是太感谢啦!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/909014.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

go-mapus为局域网地图协作而生

很多年前就用mapus了,它是用实时数据库实现协作功能。可是实时数据库是国外的,不好用,速度慢,经常封掉。 这次直接用sqlite,简单快捷,加上websocket即可,没有原来想象的那么玄乎,代码也就几千行。 我把几十G的…

【工具变量】“国家级大数据综合试验区”试点城市DID(2000-2024年) - 教程

【工具变量】“国家级大数据综合试验区”试点城市DID(2000-2024年) - 教程2025-09-21 20:28 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x…

《手搓动态顺序表:从数组到自动扩容的华丽转身》 - 详解

《手搓动态顺序表:从数组到自动扩容的华丽转身》 - 详解pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas…

板子大全

整理一下常用的板子,并重新复习一下。线段树 维护复杂信息时重载 + 号,不同的修改直接在 upd() 中改。 struct SegTree{ #define ls u<<1 #define rs u<<1|1 #define mid ((l+r)>>1)ll tr[N<&l…

通过人大金仓数据库的逻辑备份与还原功能实现数据迁移

1、源数据库的操作 1.1、逻辑备份如上图所示,在源数据库进行备份。 2、目标数据库的操作 2.1、新建数据库如上图所示,新建一个目标数据库。如上图所示,设置新建数据库的名称(本例为 testDB )。如上图所示,新建目…

完整教程:GS1-128(EAN-128)编码构造方式

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

Chapter 7 Color Detection

img = cv2.imread(images/lambo.png)def stackImages(scale, imgArray):rows = len(imgArray)cols = len(imgArray[0])rowsAvailable = isinstance(imgArray[0], list)width = imgArray[0][0].shape[1]height = imgArr…

《原子习惯》-读书笔记7

2025.09.21 Day7 第6章 原动力被高估,环境往往更重要 1、产品或服务越是触手可及,你就越有可能去尝试。人们爱喝百威清啤的原因是每个酒吧里都供应它,而人们爱去星巴克的原因是它到处都有。[插图]我们喜欢一切尽在自…

PyQt数字转大写金额GUI程序开发及财务规范实现

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

第3周预习作业

Java 1. static 方法的使用与特性 应使用 static 修饰的方法:不依赖实例变量或实例方法的方法(工具方法)。 仅操作静态变量或参数的方法。 工厂方法、单例模式获取实例的方法。 纯函数式方法(无状态、输入决定输出…

01自我介绍加规划

01自我介绍加规划1. 自我介绍 大家好~我是一名大三的学生,在机缘巧合下我选择了数据科学与大数据技术这个专业,其实这纯粹是我经过多种排除下的一个结果,我第一pass掉了电子信息工程的电路,又觉得自己胜任不了需要…

《原子习惯》-读书笔记6

2025.09.19 Day6 1、研究人员向他们提出了额外要求,即他们需要为接下来的一周制订计划,明确何时何地进行锻炼。具体地说,第三组的每个成员都完成了下面这句话:“下周,我将于日时地进行为时至少20分钟的剧烈运动。…

Java LTS版本进化秀:从8到21的欢乐升级之旅

困惑于Java版本选择?轻松幽默地穿越Java LTS版本时光隧道,掌握从Java 8到21的关键特性。通过一家初创公司的系统升级故事,直观了解每个版本如何解决代码冗余、性能瓶颈等开发痛点,助你在技术选型中做出明智决策。嘿…

201912_EASER

压缩文件,掩码爆破,明文碰撞,伪加密,PNG高度隐写Tags:压缩文件,掩码爆破,明文碰撞,伪加密,PNG高度隐写 0x00. 题目 附件路径:https://pan.baidu.com/s/1GyH7kitkMYywGC9YJeQLJA?pwd=Zmxh#list/path=/CTF附件 附件名称…

搜索百科(3):Elasticsearch — 搜索界的“流量明星”

大家好,我是 INFINI Labs 的石阳。 欢迎关注 《搜索百科》 专栏!每天 5 分钟,带你速览一款搜索相关的技术或产品,同时还会带你探索它们背后的技术原理、发展故事及上手体验等。 前两篇我们探讨了搜索技术的基石 Ap…

打印机漏洞、匿名协议与AWS安全:一周技术热点解析

本周技术热点包括Windows打印机驱动漏洞攻击、MIT提出的Riffle匿名通信协议替代方案、AWS账户入侵后的持久化技术分析,以及Linus Torvalds对代码规范的争议性观点。文章还涵盖BSidesPhilly会议征稿通知。打印机水坑攻…

从零开始训练推理模型:GRPO+Unsloth改造Qwen实战指南

推理型大语言模型现在确实火了。这类模型的特点是会先对问题做充分思考,然后再给出答案,而不是直接回复。 虽然早期训练推理型 LLM 的方法多半被各家公司当作核心机密,但最近的DeepSeek-R1、DeepSeekMath、Kimi-k1.…

ALLinSSL,开源免费的SSL证书自动化管理平台

在网络安全日益重要的今天,SSL/TLS 证书不仅是 HTTPS 协议的基石,也是保护用户数据、提升 SEO 排名的关键。然而传统证书管理方式繁琐、低效,尤其在多域名或复杂云环境下,极易出错。ALLinSSL 作为一款开源免费的 S…

《原子习惯》-读书笔记5

2025.09.18 Day5 第5章 培养新习惯的最佳方式 1、随着习惯的形成,你的行为会受到你的潜意识的支配。你会身不由己地陷入旧的模式而不自知。2、你重复这些模式的次数越多,你就越不可能质疑自己在做什么以及为什么要这…