前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >点分治入门

点分治入门

作者头像
ACM算法日常
发布2020-03-11 15:56:29
6240
发布2020-03-11 15:56:29
举报
文章被收录于专栏:ACM算法日常ACM算法日常

缘起

楼教主 男人八题之一 Poj 1741 Tree

分析

代码语言:javascript
复制
一棵n个顶点的树, 每条边有一个长度([1,1000]中的正整数), 定义dis(u, v)是u和v之间的树上距离. 给定
整数k, 每个对(u, v)称之为合法如果dis(u, v)<=k,写一个程序计算树上有多少合法对?
  
【输入】
多样例. 每个样例开始于n,k, n<=1w, 然后n-1行每行包含u,v,l 表示u和v之间有边长为l
输入以0,0 结束
  
【输出】
答案
  
【样例输入】
5 4  
1 2 3  
1 3 1  
1 4 2  
3 5 1  
0 0  
  
【样例输出】
8  
  
【限制】
1s

本题是点分治的板题~

其实点分治是树分治的一种, 树分治包括点分治和边分治. 边分治不在本文介绍范围内.

点分治是大规模处理树上路径的一种算法. 分治思想大家肯定都是十分清楚的.

image

就像要处理上图的树问题,我们可以将u子树的问题分治为A、B、C、D、V 这五棵子树的子问题,得到答案后再合并. 这就是点分治(唔,貌似说了跟没说一样,dfs它喵的不就是这样吗? 还特地发明个点分治?)

当然不是咯~ 点分治的核心思想并不是在分治,而是在树根u的选择上,确切讲,点分治一般处理无根树(也就是我们可以随意定根),选取根u之后要使得转成的有根树的最多节点的子树的节点最少。

u它喵的不就是树的重心吗?

树的重心在本公众号中有介绍过,参见【1】CodeForces 686D(2019 ICPC 徐州现场赛M)

这里说一下为什么分治要选取树的重心? 其实是显而易见的,如果树退化成链表的话,你傻傻的从链的一端不断地递归的话,复杂度显然是O(n^2)啊~ 那还分治个pi啊,直接打暴力好了. 显然应该选取链的中点作为分治点. 即

image

**树的重心有一个很重要的性质. **

每一棵子树的大小都不超过n/2, esp. 重儿子的大小不会超过n/2

证明用反证法,图1其中点u是重心,son[u]表示u点的最大的子树的大小,v是点u的重儿子,且size[v]>size[u]/2,因为size[v]>size[u]/2,所以u的其他子树加上点u的节点数小于size[u]/2,那么不难发现,我们选择点v作为重心,son[v]=size[v]−1<son[u](即v比u更适合当重心),那么很明显u不满足重心的定义. 这就矛盾了

于是每一次找到重心,递归的子树大小是不超过原树大小的一半的,那么递归层数不会超过O(logn)层,每层需要处理O(n)规模的问题,所以点分治的时间复杂度为O(nlogn). 很完美的算法~

这里用本题讲一下点分治的板子。

首先,取树的重心为树根,将无根树转换为有根树(假设是图1)并且假设我们已经处理好了A、B、C、D、V 这些子树的子问题. 然后现在要考虑经过U点的路径(则路径的起点终点势必在不同的子树中)中<=k的条数.

怎么求呢?

选定重心u作为根之后,只需要dfs一次将u子树中所有节点到u的距离dis求出来, 然后求出所有满足dis[a]+dis[b]<=k的(a,b)的点对个数X即可(ps: 这个X的求解很简单,就是将dis排个序, 然后用双指针即可,具体见代码,O(n) 很快)

等等,不对啊~ 你这样求出的(a,b)有可能a--b并不经过u啊~ 这其实好办,只需要减去子树(例如A子树)中到u的距离和<=k的点对个数即可. 例如A子树中存在两个点(p,q), p--u--q的距离之和<=k,辣么就要将X减去这样的(p,q)点对个数. 即下面代码的114行做的事情

代码语言:javascript
复制
//#include "stdafx.h"
#pragma comment(linker, "/STACK:1024000000,1024000000")
#include <stdio.h>
#include <algorithm>
#include <string.h>
using namespace std;
//#define LOCAL
const int maxn = 1e4+5, inf = 0x3f3f3f3f;
int n,k, head[maxn], cnt, dis[maxn], zx, mb, ans, tot, sz[maxn]; // zx是重心
bool v[maxn]; // v[i]=j表明i节点已经作为树根了
struct Arc  
{  
	int from, to, nxt, len;
} g[maxn << 1];
  
void addarc(int from, int to, int len)  
{
	g[cnt].from = from, g[cnt].to = to, g[cnt].len = len, g[cnt].nxt = head[from];
	head[from] = cnt++;
}
  
void mmax(int &a, int b)  
{
	if (a < b)
	{
		a = b;
	}
}
  
void dfs2(int cur, int fa) // 得到各个节点的子树大小
{
	sz[cur] = 1;
	for (int h = head[cur], to; ~h; h = g[h].nxt)
	{
		to = g[h].to;
		if (to ^ fa && !v[to])
		{
			dfs2(to, cur);
			sz[cur] += sz[to];
		}
	}
}
  
void dfs1(int cur, int fa, int nn) // 求树的重心, 返回cur子树大小. nn是求重心的树的总的节点个数
{
	int ret = 1, b = 0;
	for (int h = head[cur], to; ~h; h = g[h].nxt)
	{
		to = g[h].to;
		if (to ^ fa && !v[to])
		{
			dfs1(to, cur, nn);
			ret += sz[to];
			mmax(b, sz[to]);
		}
	}
	mmax(b, nn - ret);
	if (b < mb)
	{
		mb = b;
		zx = cur;
	}
}
  
void dfs3(int cur, int d, int fa)  
{
	dis[tot++] = d;
	for (int h = head[cur], to, len; ~h; h = g[h].nxt)
	{
		to = g[h].to;
		len = g[h].len;
		if (to ^ fa && !v[to])
		{
			dfs3(to, d + len, cur);
		}
	}
}
  
int kk(int cur, int d, int fa) // 返回cur子树中所有(a, b)的点对数, 其中(a,b)点对满足 (dis[a]+d)+(dis[b]+d)<=k, 注意,(a,b)和(b,a)视作相同点对哈,不能重复计数~ 其中dis[a]是a到cur的距离
{
	tot = 0;
	dfs3(cur, d, fa); // 获取cur子树中所有节点a的dis[a]+d的值,存入dis[0, tot)数组中
	sort(dis, dis + tot);
	int ret = 0, i = 0, j = tot - 1; // 求dis[0,..,tot) 中满足dis[a]+dis[b] <=k的(a,b)对的数量,i,j是双指针
	while(i < j)
	{
		if (dis[i] + dis[j] <= k)
		{
			ret += j - i; // 则 dis[i]和dis[i+1,...,j]都满足
			++i;
		}
		else  
		{
			--j;
		}
	}
	return ret;
}
  
void dfs(int cur, int fa) // 点分治
{
	mb = inf; // 用于求重心的
	dfs2(cur, fa); // 确定cur子树的大小sz[cur], 注意, 因为重心(即根节点)在不断变化中, 所以每次都要重新处理出sz来
	dfs1(cur, fa, sz[cur]); // 求重心zx, 它将是新的树根
	v[zx] = true;
	int t = zx; // zx是全局变量, 随着递归会变, 故而用t缓存,下面都用t
	ans += kk(t, 0, 0); // 这里传入的fa是0, 并不能阻止朝着zx去递归, 所以必须在dfs3方法中加入 !v[to]的判断(见72行)
	for (int h = head[t], to, len; ~h; h = g[h].nxt) // 注意, 树根从cur变成了t=zx
	{
		to = g[h].to;
		len = g[h].len;
		if (!v[to]) // 防止回溯到t=zx去(因为v[zx]刚刚被赋予true)
		{
			ans -= kk(to, len, t);
			dfs(to, t);
		}
	}
}
  
signed main()  
{
#ifdef LOCAL
	freopen("d:\\data.in","r",stdin);
//	freopen("d:\\my.out", "w", stdout);
#endif  
	while(scanf("%d%d", &n, &k), n)
	{
		memset(head, -1, sizeof(head));
		memset(v, 0, sizeof(v));
		cnt = ans = 0;
		for (int i = 1, u, v, l; i < n; i++)
		{
			scanf("%d%d%d", &u, &v, &l);
			addarc(u, v, l);
			addarc(v, u, l);
		}
		dfs(1, 0);
		printf("%d\n", ans);
	}
	return 0;
}

ac情况

Status

Accepted

Time

219ms

Memory

1108kB

Length

2781

Lang

G++

Submitted

2020-03-01 20:40:14

Shared

RemoteRunId

21422332

这里不得不总结一下上面点分治的代码实现细节, 因为点分治的确细节比dsu on tree、平衡树启发式合并等算法显得啰嗦了一些——虽然思路十分的直观和简明,但是细节比较多.

  1. 所有的if(to ^ fa) 变成了 if(to ^ fa && !v[to]) 为什么? 举个例子

你从重心zx=9997递归进入9998、9999、10000组成的子树, 自然这棵子树的重心是9999, 然后从9999进入该子树,然后走进其孩子cur=9998子树的时候,如果判断条件仅仅是 if(to ^ fa) 的话, 注意,fa是9999,则to可以走到9997去. 但是显然,9998已经是叶子了, 不能再有任何子节点了. 所以!v[9997]的目的是让9998进入之后不能走到9997去.

  1. 103行,见注释
  2. 106行, 见注释
  3. 44行的求树的重心的板子要传入nn, 即要求重心的树的节点总数. 这是因为求重心要发生多次,所以求重心的树的规模也在不断变化(确切讲规模是在不断的减半,这是上面的树的重心的性质保证的),所以要传入.

最后讲两个点分治容易T的点

  1. 一旦被T,先打印一下你每次求出的重心(例如上面代码的102行之前). 就可以知道你求重心的代码是否写错.
  2. 一般不要在递归中写memset,容易被T.
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-03-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 ACM算法日常 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 缘起
  • 分析
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档