【算法学习】点分治

【算法梗概】

点分治,是一种针对可带权树上简单路径统计问题的算法。本质上是一种带优化的暴力,带上一点容斥的感觉。

注意对于树上路径,并不要求这棵树有根,即我们只需要对无根树进行统计。接下来请把无根树这一关键点牢记于心。

【引入】

话不多说,先看一题:

给定一棵树,树上的边有权值,给定一个阈值\(k\),请统计这棵树上总长度小于等于\(k\)的路径个数。

路径长度为路径路径上所有边的权值和。

这就是POJ 1741

题意描述很清楚,你是否已经有了想法?

考虑简单的DFS过程,能否统计答案?

DFS把树看作有根树,那么对于一个子树\(\mathfrak T\),根节点为\(t\),如何统计\(\mathfrak T\)中的路径个数(答案)?

我们考虑\(\mathfrak T\)中的路径。

把路径分为两种:

一、经过\(t\)的路径。

二、不经过\(t\)的路径。

这样分类是显然正确的,而且对于不经过\(t\)的路径,它们一定在\(t\)的某个子节点所构成的子树中。

这样就对答案进行了划分:树\(\mathfrak T\)的答案等于\(\mathfrak T\)中经过\(t\)的路径的答案加上\(t\)的所有子节点构成的子树的答案

对于第二部分的答案,我们递归处理;现在考虑计算第一部分的答案,即计算经过\(t\)的路径的个数。

这里提供一种思路:

考虑路径的合并,利用容斥去除非法路径:

“路径的合并”说的是\(t\)到\(\mathfrak T\)中的任意一个节点(包括自身)的路径集合的合并。

比如看下面这张图:

现在树根是\(A\)点,那么路径的集合是:\(\left\{\begin{matrix}A\\A\to B\\A\to B\to D\\A\to B\to E\\A\to C\\A\to C\to F\end{matrix}\right\}\)。

两两组合,共有\(C_{n+1}^{2}=C_{7}^{2}=21\)种不同的方式。但是显然有不合法的路径:比如\(A\to B\to D\;,\;A\to B\to E\)不能合并。

注意到一条路径可以简单地用路径的终点表示,那么共有子树节点个数条路径,而能够合并的路径只有在不同子树的路径,而在同一子树的路径无法合并。

当然,可以合并的路径也可能因为路径长度大于\(k\)而不能计入答案。

先不考虑非法的路径,看看如何统计长度小于等于\(k\)的路径:

  • 通过一次DFS,把所有从根向子树的路径长度处理出来,在数组里排序,这一步时间为\(O(n\;log\;n)\),\(n\)为子树节点数。
  • 通过双指针扫描的技巧,在\(O(n)\)的时间内计算答案;或者直接在数组内二分,在\(O(n\;log\;n)\)的时间内计算。

那么接下来考虑容斥去除非法的路径,对于根节点的每个子节点代表的子树,按照同样方式统计答案,并把得出的结果从原来的答案中减去即可。

真的就行了吗?

其实还要考虑子树的路径长度,刚才对根的计算时,子树的所有路径长都加上了根到子树的边的权值。

那么在对这棵子树计算时,注意子树的路径也都要加上这条边的权值,这样正好和原来的路径长度吻合。

或者,同样的道理,因为这条边多计算了两次,把\(k\)相应地减少\(2\)倍的这条边的权值也可以。

那么对于一个节点,总的时间复杂度为\(O(n\;log\;n)\),\(n\)为子树节点个数。

那么这样,就能对于一个节点的子树统计答案了,最终把所有答案加起来就行了。

但是,真的就行了吗?请看下一个内容。

【算法核心】

可以看到,计算一个节点的复杂度为\(O(n\;log\;n)\),但要保证总复杂度不超过一个量级却很难。

但是我们可以利用无根树的性质!

可以看到,把一个点的答案算完时,它的子节点所代表的子树就互不影响了!

就是说,这些子树彼此独立,可以完全当作一个新的子问题处理。

那么考虑如下算法:

  1. 对于这棵无根树,找到一个点,使得它在树的中心位置,满足如果以它为根,它的最大子树大小尽量小,这个点称为重心
  2. 以这个点为根,计算它的答案。
  3. 把以这个点为根的树的所有子树单独作为一个子问题,回到步骤\(1\)递归处理。

这个算法的复杂度是多少呢?

先介绍一个定理:以树的重心为根的有根树,最大子树大小不超过\(\frac{n}{2}\)。

假设超过了,大小为\(k>\frac{n}{2}\),那么其他子树大小之和等于\(n-k-1\)。

那么把重心往这个子树方向移动,最大子树大小一定减小,因为\(n-k<\frac{n}{2}<k\)。

那么进一步地,就证明了经过这个算法,递归的次数是\(O(log\;n)\)级别。

这样,就进一步说明了算法总时间复杂度不超过\(O(n\;log^2\;n)\)。

【算法实现】

按照上述步骤实现代码:

①计算重心位置:使用一次简单的DFS来实现。

②计算答案:直接用另一个DFS计算。

③分治子问题:重新调用寻找重心的DFS函数,再递归求解即可。

那么可以根据此,写出代码:

void GetRoot(int u,int f){
	siz[u]=1; wt[u]=0;
	eF(i,u) if(to[i]!=f&&!vis[to[i]])
		GetRoot(to[i],u), siz[u]+=siz[to[i]], wt[u]=max(wt[u],siz[to[i]]);
	wt[u]=max(wt[u],Tsiz-siz[u]);
	if(wt[Root]>wt[u]) Root=u;
}

这是寻找重心的函数,需要传入父节点,还要调用vis数组。调用前保证Root等于0,并且wt[0]等于无限大。

注意第5行,Tsiz是当前处理的树的大小,这是因为把无根树转成有根树后,父亲所连的子树也是自己的孩子了。

void Dfs(int u,int D,int f){
	arr[++cnt]=D;
	eF(i,u) if(to[i]!=f&&!vis[to[i]]) Dfs(to[i],D+w[i],u);
}

int calc(int u,int D){
	cnt=0; Dfs(u,D,0); int l=1,r=cnt,sum=0;
	sort(arr+1,arr+cnt+1);
	for(;;++l){
		while(r&&arr[l]+arr[r]>k) --r;
		if(r<l) break;
		sum+=r-l+1;
	}
	return sum;
}

这两个是计算答案的函数,Dfs统计路径长度,而calc计算答案,使用了双指针的技巧。

void DFS(int u){
	Ans+=calc(u,0); vis[u]=1;
	eF(i,u) if(!vis[to[i]]){
		Ans-=calc(to[i],w[i]);
		Root=0, Tsiz=siz[to[i]], GetRoot(to[i],0);
		DFS(Root);
	}
}

这是点分治的核心函数,传入的是当前树的重心,在调用时计算重心的答案。

然后求每个子树的重心,再递归求解。

对于刚刚的题目,有如下代码实现:

#include<cstdio>
#include<algorithm>
#include<cstring>
#define F(i,a,b) for(int i=a;i<=(b);++i)
#define eF(i,u) for(int i=h[u];i;i=nxt[i])
using namespace std;
const int INF=0x3f3f3f3f;

int n,k,Ans;

int h[10001],nxt[20001],to[20001],w[20001],tot;
inline void ins(int x,int y,int z){nxt[++tot]=h[x];to[tot]=y;w[tot]=z;h[x]=tot;}

bool vis[10001];
int Root,Tsiz,siz[10001],wt[10001];
int arr[10001],cnt;

void GetRoot(int u,int f){
	siz[u]=1; wt[u]=0;
	eF(i,u) if(to[i]!=f&&!vis[to[i]])
		GetRoot(to[i],u), siz[u]+=siz[to[i]], wt[u]=max(wt[u],siz[to[i]]);
	wt[u]=max(wt[u],Tsiz-siz[u]);
	if(wt[Root]>wt[u]) Root=u;
}

void Dfs(int u,int D,int f){
	arr[++cnt]=D;
	eF(i,u) if(to[i]!=f&&!vis[to[i]]) Dfs(to[i],D+w[i],u);
}

int calc(int u,int D){
	cnt=0; Dfs(u,D,0); int l=1,r=cnt,sum=0;
	sort(arr+1,arr+cnt+1);
	for(;;++l){
		while(r&&arr[l]+arr[r]>k) --r;
		if(r<l) break;
		sum+=r-l+1;
	}
	return sum;
}

void DFS(int u){
	Ans+=calc(u,0); vis[u]=1;
	eF(i,u) if(!vis[to[i]]){
		Ans-=calc(to[i],w[i]);
		Root=0, Tsiz=siz[to[i]], GetRoot(to[i],0);
		DFS(Root);
	}
}

int main(){
	int x,y,z;
	while(~scanf("%d%d",&n,&k)&&n&&k){
		tot=Ans=0; memset(vis,0,sizeof vis); memset(h,0,sizeof h);
		F(i,2,n) scanf("%d%d%d",&x,&y,&z), ins(x,y,z), ins(y,x,z);
		wt[Root = 0]=INF; Tsiz=n; GetRoot(1,0);
		DFS(Root);
		printf("%d\n",Ans-n);
	}
	return 0;
}

【总结】

点分治是经典的分治思想在树上的应用,是很重要的OI算法。

其精髓在于把无根树平均地分割成若干互不影响的子问题求解,极大降低了时间复杂度,是一种巧妙的暴力。

【注】

细心的读者可能已经发现,代码中在分治过程中,对下一层分治块的总结点数处理可能会出错,但是不影响复杂度,具体证明见http://liu-cheng-ao.blog.uoj.ac/blog/2969

posted @ 2018-03-20 20:38  粉兔  阅读(9647)  评论(7编辑  收藏  举报