浅谈字典树

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


字典树

什么是字典树?

字典树,顾名思义它是一棵类似于字典的树,用树的形态存储字符串集合。具体地,它有一个自定义的无意义的根节点(通常编号为 \(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 则一般用来存储数字的二进制情况,并且通常解决的问题是位运算中的异或运算。

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

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

posted @ 2025-09-21 20:37  嘎嘎喵  阅读(131)  评论(5)    收藏  举报