[CSP-S2019]树的重心(换根+倍增/树状数组)

题面分析

原题面

小简单正在学习离散数学,今天的内容是图论基础,在课上他做了如下两条笔记:

  • 一个大小为 n n n 的树由 n n n 个结点与 n − 1 n−1 n1 条无向边构成,且满足任意两个结点间有且仅有一条简单路径。在树中删去一个结点及与它关联的边,树将分裂为若干个子树;而在树中删去一条边(保留关联结点,下同),树将分裂为恰好两个子树。
  • 对于一个大小为 n n n 的树与任意一个树中结点 c c c,称 c c c 是该树的重心当且仅当在树中删去 c c c 及与它关联的边后,分裂出的所有子树的大小均不超过 ⌊ n 2 ⌋ \lfloor \frac{n}{2} \rfloor 2n(其中 ⌊ x ⌋ \lfloor x \rfloor x 是下取整函数)。对于包含至少一个结点的树,它的重心只可能有 1 或 2 个。
    课后老师给出了一个大小为 n n n 的树 S S S,树中结点从 1 ∼ n 1 \sim n 1n 编号。小简单的课后作业是求出 S S S 单独删去每条边后,分裂出的两个子树的重心编号和之和。即:

∑ ( u , v ) ∈ E ( ∑ 1 ≤ x ≤ n 且 x 号 点 是 S u ′ 的 重 心 x + ∑ 1 ≤ y ≤ n 且 y 号 点 是 S v ′ 的 重 心 y ) \sum_{(u,v) \in E} \left( \sum_{1 \leq x \leq n \atop 且 x 号点是 S'_u 的重心} x + \sum_{1 \leq y \leq n \atop 且 y 号点是 S'_v 的重心} y \right) (u,v)ExSu1xnx+ySv1yny
上式中, E E E 表示树 S S S 的边集, ( u , v ) (u,v) (u,v) 表示一条连接 u u u 号点和 v v v 号点的边。 S u ′ S'_u Su S v ′ S'_v Sv分别表示树 S S S 删去边 ( u , v ) (u,v) (u,v) 后, u u u 号点与 v v v 号点所在的被分裂出的子树。
小简单觉得作业并不简单,只好向你求助,请你教教他。

在这里插入图片描述

细节分析

  • 多测不清空,爆零两行泪。
    在这里插入图片描述
  • 如果没有下面这一句提示我们同样需要观察给出的大样例的强度和满足的特殊性质。
    在这里插入图片描述
  • 本题部分分设置的最大玄机:
    在这里插入图片描述
    1.注意 n n n后面跟的是等号。
    2. n n n的最后一位数字实际上将这题的子任务划分为了5个档次,我们无需自己判断数据是否满足特殊性质
    3.划红线的数字:
    在这里插入图片描述

一句话题意

求一颗树断掉任意一条边之后的两棵树的所有重心的编号的和的和。
注:一棵树存在一个或者两个重心。
n ≤ 3 × 1 0 5 n\leq 3\times 10^5 n3×105

本题得分目标

  • 暴力选手最低配置:暴力, 40 + 15 = 55 40+15=55 40+15=55
  • 带了脑子考试的暴力选手配置:暴力+特性AB, 40 + 15 + 20 = 75 40+15+20=75 40+15+20=75
  • AK day2 大佬配置:正解, 90 ∼ 100 90\sim 100 90100分。

部分分算法

算法1:暴力(40分)/算法2:链(15分)

暴力按题意断掉一条边然后分别对两个树dfs找出重心即可。
链上的重心显然为这条链的中点(一个或两个),直接模拟即可。

算法3:完美二叉树(20分)

一个显然的想法是完美二叉树具有极强的对称性,考虑我们每层都只删一条边然后暴力,其它的边我们可以通过对称方法等价出来。因此删的边只会是 O ( log ⁡ n ) O(\log n) O(logn)级别的,能够通过数据。
当然这题也有很强的规律性。
例如下面这个完美二叉树:

在这里插入图片描述
试着分别割去图中带红黄切点的两种边,可以归纳出结论:

  • 删除叶子节点连边,根会成为重心之一。
    -删除某个除根节点外的节点连边时,这个点会成为重心之一。
    -删除根的左子树某条边时,根的右儿子会成为重心之一,对称同理。

这样我们就可以直接计算重心编号之和了。

正解

算法1:换根+倍增

应该是本题最自然的一种解法。
这种做法依据的一个关键性质是:

引理:
对于一个点 x x x,如果它不是这颗树的重心,那么重心一定在以它为根的重儿子的子树里面。

这个证明其实比较好理解,大概可以看这张图感性理解一下:

在这里插入图片描述

智障与大佬问答环节:
小P:如果一个节点有两个大小相同的最大siz的时候该走那边啊…
巨佬:你可以直接放弃选择。
小P:???
巨佬:此时两个节点的siz显然 ≤ ⌊ n 2 ⌋ \leq \lfloor\frac{n}{2}\rfloor 2n,那么这个点就是重心。
小P:…

知道这个做法就好做了,我们考虑换根,每次我们计算割去 x x x为父亲 v v v为儿子的边两颗树的重心。

在这里插入图片描述

定义 h s x , k hs_{x,k} hsx,k表示 x x x为根时向它的重儿子走 k k k步时到达的节点。
假设我们已经求出 h s x , k hs_{x,k} hsx,k数组,考虑如何求答案:
注意到我们总是往儿子走,那么走到的点的 s i z siz siz会随着走的步数的增加而递减,而我们向下走始终会找到一个重心,因此我们找深度最大的满足 s i z x − s i z v ≤ ⌊ s i z x 2 ⌋ siz_x-siz_v\leq \lfloor\frac{siz_x}{2}\rfloor sizxsizv2sizx的点 v v v即为这个树的一个重心,由于可能存在两个重心,我们还需要检验 v v v的父亲是不是重心。
那么我们分别考虑在 x x x为根和 v v v为根的两个联通块内倍增即可。
然而我们还需要处理 s i z x siz_x sizx h s x , k hs_{x,k} hsx,k

  • 在处理 x x x这一部分时, s i z x siz_x sizx改为 n − s i z v n-siz_v nsizv h s x , 0 hs_{x,0} hsx,0儿子现在不包含 x x x而加入了父亲,因此还要记录 x x x的次重儿子进行更新,更新完 h s x , 0 hs_{x,0} hsx,0之后再依次更新 h s x , 1... hs_{x,1...} hsx,1...的信息。
  • 在处理 v v v这一部分时,无需进行更改。
  • 注意保存割去边后的父亲数组也会相应更改,这里不详细解释。

处理完 x x x之后,我们还需要记住还原 x x x被修改的信息。
一次dfs找重儿子和次重儿子,一次dfs找答案即可。
复杂度: O ( n log ⁡ n ) O(n\log n) O(nlogn)

#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<queue>
#include<vector>
using namespace std;

#define LL long long
#define MAXN 3000000
#define mem(x,v) memset(x,v,sizeof(x))

LL read(){
	LL x=0,F=1;char c=getchar();
	while(c<'0'||c>'9'){if(c=='-')F=-1;c=getchar();}
	while(c>='0'&&c<='9'){x=x*10+c-'0';c=getchar();}
	return x*F;
}

int T,n;
vector<int> G[MAXN+5];
int siz[MAXN+5],hs[MAXN+5][18],f[MAXN+5];
int phs[MAXN+5];LL ans;

void upd(int x){
	for(int k=1;k<=17;k++)
	hs[x][k]=hs[hs[x][k-1]][k-1];
}
bool check(int x,int N){
	return max(N-siz[x],siz[hs[x][0]])<=(N/2);
}

void dfs1(int x,int fa){
	siz[x]=1,hs[x][0]=phs[x]=0,f[x]=fa;
	for(int i=0;i<G[x].size();i++){
		int v=G[x][i];
		if(v==fa)continue;
		dfs1(v,x);
		siz[x]+=siz[v];
		if(siz[hs[x][0]]<siz[v])phs[x]=hs[x][0],hs[x][0]=v;
		else if(siz[phs[x]]<siz[v])phs[x]=v;
	}
}
void dfs2(int x,int fa){
	int prehs=hs[x][0],presz=siz[x];
	for(int i=0;i<G[x].size();i++){
		int v=G[x][i];
		if(v==fa)continue;
		if(v==prehs)hs[x][0]=phs[x];
		else hs[x][0]=prehs;
		if(siz[fa]>siz[hs[x][0]])hs[x][0]=fa;
		upd(x);
		siz[x]=n-siz[v],f[x]=0,f[v]=0;
		int p=x;
		for(int k=17;k>=0;k--)
		if(hs[p][k]&&siz[x]-siz[hs[p][k]]<=(siz[x]/2))p=hs[p][k];
		ans+=p+f[p]*check(f[p],siz[x]);
		//printf("::%d %d\n",x,v);
		//printf("**%d %d\n",p,f[p]*check(f[p],siz[x]));
		p=v;
		for(int k=17;k>=0;k--)
		if(hs[p][k]&&siz[v]-siz[hs[p][k]]<=(siz[v]/2))p=hs[p][k];
		ans+=p+f[p]*check(f[p],siz[v]);
		//printf("**%d %d\n",p,f[p]*check(f[p],siz[v]));
		f[x]=v;
		dfs2(v,x);
	}
	siz[x]=presz,hs[x][0]=prehs,f[x]=fa,upd(x);
}

int main(){
	T=read();
	while(T--){
		ans=0;
		n=read();
		for(int i=1;i<=n;i++)G[i].clear();
		for(int i=1;i<n;i++){
			int u=read(),v=read();
			G[u].push_back(v),G[v].push_back(u);
		}
		dfs1(1,0);
		for(int k=1;k<=17;k++)
			for(int i=1;i<=n;i++)
			hs[i][k]=hs[hs[i][k-1]][k-1];
		dfs2(1,0);
		printf("%lld\n",ans);
	}
}

算法2:换根+树状数组

这个做法具有一定的通用性。
考虑对于每一个点计算作为重心的次数。

在这里插入图片描述
那么对于一个点 x x x而言我们可以:

  • 切取它子树内的一个子树,这个点成为重心。
  • 切取它子树外的一个子树,这个点成为重心。
  • 保留包含该子树的一个子树,这个点成为重心。

这样讨论不是不可做,但是讨论起来相当麻烦,因此我们试着优化一下。
考虑我们首先找到原树的一个重心作为根 r t rt rt,对于一个点 x ≠ r t x≠rt x=rt就不会存在第一种情况。因为子树内的点数始终小于 ⌊ n 2 ⌋ \lfloor \frac{n}{2}\rfloor 2n,父亲方向的连通块大小就会始终大于 ⌊ n 2 ⌋ \lfloor \frac{n}{2}\rfloor 2n
m x x = max ⁡ i = 1 s i z x s i z s o n x , i mx_x=\max^{siz_x}_{i=1}{siz_{son_{x,i}}} mxx=maxi=1sizxsizsonx,i
考虑割掉子树外大小为 P P P的连通块使得 x x x成为重心,那么应该满足:
n − P − s i z x ≤ ⌊ n − P 2 ⌋ m x x ≤ ⌊ n − P 2 ⌋ n-P-siz_x\leq \lfloor \frac{n-P}{2}\rfloor\\ mx_x\leq \lfloor\frac{n-P}{2}\rfloor\\ nPsizx2nPmxx2nP
联立不等式得到
n − 2 s i z x ≤ P ≤ n − 2 m x x n-2siz_x\leq P\leq n-2mx_x n2sizxPn2mxx
仍然考虑换根,我们在换根时维护 P = x P=x P=x时有多少种割法,一次换根只会影响到一个 P P P值的改变。
然而这样统计我们也统计了在 x x x子树内的 P P P值,我们需要想办法减去它。
注意到子树是dfs过程中连续的一段,因此我们可以让答案在dfs出 x x x点时已经过的点符合条件的 P P P值的个数减去进 x x x点时已经过的点符合条件的 P P P值的个数。类似NOIP2016天天爱跑步的思路。
同样可以用一个树状数组解决这个问题。当然如果你没有想到这个思路,无脑线段树合并也是可做的。
但是我们还需要统计 x = r t x=rt x=rt时合法的割边数。
由于只有一个点,我们暴力讨论即可。

  • 若割掉的边是该点的重儿子,设次大儿子的大小为 c m x r t cmx_{rt} cmxrt,需要满足:
    c m x r t ≤ ⌊ n − P 2 ⌋ m x r t − P ≤ ⌊ n − P 2 ⌋ cmx_{rt}\leq \lfloor\frac{n-P}{2}\rfloor\\ mx_{rt}-P\leq \lfloor\frac{n-P}{2}\rfloor\\ cmxrt2nPmxrtP2nP
    联立解得:
    2 m x r t − n ≤ S ≤ n − 2 c m x r t 2mx_{rt}-n\leq S\leq n-2cmx_rt 2mxrtnSn2cmxrt
    不过由于 r t rt rt是重心,后面的值恒小于 0 0 0,因此可以忽略后面的限制。
  • 若割掉的边不是该点的重儿子,那就只需要满足:
    m x r t ≤ ⌊ n − P 2 ⌋ mx_{rt}\leq \lfloor\frac{n-P}{2}\rfloor\\ mxrt2nP
    因此
    S ≤ n − 2 m x r t S\leq n-2mx_{rt} Sn2mxrt
    在上面dfs时同时计算即可。
    复杂度: O ( n log ⁡ n ) O(n\log n) O(nlogn)
#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<queue>
#include<vector>
using namespace std;

#define LL long long
#define MAXN 3000000
#define mem(x,v) memset(x,v,sizeof(x))

LL read(){
   LL x=0,F=1;char c=getchar();
   while(c<'0'||c>'9'){if(c=='-')F=-1;c=getchar();}
   while(c>='0'&&c<='9'){x=x*10+c-'0';c=getchar();}
   return x*F;
}

int T,n,rt;
int siz[MAXN+5],hs[MAXN+5],phs[MAXN+5],flag[MAXN+5];
vector<int> G[MAXN+5];
LL ans;

struct bit{
   int C[MAXN+5];
   void init(){mem(C,0);}
   int lowbit(int x){return x&(-x);}
   void Insert(int x,int v){
   	x++;
   	while(x<=n+1)C[x]+=v,x+=lowbit(x);
   }
   LL Query(int x){
   	x++;LL res=0;
   	while(x)res+=C[x],x-=lowbit(x);
   	return res;
   }
}T1,T2;

bool check(int x,int N){
   return max(N-siz[x],siz[hs[x]])<=(N/2);
}
void dfs1(int x,int fa){
   siz[x]=1,hs[x]=phs[x]=0;
   for(int i=0;i<G[x].size();i++){
   	int v=G[x][i];
   	if(v==fa)continue;
   	dfs1(v,x);
   	siz[x]+=siz[v];
   	if(siz[hs[x]]<siz[v])phs[x]=hs[x],hs[x]=v;
   	else if(siz[phs[x]]<siz[v])phs[x]=v;
   }
   if(check(x,n))rt=x;
}
void dfs2(int x,int fa){
   if(x!=rt){
   	flag[x]|=flag[fa];
   	T1.Insert(siz[fa],-1);
   	T1.Insert(n-siz[x],1);
   	ans+=x*(T1.Query(n-2*siz[hs[x]])-T1.Query(n-2*siz[x]-1));
   	ans+=x*(T2.Query(n-2*siz[hs[x]])-T2.Query(n-2*siz[x]-1));
   	if(flag[x])ans+=rt*(siz[x]<=n-2*siz[phs[rt]]);
   	else ans+=rt*(siz[x]<=n-2*siz[hs[rt]]);
   }
   T2.Insert(siz[x],1);
   for(int i=0;i<G[x].size();i++){
   	int v=G[x][i];
   	if(v==fa)continue;
   	dfs2(v,x);
   }
   if(x!=rt){
   	T1.Insert(siz[fa],1);
   	T1.Insert(n-siz[x],-1);
   	ans-=x*(T2.Query(n-2*siz[hs[x]])-T2.Query(n-2*siz[x]-1));
   }
}

int main(){
   T=read();
   while(T--){
   	ans=rt=0;
   	n=read();
   	for(int i=1;i<=n;i++)G[i].clear();
   	for(int i=1;i<n;i++){
   		int u=read(),v=read();
   		G[u].push_back(v),G[v].push_back(u);
   	}
   	dfs1(1,0),dfs1(rt,0);
   	T1.init(),T2.init();
   	for(int i=1;i<=n;i++)T1.Insert(siz[i],1),flag[i]=0;
   	flag[hs[rt]]=1;
   	dfs2(rt,0);
   	printf("%lld\n",ans);
   }
}

本题小结

  • 本题是CSP day2难度最高的题目,难度比T1T2相对来说要大的多。
    对于在场大多数同学来说,这道题还是具有极大的挑战性的。
    但是本题的部分分做法不难且与正解思路没有太大关联,因此,拿稳部分分75分相当关键。
  • 虽然近年来D2T3题目难度逐年攀升,但是其中的核心思想还是没有变。
    不难发现,每年联赛中最难的题很容易出现倍增思想的题目(NOIP2019,NOIP2018,NOIP2016,NOIP2015,NOIP2013,NOIP2012…好像有点规律),难度也逐渐提升。其中提升难度的一个重要方式就是借鉴往年题目的思路与做法。例如今年的题目就有借鉴NOIP2018保卫王国和NOIP2016天天爱跑步的思路,并在这个思路上做出创新,变成更难的题。
    因此认真研究往年的题目肯定的是有一定作用的:)
  • 本题在考点上与[Day1T2 括号树]的换根有一定重合,但质量上仍然保持了往年联赛的水准(但是省选联考就不一样了啊…)。
  • 关于联赛知识范围之外的知识,在本题中其实是有一定的思维弥补作用的。例如在方法二差分思想同样可以用线段树合并或者主席树代替,但这同时也增加了代码复杂度,并且这题时限比较紧,肯能会被卡成90分。
  • 现在联赛出题越来越喜欢多组测试数据了…小心爆零…骗分也越来越困难了。
  • 最后,愿同学们认真研究本题,掌握其核心思想。讲题人相信,这次美妙的讲题,可以给拼搏于AK IOI的逐梦之路上的你,提供一个有力的援助。
  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值