Trie 一轮复习
字典树-Trie
字典树,顾名思义,就是一个像字典一样的树。
—— OI-wiki
普通 Trie
如图:
Trie 用边代表字母,那么从根节点到某个节点的路径表示一个字符串。
Trie 支持的操作有三个:
- 插入字符串
- 查询字符串是否存在
- 删除字符串
最常用的是前两个(比如模板)。
Trie 的储存
个人习惯用结构体来表示某种数据结构的节点:
struct Trie{
int ch[26];
bool end_,vis;
}T[inf];
题目的数据范围:\(n\le10^4,s\le50\)。
那么 Trie 中插入的点最多有 \(5\times10^5\)。
所以 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。
一般用于解决一堆数的 异或 最大 / 最小值问题(因此会牵扯到部分位运算知识,不了解的不建议继续阅读)。
还有一个应用是代替平衡树。
异或最大值
以最大值为例,要使异或和最大,应该满足什么样的条件?
尽可能在 高位 上 多 出现 \(0\oplus1\) 或 \(1\oplus0\) 这两种情况。
即贪心的在高位上优先选择 \(1\)。显然这样贪心是对的,因为 \(11\underset{k\text{个}0}{\underbrace{0\cdots0}}>10\underset{k\text{个}1}{\underbrace{1\cdots1}}\)。
提前说明一下,因为我用的是递归实现,所以和网上流传的迭代 01Trie 可能不太一样。但如果想学迭代实现,也还是建议先看一下思想,然后到 迭代 部分查看代码。
递归
首先解决插入问题。
由于 Trie 的根应该对应数的最高位,所以要先将原数的二进制位进行翻转。
数据范围 \(0\le w<2^{31}\),所以 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
。
因为每次取最高位,我们从 \(2^{30}\) for
到 \(0\),然后根据当前位判断在 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;
}
时空复杂度
大概都是 \(O(n\log w)\),其中 \(n\) 为数的个数,\(w\) 为值域。
证明:
显然。
证毕。
注意空间,数组不要开小。
带删 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;
}
值得注意的是,这里的主函数中存在 \(\pm 1e7\) 的操作,这是为了让操作的数全是正数,避免一些奇怪的判断。
虽然 01Trie 挺短的,但是其空间复杂度较大,数据加强版被卡空间,想要 AC 需要单链压缩。
听说很难,等我学了再更。
压位 Trie
(不会,不想学)
可持久化
(不会,待学)