dsu on tree

树上启发式合并

dsu on tree 主要可以解决一些静态的子树信息查询的问题,当然也可以拓展到路径,最常见的套路就是强制该路径经过当前子树根节点。

启发式算法是基于人类的经验和直观感觉,对一些算法的优化。——来自 OI Wiki

简单来说,树上启发式合并就是合并子树信息时,直接继承重儿子的信息,然后暴力遍历轻儿子的子树合并信息。

主要使用场景是:每个子树都开空间记忆化信息会 MLE,每个子树都重新遍历一遍信息会 TLE。

最基础的 dsu on tree,时间复杂度是 \(O(n \log n)\),空间复杂度是 \(O(n)\)

e.g. 并查集启发式合并

相信你一定知道并查集的按秩合并:将深度小的并查集合并到深度大的并查集。

这样可以保证并查集树高是 \(\log n\) 级别的。

code

void merge(int x, int y) {
  int xx = find(x), yy = find(y);
  if (size[xx] < size[yy]) swap(xx, yy);
  fa[yy] = xx;
  size[xx] += size[yy];
}

——来自 OI Wiki

那么什么是 dsu on tree 呢?

例题

树上数颜色

给出一棵 \(n\) 个节点以 \(1\) 为根的树,节点 \(u\) 的颜色为 \(c_u\) ,有 \(m\) 次询问,每次给出结点 \(u\) 询问 \(u\) 子树里一共出现了多少种不同的颜色。

对于所有数据,有 \(1\le m,c_i\le n\le 10^5\)

暴力算法 \(O(nm)\)

每次询问遍历 \(u\) 的整棵子树。会超时。

树上莫队 \(O(n\sqrt n)\)

我不会。

dsu on tree \(O(nlogn)\)

今天的正题来啦~!

很简单的,几句话就讲完了。

考虑离线询问,预处理出答案后 \(O(1)\) 回答每个询问。

\(u\) 的信息可以通过他的儿子转移,但是空间不允许储存每个儿子的信息。

因此我们只用桶存储重儿子(在本题为 \(size\) 最大的儿子)的信息,然后暴力地遍历轻儿子的子树,将答案加到桶中。

实际实现可以先搜轻儿子,然后把桶清空。然后搜重儿子,不清空桶。然后处理自身。

对于点 \(u\),搜索的具体的步骤:

  1. 搜轻儿子,然后清空桶。
  2. 搜重儿子,不清空桶。
  3. \(u\) 的贡献加到桶里。

总时间复杂度 \(O(n \log n)\)


时空复杂度证明:

预处理重儿子时间复杂度是 \(O(n)\)

一个桶的空间是 \(O(n)\)。我们从始至终都是用这一个桶就够了。

轻儿子 \(v\) 的子树大小小于 \(u\) 子树大小的一半,即 \(siz_v < \frac{siz_u}{2}\)

所以递归轻儿子最多 \(\log n\) 层子树大小就为 \(0\) 了。

一个点 \(u\) 会被它的某些祖先暴力遍历轻儿子的子树时候搜到。

因为轻儿子 \(v\) 的子树大小小于 \(u\) 子树大小的一半,所以它只有至多 \(\log n\) 个祖先会暴力遍历它所在的子树。所以 \(u\) 至多被暴力搜 \(\log n\) 次。所以总时间复杂度是 \(O(n \log n)\) 的。

Code

#include<bits/stdc++.h>
#define ll long long
#define pf printf
#define sf scanf
using namespace std;
const int N=1e5+7;
int n,c[N];
int m,u; 
int v;
int sum[N];//每棵子树的不同颜色数
vector<int> son[N];
int siz[N],cnt[N];// cnt为桶
int gson[N];// 重儿子
int tot;// 当前不同颜色数
void dfs(int u,int fa){
	siz[u]=1;
	for(int v:son[u]){
		if(v==fa) continue;
		siz[u]+=siz[v];
		if(siz[v]>siz[gson[u]]) gson[u]=v;
	}
}
void add(int c) {if(!cnt[c]++) tot++;}
void del(int c) {if(!--cnt[c]) tot--;}
void change(int u,int fa,bool k){//k 表示加入或删除
	if(k) add(c[u]);
	else del(c[u]);
	for(int v:son[u]){
		if(v==fa) continue;
		change(v,u,k);
	}
}
void dsu(int u,int fa,bool k){// k 表示是否保留桶的信息
	for(int v:son[u]){// 计算轻儿子答案,算完删除桶
		if(v==fa||v==gson[u]) continue;
		dsu(v,u,0);
	}
	if(gson[u]) dsu(gson[u],u,1);// 计算重儿子答案,算完保留桶
	add(c[u]); // 加入 u 的贡献
	for(int v:son[u]){//暴力遍历合并轻儿子答案
		if(v==fa||v==gson[u]) continue;
		change(v,u,1);// 遍历整棵轻子树,复杂度 O(siz_v)
	}
	sum[u]=tot;//记录答案
	if(!k) change(u,fa,0);//清空桶(注意时间复杂度需要为 O(siz_u),直接 memset 会超时)
}
int main(){
	sf("%d",&n);
	for(int i=1;i<n;i++){
		sf("%d%d",&u,&v);
		son[u].push_back(v),son[v].push_back(u);
	}
	for(int i=1;i<=n;i++){
		sf("%d",&c[i]);//颜色
	}
	dfs(1,0);//找重儿子,O(n)
	dsu(1,0,0);
	sf("%d",&m);
	for(int i=1;i<=m;i++){
		sf("%d",&u);
		pf("%d\n",sum[u]);
	}
}

经验

点分治模板

点分治习题 Tree

以上两题都可以用 dsu on tree 由处理子树问题扩展到处理路径问题解决。

即处理 \(u\) 时,计算 \(u\) 子树内经过 \(u\) 的路径的答案。

具体做法参考题解。

XOR Tree

容易发现,如果可以更改 \(a_i\),则所有经过 \(i\) 的路径权值 \(d\) 都一定不为 \(0\)(将 \(a_i\) 改为极大的数即可)。

考虑深搜解决,也就是说,处理 \(u\) 时,\(u\) 的子树已处理完毕。

对于 \(d(u,v)=0\),更改 \(\text{lca}(u,v)\) 一定是最优的,证明如下:

\((u,lca)\) 上有一点 \(k\),存在一条经过 \(k\) 权值为 \(0\) 的路径 \((l,r)\),其中 \(r\)\(l\) 的祖先,那么 \((l,r)\) 必定经过 \(lca\),否则该路径已经处理过了,\(d(l,r)\neq 0\)

因此更改 \(k\) 是不优的,更改 \(lca\) 的祖先无法更改到 \((u,v)\)

因此更改 \(lca\) 是最好的。

首先预处理出每个结点到根的路径权值,即 \(d_u\),方便后面计算。

然后我们对每个点开一个 set,储存他的子树内所有点到根节点的权值。

考虑启发式合并,每个结点继承 \(set_{size}\) 最大的儿子的 \(set\),然后暴力枚举合并其他儿子。

写法上有很多种,比如可以这么写:

set<int> s[N];
// 下面是函数 dsu 的部分代码
dsu(v,u);
if(s[v].size()>s[u].size()) swap(s[u],s[v]);// 更新重儿子
for(int x:s[v]) if(s[u].find(x^a[u])!=s[u].end()) k=1; // k 是用来判断是否要清空 set
for(int x:s[v]) s[u].insert(x); // 启发式合并重儿子

计算答案只需在 \(set\) 里使用 \(find\) 函数查询是否存在值 \(d_x \ xor\ a_u\),若存在,则点 \(u\) 需要更改,答案 \(+1\),同时清空 \(set_u\)

复杂度因为有 set,是两只 \(log\) 的,但是 dsu 常数实在是很小。

演唱会

P4556 [Vani有约会] 雨天的尾巴 /【模板】线段树合并 线段树合并板子当然要用可爱的 dsu on tree 做啦因为维护最大值需要再开一个数据结构维护,我使用了常数较小的 priority_queue,时间比线段树合并多一个 \(\log\),是 \(O(n \log^2 n)\) 的,但是可能因为可爱的 dsu 的优秀常数应该说是因为人见人爱花见花开的线段树的巨大常数导致在没有优化常数的情况下甚至也可以吊打线段树合并。

posted @ 2024-08-13 21:32  wing_heart  阅读(60)  评论(0)    收藏  举报