树上点分治详解

声明:本文章为转载文章 膜拜大佬 tql
https://blog.csdn.net/a_forever_dream/article/details/81778649

非常详细

本蒟蒻想要给大家写一篇尽可能详细的树上点分治的文章,以便刚入门的各位能够理解树上点分治,就不用像我一样在网上看了十几篇大佬的文章后还很蒙逼了(我太菜了QAQ),那么,我们就进入正题吧!

首先安利一波Guess_Ha大佬的树上点分治!

树上点分治主要解决有关树上路径统计的问题,我们这里以洛谷上的P3806 【模板】点分治1作为例题。

题目大意:有一棵树,m个询问,每次询问树上有没有两个点之间的路径长度为k。

根据树上点分治这个名字,那么我们自然就要分治啦,怎么分?题目只给了一棵树,于是我们就分这颗树,树上点分治的流程就是:每次选当前树上的一个点作为根,将这个点与它的子树分开,然后对子树也进行这样的操作(分),最后利用子树来统计答案(治)。就像这样:

在这里插入图片描述

那么,第一个重点来了!如何拆这颗树,这是个很严肃的问题,分的方法决定了分治的效率,不妨设想一下,假如树退化成一条链(有n个节点),我们每次取链首作为根,那么这样分治要分n层,因为点分治在每一层所用的时间大约为O(n),那这样分治的时间复杂度就是O(n^2), 稳稳地超时(绝望.jpg),那么找哪个点作为根才能让时间复杂度变得更优秀呢?显然的,找树的重心是最优的,树的重心的定义是,树中所有节点中最大的子树最小的那个节点(百度百科),那么每次选树的重心作为根,拆出来的子树的大小就会尽可能平均,那分治的层数也就尽可能的小了(如果还是不理解的同学可以联系二分来思考一下,二分查找的时候是看左右端点的中间点,这样可以把序列分成平均的两份,如果是随便找一个点,那么查找的次数自然就会增多,效率也就低了),这样可以保证分治的层数大约是logn,那总的时间复杂度就是O(nlogn)了,十分的优秀(记笔记记笔记)。

找树的重心我们只需要将整棵树过一遍即可,先贴上代码,对着代码讲方便各位理解。

void getroot(int x,int fa)//fa表示x的父亲,防止往回搜
{
	size[x]=1;mson[x]=0;//size[i]记录以i为根的子树的大小,mson[i]表示i节点的最大子树的大小 
	for(int i=first[x];i;i=e[i].next)
	{
		int y=e[i].y;
		if(v[y]||y==fa)continue;//v[y]表示当前节点是否被分治过,先别管这货,后面再讲 
		getroot(y,x);//往下继续搜索 
		size[x]+=size[y];//加上子树大小 
		if(size[y]>mson[x])mson[x]=size[y];//更新最大的子树 
	}
	if(Size-size[x]>mson[x])mson[x]=Size-size[x];
	//Size表示当前这整棵树的大小,那么Size-size[x]就表示不在x的子树内的节点数量,下面详解 
	if(ms>mson[x])ms=mson[x],root=x;//ms表示树的重心的最大子树的大小(相当于mson[root]),这一步是用来更新树的重心的,root用来记录重心 
}

再解释一下这一句

if(Size-size[x]>mson[x])mson[x]=Size-size[x];

先画一幅图, 方便理解。

在这里插入图片描述

比如说当前到了红色节点处(也就是x为红色节点),那么size[x]就是绿圈里的点,Size-size[x]就是蓝圈里的点。

如果以红色节点为根的话,其实蓝色圈里的节点也是红色节点的一棵子树,所以要算出这棵蓝圈里面的子树的大小,用它来更新一下mson(别忘记mson的定义呀)。

那。。。我们继续?

现在我们知道了如何拆树,也就是分的过程差不多解决了,那接下来的问题就是如何治!

对于每一次找到的重心,我们统计与它有关的路径,也就是经过它的路径,怎么统计呢?先算出一个dis数组,dis[i]表示i这个点到目标点的距离(也可以说是目标点到这个点的距离),那么我们很容易就可以在O(n)的时间内求出dis数组。代码如下:

void getdis(int x,int fa,int z)//fa表示x的父亲,z表示x到目标点的距离
{
    dis[++t]=z;//这里写dis[++t]=z而不是dis[x]=z是因为后面我们会发现每一个t对应哪一个x并不重要,也就是说我们只需要知道每一个点到目标点的距离,而不需要知道那个点是谁,看到后面就会明白的啦
    for(int i=first[x];i;i=e[i].next)
    {
        int y=e[i].y;
        if(y==fa||v[y])continue;//这里加个v[y]是因为不能往被分治过的点走,后面会讲的啦
        getdis(y,x,z+e[i].z);
    }
}

求出dis数组后,我们就可以将两两组合,得到一条条路径的长度,比如说,我们设目标点为重心,有一个点A到重心的距离为x,又有一个点B到重心的距离为y,那么A到B的距离就是x+y了!(树上两点间的路径唯一)

但是!问题又来了,我们再看到下面这一幅图:

在这里插入图片描述

我们设点1为重心。

那么

1—2的路径就是1—2,长度为1

1—3的路径就是1—2—3,长度为2

1—4的路径就是1—2—4,长度为2

1—5的路径就是1—5,长度为1

假如将1—2和1—5两条路径合并,得到2—1—5,也就是2到5的路径,长度为(1—2的长度)+(1—5的长度)=2,和上面说的一样。

但问题就在于我如果把1—2—3和1—2—4这两条路径合并,得到的就是3—2—1—2—4这么一条路径,长度为4!什么鬼 ,这和想象中的不一样啊!3—4的距离不应该是2吗?然后发现,这条奇怪的路径走了1—2这条边两次,然而事实上1—2这条边是一次都不用走的,那么这种问题怎么解决呢?

仔细想想,可以发现,假如我们将两个在同一棵子树内的点的dis组合起来,就会发生如上问题,就像上述例子,假如组合3、4,因为3、4都是在重心1的同一棵子树内,所以,我们称这种组合为不合法组合。那么我们一开始将dis数组两两组合得到的所有组合称为所有组合,那答案就显而易见了!合法组合=所有组合-不合法组合,代码如下:

void fenzhi(int x,int ssize)//ssize是当前这棵子树的大小
{
    v[x]=true;//代码保证每次进来的x都必定是当前这棵树的重心,我们将v[x]标记为true,表示x点被分治过
    solve(x,0,1);//计算这棵树以x为重心的所有组合,solve函数后面会讲
    for(int i=first[x];i;i=e[i].next)
    {
        int y=e[i].y;
        if(v[y])continue;
        solve(y,e[i].z,-1);//计算不合法组合,用所有组合减去不合法组合
        ms=inf;root=0;//记得要初始化
        Size=size[y]<size[x]?size[y]:(ssize-size[x]);//下面单独解释
        getroot(y,0);//求出以y为根的子树
        fenzhi(root,(size[y]<size[x]?size[y]:(ssize-size[x])));
    }
}

那么看那个特别的语句。(更新Size的那一行)

举个例子,假如有这么一棵树:

在这里插入图片描述

显然,重心是2。但是,我们在找重心的时候,是从点1出发的,那么求出来的size数组就是这个样子的:

在这里插入图片描述

那么在分治完当前重心2之后,就会去分治2的子树1。我们知道,要分治,就要找重心,要找重心,就要用到一个叫Size的东西,那我们怎么知道1这棵子树的大小呢?答案就是这棵树总的大小减去重心的大小。而点3点4这两棵子树的大小就是它们本身的size值了。

接下来附上solve函数:

void solve(int x,int y,int id)//x表示getdis的起始点,y表示x到目标点的距离,id表示这一次统计出来的答案是合理的还是不合理的
{
    t=0;
    getdis(x,0,y);//算出树中的点到目标点的距离 
    tt=0;
    sort(dis+1,dis+t+1,cmp);//排序,从小到大
    dis[0]=-233;
    for(int i=1;i<=t;i++)//把距离相同的整合在一起,方便处理
    if(dis[i]!=dis[i-1])arr[++tt].x=dis[i],arr[tt].y=1;//如果和上一个不一样,那么就新开一个
    else arr[tt].y++;//否则数量+1
    for(int i=1;i<=m;i++)
    {
    	if(ask[i]%2==0)for(int j=1;j<=tt;j++)//单独处理一下到根的距离为k/2的点,因为这些点相互之间的距离就是k,不需要二分去找别人
    		if(arr[j].x==ask[i]/2)ans[i]+=(arr[j].y-1)*arr[j].y/2*id;
    	
    	for(int j=1;j<=tt&&arr[j].x<ask[i]/2;j++)
        //假如还有可以枚举的距离,并且当前的距离小于k/2,就继续查找
    	{
            //我们只需要让小于k/2的去查找大于k/2的就可以了,所以大于k/2的不用枚举
            //这也是我们需要单独考虑距离恰好是k/2的点的原因
    		int l=j+1,r=tt;
    		while(l<=r)//二分找距离为k的点
    		{
    			int mid=l+r>>1;
    			if(arr[j].x+arr[mid].x==ask[i])//假如找到了
    			{
    				ans[i]+=arr[j].y*arr[mid].y*id;
    				break;//记录答案,退出循环
				}
				if(arr[j].x+arr[mid].x>ask[i])r=mid-1;
				else l=mid+1;
			}
		}
	}
}

//本来是用n^2做法水的= =
//后来被威逼利诱还是打了正解(我太良心了~
先别走!再等我多唠叨几句!

我觉得我还需要对代码中的一个地方讲解一下

那就是fenzhi函数中去除不合理组合的那一句话:solve(y,e[i].z,0);

我们仔细思考一下这句话,solve(y,e[i].z,0),进入到solve函数时,首先做什么?getdis!没错,我们会以x点(也就是y的父亲)作为目标点计算出以y为根的子树中所有点的dis。等等!为什么不是以y作为目标点?仔细看看,solve里面y后面的那个参数,是e[i].z,而不是0,这意味着dis[y]=e[i].z。那么以y为根的子树中的所有点的dis值其实就等于他们到y点的距离+e[i].z,又因为e[i].z是y点到x点的距离,所以,其实这次计算出来的dis值就是以y为根的子树中所有点到x点的距离!

完整代码:

// luogu-judger-enable-o2
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
#define inf 999999999
 
int n,m,len=0,Size;
struct node{int x,y,z,next;};
node e[20010];
int first[10010];
int root,ms,size[10010],mson[10010],sum[10000010];
bool v[10010];
void buildroad(int x,int y,int z)
{
    len++;
    e[len].x=x;
    e[len].y=y;
    e[len].z=z;
    e[len].next=first[x];
    first[x]=len;
}
void getroot(int x,int fa)//fa表示x的父亲,防止往回搜
{
    size[x]=1;mson[x]=0;//size[i]记录以i为根的子树的大小,mson[i]表示i节点的最大子树的大小 
    for(int i=first[x];i;i=e[i].next)
    {
        int y=e[i].y;
        if(v[y]||y==fa)continue;//v[y]表示当前节点是否被分治过,先别管这货,后面再讲 
        getroot(y,x);//往下继续搜索 
        size[x]+=size[y];//加上子树大小 
        if(size[y]>mson[x])mson[x]=size[y];//更新最大的子树 
    }
    if(Size-size[x]>mson[x])mson[x]=Size-size[x];
    //Size表示当前这整棵树的大小,那么Size-size[x]就表示不在x的子树内的节点数量,下面详解 
    if(ms>mson[x])ms=mson[x],root=x;//ms表示树的重心的最大子树的大小(相当于mson[root]),这一步是用来更新树的重心的,root用来记录重心 
}
int t;
int dis[10010];
int ask[110],ans[110];
void getdis(int x,int fa,int z)//fa表示x的父亲,z表示x到目标点的距离
{
    dis[++t]=z;//这里写dis[++t]=z而不是dis[x]=z是因为后面我们会发现每一个t对应哪一个x并不重要,也就是说我们只需要知道每一个点到目标点的距离,而不需要知道那个点是谁,看到后面就会明白的啦
    for(int i=first[x];i;i=e[i].next)
    {
        int y=e[i].y;
        if(y==fa||v[y])continue;//这里加个v[y]是因为不能往被分治过的点走,后面会讲的啦
        getdis(y,x,z+e[i].z);
    }
}
struct asd{int x,y;}arr[10010];
int tt;
bool cmp(int x,int y){return x<y;}
void solve(int x,int y,int id)//x表示getdis的起始点,y表示x到目标点的距离,id表示这一次统计出来的答案是合理的还是不合理的
{
    t=0;
    getdis(x,0,y);//算出树中的点到目标点的距离 
    tt=0;
    sort(dis+1,dis+t+1,cmp);
    dis[0]=-233;
    for(int i=1;i<=t;i++)
    if(dis[i]!=dis[i-1])arr[++tt].x=dis[i],arr[tt].y=1;
    else arr[tt].y++;
    for(int i=1;i<=m;i++)
    {
    	if(ask[i]%2==0)for(int j=1;j<=tt;j++)
    		if(arr[j].x==ask[i]/2)ans[i]+=(arr[j].y-1)*arr[j].y/2*id;
    	
    	for(int j=1;j<=tt&&arr[j].x<ask[i]/2;j++)
    	{
    		int l=j+1,r=tt;
    		while(l<=r)
    		{
    			int mid=l+r>>1;
    			if(arr[j].x+arr[mid].x==ask[i])
    			{
    				ans[i]+=arr[j].y*arr[mid].y*id;
    				break;
                }
                if(arr[j].x+arr[mid].x>ask[i])r=mid-1;
                else l=mid+1;
            }
        }
    }
}
void fenzhi(int x,int ssize)//ssize是当前这棵子树的大小
{
    v[x]=true;//代码保证每次进来的x都必定是当前这棵树的重心,我们将v[x]标记为true,表示x点被分治过
    solve(x,0,1);//计算这棵树以x为重心的所有组合,solve函数后面会讲
    for(int i=first[x];i;i=e[i].next)
    {
        int y=e[i].y;
        if(v[y])continue;
        solve(y,e[i].z,-1);//计算不合法组合,用所有组合减去不合法组合
        ms=inf;root=0;//记得要初始化
        Size=size[y]<size[x]?size[y]:(ssize-size[x]);
        getroot(y,0);//求出以y为根的子树
        fenzhi(root,(size[y]<size[x]?size[y]:(ssize-size[x])));
    }
}
 
int main()
{
    scanf("%d %d",&n,&m);
    for(int i=1;i<n;i++)
    {
        int x,y,z;
        scanf("%d %d %d",&x,&y,&z);
        buildroad(x,y,z);
        buildroad(y,x,z);
    }
    for(int i=1;i<=m;i++)
    scanf("%d",&ask[i]);
    root=0;ms=inf;Size=n;
    getroot(1,0);
    fenzhi(root,n);
    for(int i=1;i<=m;i++)
    if(ans[i]>0)printf("AYE\n");
    else printf("NAY\n");
}


  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值