Trie 一轮复习

字典树-Trie

字典树,顾名思义,就是一个像字典一样的树。
—— OI-wiki

普通 Trie

如图:

Trie 用边代表字母,那么从根节点到某个节点的路径表示一个字符串。

Trie 支持的操作有三个:

  1. 插入字符串
  2. 查询字符串是否存在
  3. 删除字符串

最常用的是前两个(比如模板)。

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 更不容易出错。

删除

首先说明,上题和大多数题中并没有此操作。

删除的时候分情况讨论:

  1. 当前节点不是叶子节点。

    清除标记即可。

  2. 当前节点为叶子节点,且根节点到当前节点有且仅有一个标记。

    删除整条路径就好。

  3. 当前节点为叶子节点,且根节点到当前节点不只有一个标记。

    从叶子节点删到上一个标记节点。

其实后两种情况差不多,都是从当前节点向上删。

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

(不会,不想学)

可持久化

(不会,待学)

posted @ 2022-09-18 08:35  Zvelig1205  阅读(78)  评论(0编辑  收藏  举报