当前位置: 代码迷 >> 综合 >> [CSP-S2019]树的重心(换根+倍增/树状数组)
  详细解决方案

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

热度:28   发布时间:2023-10-22 20:43:19.0

题面分析

原题面

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

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

∑(u,v)∈E(∑1≤x≤n且x号点是Su′的重心x+∑1≤y≤n且y号点是Sv′的重心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)E?????xSu?1xn??x+ySv?1yn??y????
上式中,EEE 表示树 SSS 的边集,(u,v)(u,v)(u,v) 表示一条连接 uuu 号点和 vvv 号点的边。Su′S'_uSu?Sv′S'_vSv?分别表示树 SSS 删去边 (u,v)(u,v)(u,v) 后,uuu 号点与 vvv 号点所在的被分裂出的子树。
小简单觉得作业并不简单,只好向你求助,请你教教他。

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

细节分析

  • 多测不清空,爆零两行泪。
    [CSP-S2019]树的重心(换根+倍增/树状数组)
  • 如果没有下面这一句提示我们同样需要观察给出的大样例的强度和满足的特殊性质。
    [CSP-S2019]树的重心(换根+倍增/树状数组)
  • 本题部分分设置的最大玄机:
    [CSP-S2019]树的重心(换根+倍增/树状数组)
    1.注意nnn后面跟的是等号。
    2.nnn的最后一位数字实际上将这题的子任务划分为了5个档次,我们无需自己判断数据是否满足特殊性质
    3.划红线的数字:
    [CSP-S2019]树的重心(换根+倍增/树状数组)

一句话题意

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

本题得分目标

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

部分分算法

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

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

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

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

[CSP-S2019]树的重心(换根+倍增/树状数组)
试着分别割去图中带红黄切点的两种边,可以归纳出结论:

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

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

正解

算法1:换根+倍增

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

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

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

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

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

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

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

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

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

处理完xxx之后,我们还需要记住还原xxx被修改的信息。
一次dfs找重儿子和次重儿子,一次dfs找答案即可。
复杂度:O(nlog?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:换根+树状数组

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

[CSP-S2019]树的重心(换根+倍增/树状数组)
那么对于一个点xxx而言我们可以:

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

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

  • 若割掉的边是该点的重儿子,设次大儿子的大小为cmxrtcmx_{rt}cmxrt?,需要满足:
    cmxrt≤?n?P2?mxrt?P≤?n?P2?cmx_{rt}\leq \lfloor\frac{n-P}{2}\rfloor\\ mx_{rt}-P\leq \lfloor\frac{n-P}{2}\rfloor\\ cmxrt??2n?P??mxrt??P?2n?P??
    联立解得:
    2mxrt?n≤S≤n?2cmxrt2mx_{rt}-n\leq S\leq n-2cmx_rt 2mxrt??nSn?2cmxr?t
    不过由于rtrtrt是重心,后面的值恒小于000,因此可以忽略后面的限制。
  • 若割掉的边不是该点的重儿子,那就只需要满足:
    mxrt≤?n?P2?mx_{rt}\leq \lfloor\frac{n-P}{2}\rfloor\\ mxrt??2n?P??
    因此
    S≤n?2mxrtS\leq n-2mx_{rt} Sn?2mxrt?
    在上面dfs时同时计算即可。
    复杂度:O(nlog?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的逐梦之路上的你,提供一个有力的援助。