点分治入门

点分治

在以前,我们做的都是在序列上的分治问题,现在我们研究一类用于树上静态路径统计的分治算法——点分治(动态需要扩展-点分树)
其核心思想是在树上进行分治
例题:TREE
给定一个有 N 个点(编号 0,1,…,N−1)的树,每条边都有一个权值(不超过 1000)。

树上两个节点 x 与 y 之间的路径长度就是路径上各条边的权值之和。

求长度不超过 K 的路径有多少条。

分析,若我们采用分治思想,则树上所有的路径可以分为两个部分:

  1. 经过p的路径
  2. 不经过p的路径

在一次solve中,我们仅仅处理第一类路径,然后递归处理第二类路径
下面我们来讨论如何在\(O(n)\)的时间内求出经过\(p\)的路径中长度不超过\(K\)
在第一类路径中,很明显每一条路径\(x,y\)满足\(LCA(x,y)=p\),于是求路径可以使用一次\(dfs\)求出\(p\)的所有子树节点与p的距离d,但在枚举路径时,我们要保证\(LCA(x,y)=p\),于是引进数组\(b\)\(b[x]\)表示节点\(x\)属于p节点的\(b[x]\)子节点,这样\(b[x]\ne b[y] \leftrightarrow LCA(x,y)=p\)
为了减少冗余运算,参考归并排序思路,我们可以将所有的\(d\)进行排序,采用双指针扫描法
最初l=1,r=tot//tot为数量
可以发现的是,对于每一个\(l\),满足\(d[l]+d[r]\le k\)的最大的\(r\)的决策是单调递减的,于是在每一次循环后,将l++,然后不断找到满足\(d[l]+d[r]\le k\)的最大的\(r\),也即while(d[l]+d[r]>k)r--;,按照区间计数,我们就可以发现:\(\forall i\in[l+1,r]\)都满足\(d[l]+d[i]\le k\),那么就有\(r-l\)个满足的路径,累加上答案
但这样做有着缺陷,也即在\([l+1,r]\)中可能有节点\(i\)满足\(b[i]=b[a[l]]\)(a便是排序前的节点编号),为了统计需要满足的互斥性,我们需要排除这种情况,一个好的解决方案便是使用数组\(cnt\)统计每一个\(b\)值的出现次数,然后在双指针扫描时范围的不断缩小就不断从\(cnt\)里删除,累加答案的时候就变成了\(r-l-cnt[b[a[l]]]\)

统计方法说完了,便需要思考如何拆分树使得分治复杂度最优
回想起数列分治,都是分成\(\frac{1}{2}\),于是我们采用树的重心进行分治

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
int cnt[10005],vis[10005],vis2[10005],a[10005],k,rt,sum,ans,b[10005],d[10005],tot,siz[10005],head[10005],tot2,ver[20005],nxt[20005],cost[20005];
void add(int u,int v,int w){
	ver[++tot2]=v,nxt[tot2]=head[u],cost[tot2]=w,head[u]=tot2;
}
void find_root(int S,int u){
	siz[u]=1;
	int maxx=0;
	vis2[u]=1;
	for(int i=head[u];i;i=nxt[i]){
		if(vis[ver[i]]||vis2[ver[i]])continue;
		find_root(S,ver[i]);
		siz[u]+=siz[ver[i]];
		maxx=max(maxx,siz[ver[i]]); 
	}
	maxx=max(maxx,S-siz[u]);
	if(maxx<ans){
		ans=maxx;
		rt=u;
	}
} 
void dfs(int u){
	vis2[u]=1;
	for(int i=head[u];i;i=nxt[i]){
		if(vis[ver[i]]||vis2[ver[i]])continue;
		d[ver[i]]=d[u]+cost[i];
		b[ver[i]]=b[u];
		a[++tot]=ver[i];
		cnt[b[u]]++; 
		dfs(ver[i]);
	}
}
bool cmp(int a,int b){
	return d[a]<d[b];
}
void solve(int S,int u){
	memset(vis2,0,sizeof vis2);
	ans=S,rt=-1;
	find_root(S,u);
	memset(d,0,sizeof d);
	memset(cnt,0,sizeof cnt);
	memset(vis2,0,sizeof vis2);
	tot=0;
	a[++tot]=rt;
	b[rt]=rt;
	vis[rt]=1;
	cnt[rt]++;
	for(int i=head[rt];i;i=nxt[i]){
		if(vis[ver[i]]||vis2[ver[i]])continue;
		b[ver[i]]=ver[i];
		d[ver[i]]=cost[i];
		a[++tot]=ver[i];
		cnt[ver[i]]=1;
		dfs(ver[i]);
	}
	int l=1,r=tot;
	sort(a+1,a+tot+1,cmp);
	--cnt[b[a[l]]];
	while(l<r){
		while(d[a[l]]+d[a[r]]>k)cnt[b[a[r--]]]--;
		sum+=r-l-cnt[b[a[l]]];
		cnt[b[a[++l]]]--;
	}
	int v=rt;
	for(int i=head[v];i;i=nxt[i]){
		if(!vis[ver[i]])solve(siz[ver[i]],ver[i]);
	}
}
int main(){
	int n,m,u,v,w;
	while(~scanf("%d%d",&n,&k)&&n&&k){
		memset(head,0,sizeof head);
		tot2=0;
		memset(vis,0,sizeof vis);
		for(int i=1;i<n;i++){
			scanf("%d%d%d",&u,&v,&w);
			add(u,v,w);
			add(v,u,w);
		}
		sum=0;
		solve(n,1);
		printf("%d\n",sum);
	}
}

事实上,本题在统计的时候还有一种方法,就是运用容斥原理,对于同一子树的不超过\(k\)的路径我们同样累计上,然后在所有的子树里计算\(\le k-cost[i]\)的路径数量,答案减去它即可

接着来一点点分树的概念
如果把分治每个子树的重心向当前重心连边,则得到点分树,高度不超过logn
点分树的性质是对于每个点,它子树上的点互相组成的路径上包含这个点。
这个性质可以用来解决一些带查询的点分治问题(如scoi2016 幸运数字)
如果每个点维护它的子树信息,则可以在一些问题上支持修改,如震波。
这种题目要先从点分的角度思考,然后再考虑是否能动态点分。
【模板】点分树 | 震波
在一片土地上有 \(n\) 个城市,通过 \(n-1\) 条无向边互相连接,形成一棵树的结构,相邻两个城市的距离为 \(1\),其中第 \(i\) 个城市的价值为 \(value_i\)

不幸的是,这片土地常常发生地震,并且随着时代的发展,城市的价值也往往会发生变动。

接下来你需要在线处理 \(m\) 次操作:

0 x k 表示发生了一次地震,震中城市为 \(x\),影响范围为 \(k\),所有与 \(x\) 距离不超过 \(k\) 的城市都将受到影响,该次地震造成的经济损失为所有受影响城市的价值和。

1 x y 表示第 \(x\) 个城市的价值变成了 \(y\)

为了体现程序的在线性,操作中的 \(x\)\(y\)\(k\) 都需要异或你程序上一次的输出来解密,如果之前没有输出,则默认上一次的输出为 \(0\)

首先我们知道,单次询问,树上路径的问题可以用点分治解决。

但如果加上什么 \(q\) 次询问之类的东西怎么办呢?比如说这题。

显然每次都跑一遍点分治时间复杂度肯定吃不消。

考虑把点分治的过程离线下来,将当前树的重心与上一层的树的重心连边,这样就可以得到一棵树,我们称之为“点分树”

比如说我们有如下图所示的树:

建出点分树来如下图所示:

很明显,我们建出的点分树与原树几乎没有联系,父子关系完全被打乱了,也无法通过两点在点分树上的距离算出它们在原树上的距离。甚至有可能某两点在点分树上是父子关系,在原树上相隔十万八千里,或者某两点在原树上是父子关系,在点分树上相隔十万八千里(当然只是相对来说)。

那么这棵树对于我们做题有什么帮助呢?

有的问题我们不是非常关心树的形态特点,比如路径问题,联通块问题,寻找关键点问题等等,以路径问题为例,我们不一定非得查到 \(p,q\)\(LCA\) 才可以处理 \(p,q\) 的路径信息,相反,我们可以随便从这个路径上寻找一个分割点 \(t\),只要我们可以快速的处理\(p\)\(t\)\(q\)\(t\) 的信息,我们就可以处理\(p\)\(q\) 的信息。

而点分树恰恰就是对原树做了这样的映射。

它有以下性质:

  1. 它的高度与点分治的深度一样,只有 \(\log n\) 级别,这个性质很关键,由于它的高度只有 \(\log n\),所以我们可以搞出各种各样在一般树论里过不去的暴力做法,比如说对每个点开个包含子树中所有点的 \(vector\),空间复杂度也只有。
  2. 对于任意两点 \(u,v\),唯一可以确定的是 \(u,v\) 在点分树上的 \(LCA\)一定在 \(u\to v\) 的路径上。换句话说,\(dis(u,v)=dis(u,lca)+dis(lca,v)\)

回到这题来,我们要求 \(\sum\limits_{dis(x,y)\leq k}a_y\)
考虑枚举 \(x,y\) 在点分树上的 \(LCA\) \(z\)(这显然是 \(\log n\)级别的),根据上面的推论有\(dis(x,y)=dis(x,z)+dis(y,z)\)

\[ans=\sum\limits_{dis(x,z)+dis(z,y)\leq k\& LCA(x,y)=z}a_y=\sum\limits_{dis(z,y)\leq k-dis(x,z)\&LCA(x,y)=z}a_y \]

考虑什么样的 \(y\) 满足 \(LCA(x,y)=z\),显然符合要求 \(y\) 组成的集合就是 \(z\)的子树抠掉 \(z\)\(x\) 方向上的儿子 \(s\) 的子树。而我们要求这个点集中到 \(z\)的距离 \(\leq k-dis(x,z)\) 的点权和。显然可以拿 \(z\) 的子树内到 \(z\) 的距离 \(\leq k-dis(x,z)\) 的点权和 − s 子树中到 z 的距离 \(\leq k-dis(x,z)\) 的点权和。

对每个点 \(x\) 建一棵动态开点线段树,下标为 \(i\) 的位置维护 \(x\) 子树内所有 \(dis(x,z)=i\)\(a_z\)的和。

那么求 \(z\) 子树内到 \(z\) 的距离 \(\leq k-dis(x,z)\) 的点权和就在对应线段树上查个区间和就 \(ok\) 了。

\(z\)\(x\) 方向上的儿子 \(s\) 的子树怎么办呢?

初学点分树的萌新(例如我)很容易进入一个误区,那就是这东西可以在 \(s\) 对应的线段树上查 \([0,k-dis(x,z)-1]\)的和。但这显然是错的,因为两点在点分树上的距离与两点在原树上的距离没有一丁点联系。到 \(s\) 距离 \(\leq k-dis(x,z)-1\),并不意味着到 \(z\) 距离 \(\leq k-dis(x,z)\)

那么正解是什么呢?考虑对于每个点再建立一棵动态开点线段树,线段树上下标为 \(i\) 的位置维护 \(x\) 子树内到 \(fa_x\)距离 \(=i\)的点权和。解决 \(z\)\(x\) 方向上的儿子\(s\) 的子树的问题只需在点 \(s\) 的线段树上查询 \([0,k-dis(x,z)]\)的和就行了。

#include <bits/stdc++.h>
using namespace std;
#define fi first
#define se second
#define fz(i,a,b) for(int i=a;i<=b;i++)
#define fd(i,a,b) for(int i=a;i>=b;i--)
#define ffe(it,v) for(__typeof(v.begin()) it=v.begin();it!=v.end();it++)
#define fill0(a) memset(a,0,sizeof(a))
#define fill1(a) memset(a,-1,sizeof(a))
#define fillbig(a) memset(a,63,sizeof(a))
#define pb push_back
#define ppb pop_back
#define mp make_pair
template<typename T1,typename T2> void chkmin(T1 &x,T2 y){if(x>y) x=y;}
template<typename T1,typename T2> void chkmax(T1 &x,T2 y){if(x<y) x=y;}
typedef pair<int,int> pii;
typedef long long ll;
template<typename T> void read(T &x){
	x=0;char c=getchar();T neg=1;
	while(!isdigit(c)){if(c=='-') neg=-1;c=getchar();}
	while(isdigit(c)) x=x*10+c-'0',c=getchar();
	x*=neg;
}
const int MAXN=1e5;
const int MAXP=5e6;
const int LOG_N=17;
const int INF=1e9;
int n,qu,a[MAXN+5];
int hd[MAXN+5],to[MAXN*2+5],nxt[MAXN*2+5],ec=0;
void adde(int u,int v){to[++ec]=v;nxt[ec]=hd[u];hd[u]=ec;}
int fa[MAXN+5][LOG_N+2],dep[MAXN+5];
void dfs0(int x,int f){
	fa[x][0]=f;
	for(int e=hd[x];e;e=nxt[e]){
		int y=to[e];if(y==f) continue;
		dep[y]=dep[x]+1;dfs0(y,x);
	}
}
int getlca(int x,int y){
	if(dep[x]<dep[y]) swap(x,y);
	for(int i=LOG_N;~i;i--) if(dep[x]-(1<<i)>=dep[y]) x=fa[x][i];
	if(x==y) return x;
	for(int i=LOG_N;~i;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i];
	return fa[x][0];
}
int getdis(int x,int y){return dep[x]+dep[y]-(dep[getlca(x,y)]<<1);}
int siz[MAXN+5],mx[MAXN+5],cent=0;
bool vis[MAXN+5];
void findcent(int x,int f,int tot){
	siz[x]=1;mx[x]=0;
	for(int e=hd[x];e;e=nxt[e]){
		int y=to[e];if(y==f||vis[y]) continue;
		findcent(y,x,tot);chkmax(mx[x],siz[y]);siz[x]+=siz[y];
	} chkmax(mx[x],tot-siz[x]);
	if(mx[x]<mx[cent]) cent=x;
}
int dfa[MAXN+5];
void divcent(int x,int tot){
//	printf("%d\n",x);
	vis[x]=1;
	for(int e=hd[x];e;e=nxt[e]){
		int y=to[e];if(vis[y]) continue;
		cent=0;int sz=(siz[y]<siz[x])?siz[x]:(tot-siz[x]);
		findcent(y,x,sz);dfa[cent]=x;divcent(cent,sz);
	}
}
struct segtree{
	int rt[MAXN+5],ncnt=0;
	struct node{int ch[2],val;} s[MAXP+5];
	void modify(int &k,int l,int r,int p,int x){
		if(!k) k=++ncnt;
		if(l==r){s[k].val+=x;return;}
		int mid=(l+r)>>1;
		if(p<=mid) modify(s[k].ch[0],l,mid,p,x);
		else modify(s[k].ch[1],mid+1,r,p,x);
		s[k].val=s[s[k].ch[0]].val+s[s[k].ch[1]].val;
	}
	int query(int k,int l,int r,int ql,int qr){
		if(!k) return 0;
		if(ql<=l&&r<=qr) return s[k].val;
		int mid=(l+r)>>1;
		if(qr<=mid) return query(s[k].ch[0],l,mid,ql,qr);
		else if(ql>mid) return query(s[k].ch[1],mid+1,r,ql,qr);
		else return query(s[k].ch[0],l,mid,ql,mid)+query(s[k].ch[1],mid+1,r,mid+1,qr);
	}
} w1,w2;
void modify(int x,int v){
	int cur=x;
	while(cur){
		w1.modify(w1.rt[cur],0,n-1,getdis(cur,x),v);
		if(dfa[cur]) w2.modify(w2.rt[cur],0,n-1,getdis(dfa[cur],x),v);
		cur=dfa[cur];
	}
}
int query(int x,int k){
	int cur=x,pre=0,ret=0;
	while(cur){
		if(getdis(cur,x)>k){
			pre=cur;cur=dfa[cur];continue;
		}
		ret+=w1.query(w1.rt[cur],0,n-1,0,k-getdis(cur,x));
		if(pre) ret-=w2.query(w2.rt[pre],0,n-1,0,k-getdis(cur,x));
		pre=cur;cur=dfa[cur];
	} return ret;
}
int main(){
	scanf("%d%d",&n,&qu);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	for(int i=1;i<n;i++){int u,v;scanf("%d%d",&u,&v);adde(u,v);adde(v,u);}
	dfs0(1,0);for(int i=1;i<=LOG_N;i++) for(int j=1;j<=n;j++)
		fa[j][i]=fa[fa[j][i-1]][i-1];
	mx[0]=INF;cent=0;findcent(1,0,n);divcent(cent,n);
//	for(int i=1;i<=n;i++) printf("%d\n",dfa[i]);
	for(int i=1;i<=n;i++) modify(i,a[i]);
	int preans=0;
	while(qu--){
		int opt,x,y;scanf("%d%d%d",&opt,&x,&y);
		x^=preans;y^=preans;
		if(opt==0){preans=query(x,y);printf("%d\n",preans);}
		else{modify(x,y-a[x]);a[x]=y;}
	}
	return 0;
}

对于点分树问题
一个比较常见的套路是这样的:

进行一次点分治,求出每个点在点分树上的父节点。

  1. 对于每个点,开一个数据结构\(S_1\),存储点分树子树的贡献,再开一个数据结构\(S_2\),存储点分树父亲的贡献,用来容斥,防止算重。
  2. \(x\)进行修改时,从\(x\)开始不断跳点分树的父亲一直到根,每次对经过的节点的\(S_1, S_2\)修改它的贡献,时空复杂度为\(点分树高 \times节点上的子数据结构单次修改复杂度\)
  3. \(x\)进行查询时,从\(x\)开始不断跳点分树的父亲一直到根,每次把\(S_1\)的贡献添加进答案,把\(S_2\)的贡献从答案刨去,时间复杂度为\(点分树高 \times节点上的子数据结构单次查询复杂度。\)

初始化可视为直接进行n次修改。

posted @ 2022-11-30 22:30  spdarkle  阅读(46)  评论(0编辑  收藏  举报