Trie 一轮复习
字典树-Trie
字典树,顾名思义,就是一个像字典一样的树。
—— OI-wiki
普通 Trie
如图:
Trie 用边代表字母,那么从根节点到某个节点的路径表示一个字符串。
Trie 支持的操作有三个:
- 插入字符串
- 查询字符串是否存在
- 删除字符串
最常用的是前两个(比如模板)。
Trie 的储存
个人习惯用结构体来表示某种数据结构的节点:
struct Trie{ int ch[26]; bool end_,vis; }T[inf];
题目的数据范围:。
那么 Trie 中插入的点最多有 。
所以 inf=5e5+7
。
ch
表示子节点,共 26 个(根据题目不同子节点的的个数可能不同),end_
表示这个点是不是字符串的结尾,vis
表示是不是第一次查找到这个点(其他题应该用不到)。
插入
每找到一个节点,如果当前节点没有相应字母所对应的子节点,就新建一个节点作为其节点,直到整个字符串结束,标记一下 end_
。
没错就是线段树、平衡树那里的动态开点。
void insert(int now,int i) { if(i==len){T[now].end_=1;return;} if(T[now].ch[a[i]]==0) T[now].ch[a[i]]=++cnt; insert(T[now].ch[a[i]],i+1); }
当然也可以用迭代,但感觉迭代没有递归好理解。
(刚开始学的时候用的迭代,这次复习才弄递归)
void build(char *s) { int now=1,i=0,len=strlen(s); while(i<len) { if(trie[now][a[i]]==0) trie[now][a[i]]=++cnt; now=trie[now][a[i]],i++; } end_[now]=1; }
接下来的代码将不再展示迭代,因为远古时期的码风是在太丑了。
查询
首先查找此字符串是否存在,如果按找路径找到某个节点为空则不存在。
再看最终节点是否为结束的节点,即有没有 end_
标记。
最后在看是不是第一次查找到,即有没有 vis
标记。
#define OK 0 #define WRONG 1 #define REPEAT -1 int ask(int now,int i) { if(now==0)return WRONG; if(i==len) { if(T[now].end_==0)return WRONG; if(T[now].vis==1)return REPEAT; T[now].vis=1; return OK; } return ask(T[now].ch[a[i]],i+1); }
此处的 define
更不容易出错。
删除
首先说明,上题和大多数题中并没有此操作。
删除的时候分情况讨论:
-
当前节点不是叶子节点。
清除标记即可。
-
当前节点为叶子节点,且根节点到当前节点有且仅有一个标记。
删除整条路径就好。
-
当前节点为叶子节点,且根节点到当前节点不只有一个标记。
从叶子节点删到上一个标记节点。
其实后两种情况差不多,都是从当前节点向上删。
bool pd_ye; void remove(int now,int i) { if(now==0)return; if(i==len) { pd_ye=1; for(int j=0;j<26;j++) if(T[now].ch[j])pd_ye=0; T[now].end_=0,T[now].vis=0; return; } remove(T[now].ch[a[i]],i+1); if(pd_ye&&T[T[now].ch[a[i]]].end_==0) T[now].ch[a[i]]=0; else pd_ye=0; }
但这样操作可能会被卡空间:不断地插入删除,虽然 Trie 的规模很小,实际的数组花费很大。
和 Fhq_Treap 那里一样,可以弄一个 垃圾场,将删除的数存到垃圾场里,等在动态开点的时候直接去垃圾场里找点。
stack<int>bin; void insert(int now,int i) { if(i==len){T[now].end_=1;return;} if(T[now].ch[a[i]]==0) { if(bin.empty())T[now].ch[a[i]]=++cnt; else T[now].ch[a[i]]=bin.top(),bin.pop(); } insert(T[now].ch[a[i]],i+1); } bool pd_ye; void remove(int now,int i) { if(now==0)return; if(i==len) { pd_ye=1; for(int j=0;j<26;j++) if(T[now].ch[j])pd_ye=0; T[now].end_=0,T[now].vis=0; return; } remove(T[now].ch[a[i]],i+1); if(pd_ye&&T[T[now].ch[a[i]]].end_==0) bin.push(T[now].ch[a[i]]),T[now].ch[a[i]]=0; else pd_ye=0; }
应该是对的,但我不是很确定(因为没找到例题),欢迎 dalao hack 和提供正确的代码。
例题找到了,但是是 01Trie 的题,而且删除的代码和这个不怎么一样。绝对正确的带删除 Trie 的代码请自行向下翻找吧。
(这里懒得维护了嘻嘻。)
通过这三个操作,就可以在较短的时间里实现插入、查找、删除一个字符串等操作。
AC Code:
const int inf=5e5+7; int n,m,len,a[57]; char s[57]; struct Trie{ int ch[26]; bool end_,vis; }T[inf]; int cnt; void insert(int now,int i) { if(i==len){T[now].end_=1;return;} if(T[now].ch[a[i]]==0) T[now].ch[a[i]]=++cnt; insert(T[now].ch[a[i]],i+1); } #define OK 0 #define WRONG 1 #define REPEAT -1 int ask(int now,int i) { if(now==0)return WRONG; if(i==len) { if(T[now].end_==0)return WRONG; if(T[now].vis==1)return REPEAT; T[now].vis=1; return OK; } return ask(T[now].ch[a[i]],i+1); } int main() { n=re(); for(int i=1;i<=n;i++) { scanf("%s",s);len=strlen(s); for(int j=0;j<len;j++)a[j]=s[j]-'a'; insert(1,0); } m=re(); for(int i=1;i<=m;i++) { scanf("%s",s);len=strlen(s); for(int j=0;j<len;j++)a[j]=s[j]-'a'; int ls=ask(1,0); if(ls==OK)puts("OK"); if(ls==REPEAT)puts("REPEAT"); if(ls==WRONG)puts("WRONG"); } return 0; }
01Trie
普通的 Trie 是一种 26 叉树,实际上更常用的是 2 叉树,也就是 01Trie。
一般用于解决一堆数的 异或 最大 / 最小值问题(因此会牵扯到部分位运算知识,不了解的不建议继续阅读)。
还有一个应用是代替平衡树。
异或最大值
以最大值为例,要使异或和最大,应该满足什么样的条件?
尽可能在 高位 上 多 出现 或 这两种情况。
即贪心的在高位上优先选择 。显然这样贪心是对的,因为 。
提前说明一下,因为我用的是递归实现,所以和网上流传的迭代 01Trie 可能不太一样。但如果想学迭代实现,也还是建议先看一下思想,然后到 迭代 部分查看代码。
递归
首先解决插入问题。
由于 Trie 的根应该对应数的最高位,所以要先将原数的二进制位进行翻转。
数据范围 ,所以 Trie 的深度大概为 31。
既然都是 31,那么 end_
也就没有存在的必要了。
void insert(int &i,int k,int dep) { if(i==0)i=++num; if(dep==31)return; insert(T[i].ch[k&1],k>>1,dep+1); } void chuli(int k) { int s=0,dep=0; while(k)s=s<<1|(k&1),k>>=1,dep++; while(dep<31)s<<=1,dep++; insert(rot,s,0); }
然后是查询。
我比较标新立异(主要是当时了解思想后没看代码,自己想的),用的 dfs。
找两个指针,分别指向 Trie 的两个节点,尽力 让这两个节点异或得 1。
void ask(int x,int y,int dep,int sum) { if(dep==31){ ans=max(ans,sum); return; } bool pd=0;//最优性剪枝 //当前两种情况成立的时候,后两种无论如何拿不到最优解,所以不需要搜 if(T[x].ch[0]&&T[y].ch[1]) pd=1,ask(T[x].ch[0],T[y].ch[1],dep+1,sum<<1|1); if(T[x].ch[1]&&T[y].ch[0]) pd=1,ask(T[x].ch[1],T[y].ch[0],dep+1,sum<<1|1); if(!pd&&T[x].ch[0]&&T[y].ch[0]) ask(T[x].ch[0],T[y].ch[0],dep+1,sum<<1); if(!pd&&T[x].ch[1]&&T[y].ch[1]) ask(T[x].ch[1],T[y].ch[1],dep+1,sum<<1); }
这题有一个前置知识,就是 树上前缀和。
那么最长异或路径也就是两个节点前缀和的异或值最大。
代码:
const int inf=1e5+7; int n,ans; int fir[inf],nex[inf<<1],poi[inf<<1],val[inf<<1],cnt; void ins(int x,int y,int z) { nex[++cnt]=fir[x]; poi[cnt]=y; val[cnt]=z; fir[x]=cnt; } int fa[inf],sum[inf]; void dfs(int now,int from) { fa[now]=from; for(int i=fir[now];i;i=nex[i]) { int p=poi[i]; if(p==from)continue; sum[p]=sum[now]^val[i]; dfs(p,now); } } struct Tire01{ int ch[2]; }T[inf*30]; int num,rot; void insert(int &i,int k,int dep) { if(i==0)i=++num; if(dep==31)return; insert(T[i].ch[k&1],k>>1,dep+1); } void chuli(int k) { int s=0,dep=0; while(k)s=s<<1|(k&1),k>>=1,dep++; while(dep<31)s<<=1,dep++; insert(rot,s,0); } void ask(int x,int y,int dep,int sum) { if(dep==31){ ans=max(ans,sum); return; } bool pd=0; if(T[x].ch[0]&&T[y].ch[1]) pd=1,ask(T[x].ch[0],T[y].ch[1],dep+1,sum<<1|1); if(T[x].ch[1]&&T[y].ch[0]) pd=1,ask(T[x].ch[1],T[y].ch[0],dep+1,sum<<1|1); if(!pd&&T[x].ch[0]&&T[y].ch[0]) ask(T[x].ch[0],T[y].ch[0],dep+1,sum<<1); if(!pd&&T[x].ch[1]&&T[y].ch[1]) ask(T[x].ch[1],T[y].ch[1],dep+1,sum<<1); } int main() { n=re(); for(int i=1;i<n;i++) { int u=re(),v=re(),w=re(); ins(u,v,w),ins(v,u,w); } dfs(1,1); for(int i=1;i<=n;i++) chuli(sum[i]); ask(rot,rot,0,0); wr(ans),putchar('\n'); return 0; }
迭代
迭代实现在网上就比较多了。
和之前的迭代插入不太相同,这里用的是 for
而非 while
。
因为每次取最高位,我们从 for
到 ,然后根据当前位判断在 Trie 中应该是左节点还是右节点。
虽然动态开点,但根的位置不会改变,所以默认为 0。
void insert(int k) { int now=0; for(int i=(1<<30);i;i>>=1) { bool s=k&i; if(!T[now].ch[s]) T[now].ch[s]=++num; now=T[now].ch[s]; } }
查询时,对于数列里的每个值,都在 Trie 的高位尽力找 1。
int ask(int k) { int ret=0,now=0; for(int i=(1<<30);i;i>>=1) { bool s=k&i;ret<<=1; if(!T[now].ch[s^1])now=T[now].ch[s]; else now=T[now].ch[s^1],ret++; } return ret; }
不完整代码(树上前缀和没放)
struct Trie01{ int ch[2]; }T[inf*10]; int num; void insert(int k) { int now=0; for(int i=(1<<30);i;i>>=1) { bool s=k&i; if(!T[now].ch[s]) T[now].ch[s]=++num; now=T[now].ch[s]; } } int ask(int k) { int ret=0,now=0; for(int i=(1<<30);i;i>>=1) { bool s=k&i;ret<<=1; if(!T[now].ch[s^1])now=T[now].ch[s]; else now=T[now].ch[s^1],ret++; } return ret; } int main() { n=re(); for(int i=1;i<n;i++) { int u=re(),v=re(),w=re(); ins(u,v,w),ins(v,u,w); } dfs(1,1); for(int i=1;i<=n;i++) insert(sum[i]); for(int i=1;i<=n;i++) ans=max(ans,ask(sum[i])); wr(ans); return 0; }
时空复杂度
大概都是 ,其中 为数的个数, 为值域。
证明:
显然。
证毕。
注意空间,数组不要开小。
带删 01Trie
只是在原来的基础上多了个删除操作。
说实话,我没想起来怎么用迭代实现,所以还是用递归。
在这里我们维护一个 siz
表示以当前节点为根的子树包含的数的个数。插入一个数(已通过上述的 chuli
函数处理)的时候,路径上的节点 siz++
。而删除的时候,其路径上的节点 siz--
,那么在回溯时,如果当前节点的 siz
为 0,就直接删除此节点(放入垃圾回收站)。
void insert(int &i,int k,int dep) { if(i==0) { if(bin.empty())i=++cnt; else i=bin.top(),bin.pop(); } T[i].siz++; if(dep==31)return; insert(T[i].ch[k&1],k>>1,dep+1); } void remove(int &i,int k,int dep) { T[i].siz--; if(dep==31) { if(!T[i].siz)bin.push(i),i=0; return; } remove(T[i].ch[k&1],k>>1,dep+1); if(!T[i].siz)bin.push(i),i=0; }
感觉很好理解。
至于查询操作,我们把迭代实现的思路借鉴过来,然后 6 行解决:
int ask(int i,int k,int dep,int sum) { if(dep==31)return sum; if(!T[i].ch[(k&1)^1])return ask(T[i].ch[k&1],k>>1,dep+1,sum<<1); else return ask(T[i].ch[(k&1)^1],k>>1,dep+1,sum<<1|1); }
不得不说,还是递归好理解。
完整代码:
const int inf=2e5+7; int n,ans; char op[2]; struct Tire01{ int ch[2],siz; }T[inf*40]; int cnt,rot; int chuli(int k) { int s=0,dep=0; while(k)s=s<<1|(k&1),k>>=1,dep++; while(dep<31)s<<=1,dep++; return s; } stack<int>bin; void insert(int &i,int k,int dep) { if(i==0) { if(bin.empty())i=++cnt; else i=bin.top(),bin.pop(); } T[i].siz++; if(dep==31)return; insert(T[i].ch[k&1],k>>1,dep+1); } void remove(int &i,int k,int dep) { T[i].siz--; if(dep==31) { if(!T[i].siz)bin.push(i),i=0; return; } remove(T[i].ch[k&1],k>>1,dep+1); if(!T[i].siz)bin.push(i),i=0; } int ask(int i,int k,int dep,int sum) { if(dep==31)return sum; if(!T[i].ch[(k&1)^1])return ask(T[i].ch[k&1],k>>1,dep+1,sum<<1); else return ask(T[i].ch[(k&1)^1],k>>1,dep+1,sum<<1|1); } int main() { n=re();insert(rot,0,0); for(int i=1;i<=n;i++) { scanf("%s",op); int k=re();k=chuli(k); if(op[0]=='+')insert(rot,k,0); if(op[0]=='-')remove(rot,k,0); if(op[0]=='?')wr(ask(rot,k,0,0)),putchar('\n'); } return 0; }
平衡树
之前,在权值线段树那里,我们提到过:“平衡树的题不能用平衡树来做”,现在,我们就来贯彻这个思想。
其实思路和权值线段树差不多。
权值线段树为什么能维护平衡树的问题?
因为线段树维护的桶是单调递增的。
而 01Trie 的叶子节点也是单调递增的,所以 01Trie 也是可以代替平衡树的。
基本操作和权值线段树相同,此处就直接贴代码了:
const int inf=1e5+7; int n; struct Trie01{ int ch[2]; int siz; }T[inf*20]; int cnt,rot; int chuli(int k) { int s=0,dep=0; while(k)s=s<<1|(k&1),k>>=1,dep++; while(dep<31)s<<=1,dep++; return s; } stack<int>bin; void insert(int &i,int k,int dep) { if(i==0) { if(bin.empty())i=++cnt; else i=bin.top(),bin.pop(); } T[i].siz++; if(dep==31)return; insert(T[i].ch[k&1],k>>1,dep+1); } void remove(int &i,int k,int dep) { T[i].siz--; if(dep==31) { if(!T[i].siz)bin.push(i),i=0; return; } remove(T[i].ch[k&1],k>>1,dep+1); if(!T[i].siz)bin.push(i),i=0; } int ask_rnk(int now,int k,int dep) { if(dep==31)return 1; int ans=ask_rnk(T[now].ch[k&1],k>>1,dep+1); if(k&1)ans+=T[T[now].ch[0]].siz; return ans; } int ask_kth(int now,int k,int dep,int ans) { if(dep==31)return ans; if(k<=T[T[now].ch[0]].siz)return ask_kth(T[now].ch[0],k,dep+1,ans<<1); return ask_kth(T[now].ch[1],k-T[T[now].ch[0]].siz,dep+1,ans<<1|1); } int main() { n=re(); for(int i=1;i<=n;i++) { int op=re(),k=re(); if(op==1)insert(rot,chuli(k+1e7),0); if(op==2)remove(rot,chuli(k+1e7),0); if(op==3)wr(ask_rnk(rot,chuli(k+1e7),0)),putchar('\n'); if(op==4)wr(ask_kth(rot,k,0,0)-1e7),putchar('\n'); if(op==5)wr(ask_kth(rot,ask_rnk(rot,chuli(k+1e7),0)-1,0,0)-1e7),putchar('\n'); if(op==6)wr(ask_kth(rot,ask_rnk(rot,chuli(k+1e7+1),0),0,0)-1e7),putchar('\n'); } return 0; }
值得注意的是,这里的主函数中存在 的操作,这是为了让操作的数全是正数,避免一些奇怪的判断。
虽然 01Trie 挺短的,但是其空间复杂度较大,数据加强版被卡空间,想要 AC 需要单链压缩。
听说很难,等我学了再更。
压位 Trie
(不会,不想学)
可持久化
(不会,待学)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】