D S U : D i s j o i n t S e t U n i o n DSU:Disjoint\;Set\;Union DSU:DisjointSetUnion
中文名:并查集
d s u o n t r e e dsu\;on\;tree dsuontree直译过来就是“在树上的并查集”
但并不是这样
你或许也听过这样一种说法
d s u o n t r e e = dsu\;on\;tree= dsuontree= 树上启发式合并
实际上
d s u o n t r e e ! = dsu\;on\;tree!= dsuontree!= 树上启发式合并
为什么说 d s u o n t r e e ! = dsu\;on\;tree!= dsuontree!= 树上启发式合并呢
首先我们先来思考一下
“启发式合并”这个词以前你是否听过?
如果没有,那么换种说法,“按秩合并”这个词以前你是否听过?
如果还没有,那么联系一下本文最开头提到的并查集
实际上,并查集的按秩合并,就是树上启发式合并
这也许就是 d s u dsu dsu和 d s u o n t r e e dsu\;on\;tree dsuontree的唯一共通之处吧
先回忆一下什么是并查集
可能很多同学都对并查集的理解就只局限于了简单的几行代码和它实际的应用
如同我们所知的众多 S T L STL STL,他们都是由一些底层的数据结构来维护的,例如堆是用二叉树, s e t set set是用红黑树
并查集虽然不是 S T L STL STL,但它实际上也有着内部的结构:树
道理很简单,每个节点都有着它自己的 f a fa fa,这样的结构不就是一颗树吗?
那么并查集是如何高效的合并两棵树的呢?
利用路径压缩和按秩合并
实际上我们平时只会用路径压缩,因为仅使用这个就已经很高效了
如果现在给你两棵树,让你把他们合并在一起,且不需要关心树的结构,你会怎么做?
最暴力的做法当然就是把一棵树上所有节点的 f a fa fa全部设置为另一棵树上的某个节点
那假设你的两棵树的大小分别为 1 1 1和 1000000 1000000 1000000呢
显然,你不会把后者合并到前者上,因为这样时间复杂度就太高了
一句话归纳:把小树合并到大树上,这就是树上启发式合并
按秩合并同理,是把小的并查集合到大的并查集上
这样听下来,如果 d s u o n t r e e = dsu\;on\;tree= dsuontree= 树上启发式合并,那么这个算法是不是就很轻松的讲完了?
在讲 d s u o n t r e e dsu\;on\;tree dsuontree前,我们还需了解一个概念:轻重边
这个概念的最主要的应用在于树链剖分,但这里不过多介绍
先来了解:重儿子
在一棵有根树中,对于一个节点 x x x,他会有许多的子节点,在这些子节点中,只有一个称得上是它的重儿子
即子树节点个数最多的那个儿子
我们将一个节点 x x x和它重儿子之间的边成为重边,其他边为轻边
在我看来, d s u o n t r e e dsu\;on\;tree dsuontree和树上启发式合并没有什么必然联系
这个算法的本质在于利用轻重边的性质,做一个看似很暴力,实际上复杂度很低的事情
我们引入一道例题:
给定一棵有根树,每个节点 i i i有颜色 c i c_i ci,求出每个节点的子树中,出现次数最多的颜色是什么,如果解不唯一,输出任意一个即可
首先考虑最暴力做法:
暴力枚举每个节点 x x x,遍历 x x x子树内的所有节点,统计出现次数最多的颜色是什么
复杂度显然是 O ( n 2 ) O(n^2) O(n2)的
这时候就有人要问了:
“为什么一定要遍历每个节点 x x x?做一遍树形 d p dp dp不就轻松搞定了吗”
这个算法固然有一定道理,但它存在很大的一个弊端:
如果节点 x x x有 10000 10000 10000个儿子,你该如何统计每种颜色的出现次数呢?
你需要开 10000 10000 10000个数组,每个子节点都需要单独开一个数组记录它子树中每种颜色出现次数
空间远不能接受
这两种做法,前者的时间爆炸,后者的空间爆炸
我们的目的就是要设计一个算法来将二者结合,时间优化为 O ( n l o g n ) O(nlogn) O(nlogn),空间优化为 O ( n ) O(n) O(n)
这个算法就是本文的重头戏: d s u o n t r e e dsu\;on\;tree dsuontree
遍历每一个节点
\; 递归轻儿子
\; 递归重儿子,将重儿子的信息继承下来
\; 暴力统计所有轻儿子对答案的影响
\; 更新该节点的答案
\; 若当前节点为重儿子,将当前节点信息向上传递,否则清空信息
简单来说,我们结合了刚才的两种做法,进行着类似于树形 d p dp dp的操作,但并不对于每个节点都存储它子树内的颜色出现次数,而是对于一个节点 x x x,直将它最重要的一个子节点 y y y的信息传递给它,其余的子节点全部都重新递归下去统计答案,节点 y y y显然应该是 x x x的重儿子
乍一看是不是稀里糊涂的,没关系,我们结合这题的代码具体讲解
#include
using namespace std;
const int N=1e5+10;
int n,c[N],tot=0,head[N],siz[N],son[N],cnt[N],Son,maxx;
long long ans[N];
struct Star{int nxt,to;}edge[2*N];
void Lian(int x,int y){tot++;edge[tot].nxt=head[x];edge[tot].to=y;head[x]=tot;}
void dfs1(int x,int fa)//求重儿子
{
siz[x]=1;
int maxx=0;
for(int i=head[x];i;i=edge[i].nxt)
{
int y=edge[i].to;
if(y==fa) continue;
dfs1(y,x);
siz[x]+=siz[y];
if(siz[y]>maxx) maxx=siz[y],son[x]=y;
}
}
void change(int x,int fa,int k)//递归更新答案
{
cnt[c[x]]+=k;
if(cnt[c[x]]>maxx) maxx=cnt[c[x]];//更新maxx
for(int i=head[x];i;i=edge[i].nxt)
{
int y=edge[i].to;
if(y==fa||y==Son) continue;
change(y,x,k);
}
}
void dfs2(int x,int fa,int typ)//typ表示x是否为fa的重儿子
{
for(int i=head[x];i;i=edge[i].nxt)
{
int y=edge[i].to;
if(y==fa||y==son[x]) continue;
dfs2(y,x,0); //递归解决轻儿子
}
if(son[x]) dfs2(son[x],x,1),Son=son[x];//递归解决重儿子,并将重儿子的答案继承下来,记录重儿子,为后面做铺垫
//先轻后重是因为我们只有一个数组,要把这个数组完整地从重儿子处交给当前节点,应该最后遍历重儿子
change(x,fa,1);//暴力递归统计轻儿子答案,由于重儿子已经被统计过了,所以当节点==Son时就continue
ans[x]=maxx;//更新答案
Son=0;//将Son初始化
//现在我们需要向上传递,如果当前节点x是它父节点的重儿子,那就不需要清空信息,直接传上去就好,否则就需要把当前的信息清空掉
if(!typ) change(x,fa,-1),maxx=0;
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&c[i]);
for(int i=1,x,y;i<n;i++) scanf("%d%d",&x,&y),Lian(x,y),Lian(y,x);
dfs1(1,0);
dfs2(1,0,0);
for(int i=1;i<=n;i++) printf("%lld ",ans[i]);
return 0;
}
仍以此图为例
对于1号点,考虑它被遍历了多少次
1.统计1号点答案,次数1
2.统计3号点答案,暴力递归轻子树,次数2
3.统计4号点答案,3号点为4号点的重儿子,它子树的信息已包括1号点,无需额外递归统计,次数2
4.统计5号点答案,4号点为5号点的重儿子,它子树的信息已包括1号点,无需额外递归统计,次数2
总次数:2
同理再来计算一下2号点
1.统计2号点答案,次数1
2.统计7号点答案,暴力递归轻子树,次数2
3.统计6号点答案,7号点为6号点的重儿子,它子树的信息已包括2号点,无需额外递归统计,次数2
4.统计5号点答案,暴力递归轻子树,次数3
总次数:3
不难看出,节点 x x x被遍历的次数为 x x x到达根节点所经过的轻边数量 + 1 +1 +1
那么每个节点到根节点的轻边数量为多少呢?
对于一个节点 a a a,设它到达根节点需要经过 x x x条轻边,该节点子树大小为 s i z [ a ] siz[a] siz[a]
若它为父节点 b b b的轻儿子,那么必然存在一个 c c c为 b b b的重儿子,满足 s i z [ c ] > s i z [ a ] siz[c]>siz[a] siz[c]>siz[a]
则 s i z [ b ] > s i z [ c ] + s i z [ a ] > 2 × s i z [ a ] siz[b]>siz[c]+siz[a]>2\times siz[a] siz[b]>siz[c]+siz[a]>2×siz[a]
那么可推出: n = s i z [ r o o t ] > 2 x × s i z [ a ] n=siz[root]>2^x\times siz[a] n=siz[root]>2x×siz[a]
则 x < l o g 2 n x
故每个节点到根节点的轻边数量一定小于 l o g 2 n log_2n log2n
所以可得出 d s u o n t r e e dsu\;on\;tree dsuontree的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
对于空间复杂度,由于全程就只循环使用同一个数组,所以复杂度显然为 O ( n ) O(n) O(n)
1.给定一棵有根树,多次询问,每次询问给出 x x x和 d d d,求 x x x的子树内有多少节点到达 x x x的距离为 d d d
2.给定一棵有根树,每个节点上有一个字符,定义树上一条路径为特殊的,当仅当路径上的所有字符经过重新排序后可以变成一个回文串,求每个节点的子树中最长的特殊路径长度
3.给定一棵有根树,求
∑ i = 1 n ∑ j = i + 1 n [ a i ⊕ a j = a l c a ( i , j ) ] ( i ⊕ j ) \sum_{i=1}^n\sum_{j=i+1}^n[a_i⊕a_j=a_{lca(i,j)}](i⊕j) ∑i=1n∑j=i+1n[ai⊕aj=alca(i,j)](i⊕j)
4.给定一棵有根树,每个节点有一个权值 v a l val val,求有多少不同点对 ( x , y ) (x,y) (x,y),满足 x x x不是 y y y的祖先, y y y不是 x x x的祖先,并且 v a l [ x ] + v a l [ y ] = 2 × v a l [ l c a ( x , y ) ] val[x]+val[y]=2\times val[lca(x,y)] val[x]+val[y]=2×val[lca(x,y)]
5.给定一棵有根树,每个节点有一个权值 v a l val val,一个体积 V V V,多次询问,每次询问给出 x x x和 s s s,求在 x x x的子树内选出一些大小不超过 s s s的节点,最大权值为多少