【图论】点分治

154 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

点分治,是一种针对可带权树上简单路径统计问题的算法,本质上是一种带优化的暴力。下面我们以一个经典问题为例对这个算法进行介绍。

题目链接

P3806 【模板】点分治1 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

问题定义

给定一棵有 nn 个节点的带权无根树,和 mm 个询问,每个询问要求输出树上距离为 kk 的点对是否存在。

1n10000,1m100,1k1071\le n\le 10000,1\le m\le 100,1\le k\le 10^7

问题分析

很容易想到可以枚举每个点作为根节点,对整棵树进行 DFS 求出每个节点到本次选择的根节点的距离,给所有合法解都打上标记。预处理后,对每个询问 O(1)O(1) 查询输出。时间复杂度 O(n2)O(n^2),会 TLE。

考虑优化,上述过程中时间浪费在什么地方呢?不同路径长度的种类是 n2n^2 级别,难以突破瓶颈。因为每个点都会被选择为根节点进行 DFS,每条边都会被访问左右两边点的数量之积次!而且每次 DFS 的结果都会被扔掉,这太浪费了。怎么才能更好的利用单次 DFS 求得的信息呢?

点分治

分治

因为 mm 很小,我们尝试利用。对于每次一询问 kk

让我们先随便找一个点作为根节点把整棵树拎起来,并求出每个点到该点的距离 disdis,和以每个点为根的子树的大小 sizsiz

image.png

我们可以把所有路径分为经过根节点和不经过两种。如果我们可以判断经过根节点的所有路径的长度是否存在 kk,就可以把根节点删掉形成许多棵子树,对子树进行同样的处理求出不经过根节点的路径的答案。

下面我们讨论怎样求经过根节点的所有路径的长度是否存在 kk

对于以 rootroot 为端点的两条路径,如果它们的另一个端点 p1p_1p2p_2 不在 rootroot 的同一棵子树中,它们两个可以接在一起,形成一条长度为 disp1+disp2dis_{p_1}+dis_{p_2} 的路径。

因为 kk 不超过 10710^7,我们可以开一个桶 cc 记录以 rootroot 为一个端点的路径长度,一开始桶为空。遍历 rootroot 的每一个子树执行以下操作:

  1. 对于第 ii 棵子树中的每一个节点 pp,我们都去看看桶 cc 里是否存在标记 kdispk-dis_p。若存在,即可说明经过根节点的所有路径的长度存在 kk
  2. 用子树 ii 中所有节点的 disdis 值更新桶 cc

对每个点执行该过程的时间复杂度为 O(sizroot)O(siz_{root})

上述过程因题而异

重心

虽然上述求解好像比直接暴力优秀的一点点,但是总时间复杂度与树的形状关系很大,当树变成一条链的时候复杂度退化为 O(n2)O(n^2)。随便估算一下发现时间复杂度约为点数与树高之积。

说到最小化树高容易想到可以求直径的中点,但是这还不是最优的。还记得我们刚才提到:

如果我们可以判断经过根节点的所有路径的长度是否存在 kk,就可以把根节点删掉形成许多棵子树,对子树进行同样的处理求出不经过根节点的路径的答案。

当我们计算过经过根节点 rtrt 的路径之后,rtrt 的各个子树之间的答案相互独立毫无关联,对 rtrt 也没有贡献。可以视为将根节点直接删掉,形成的森林中的每棵树仍然是无根树。所以我们对节点 rtrt 进行操作后,包含其子节点 vv 的未被操作的连通块可以重新在其内部选择一个根,不必须以 vv 为根。

所以如果每个根 xx 被删除后其子节点形成的最大的连通块大小不超过 sizx2\lfloor\frac{siz_x}{2}\rfloor,那么整棵树将被划分为 log2(n)log_2(n) 层。我们希望删除掉根节点后形成的最大的子树尽可能小,这样我们就可以用最少的层数计算整棵树的答案,这符合树的重心的定义。

求树的重心只需要 O(n)O(n) 遍历一遍树即可,详见代码。

inline void getroot(int u,int fa,int num)
{
	siz[u]=1;
	mx[u]=0;
	for (auto v:e[u])
	{
		if (v.to==fa||vis[v.to]) continue;
		getroot(v.to,u,num);
		siz[u]+=siz[v.to];
		mx[u]=max(mx[u],siz[v.to]);
	}
	mx[u]=max(mx[u],num-siz[u]);
	if (mx[rt]>mx[u]) rt=u;
}

所以总体复杂度 O(mnlog(n))O(mnlog(n))

上述过程就是点分治,或称为树的重心剖分。即在大规模问题的答案与小规模问题的答案相互独立互不影响时,可以一直以没有被操作过的连通块的重心作为分治点(相当于改变树的形状以降低树高),来减小时间消耗。

代码

由于点分治常数较大,可以将查询都读进来在一次点分治内离线处理。

#include <stdio.h>
#include <algorithm>
#include <vector>
using namespace std;
const int N=10001;
struct asdf{
	int to,val;
};
vector<asdf> e[N];
int n,m,mx[N],siz[N],rt,vis[N],q[N],ans[N];
int dis[N],c[10000001],tot,clr[N],cnt;
inline void getroot(int u,int fa,int num)
{
	siz[u]=1;
	mx[u]=0;
	for (auto v:e[u])
	{
		if (v.to==fa||vis[v.to]) continue;
		getroot(v.to,u,num);
		siz[u]+=siz[v.to];
		mx[u]=max(mx[u],siz[v.to]);
	}
	mx[u]=max(mx[u],num-siz[u]);
	if (mx[rt]>mx[u]) rt=u;
}
inline void getdis(int u,int nw,int fa)
{
	if (nw>1e7) return; 
	dis[++tot]=nw;
	for (auto v:e[u])
	{
		if (vis[v.to]||v.to==fa) continue;
		getdis(v.to,nw+v.val,u);
	}
}
inline void divide(int u)
{
	vis[u]=c[0]=1;
	cnt=0;
	for (auto v:e[u])
	{
		if (vis[v.to]) continue;
		tot=0;
		getdis(v.to,v.val,u);
		for (int i=1;i<=m;++i)
			for (int j=1;j<=tot&&!ans[i];++j)
				if (q[i]>=dis[j]) ans[i]|=c[q[i]-dis[j]];
		for (int i=1;i<=tot;++i)
		{
			c[dis[i]]=1;
			clr[++cnt]=dis[i];
		}
	}
	for (int i=1;i<=cnt;++i) c[clr[i]]=0;
	for (auto v:e[u])
	{
		if (vis[v.to]) continue;
		rt=0;
		getroot(v.to,0,siz[v.to]);
		getroot(rt,0,siz[v.to]);
		divide(rt);
	}
}
int main()
{
	scanf("%d%d",&n,&m);
	mx[0]=1e9;
	for (int i=1,x,y,z;i<n;++i)
	{
		scanf("%d%d%d",&x,&y,&z);
		e[x].push_back({y,z});
		e[y].push_back({x,z});
	}
	for (int i=1;i<=m;++i) scanf("%d",&q[i]);
	rt=0;
	getroot(1,0,n);
	getroot(rt,0,n);
	divide(rt);
	for (int i=1;i<=m;++i)
		if (ans[i]) printf("AYE\n");
		else printf("NAY\n");
	return 0;
}