点分治详解
初学者写了两篇模板题后略有心得,故写此篇博客帮助和我一样的初学者理解,本博客会结合两道模板题来进行解释点分治。若有错误的理解和解释希望大佬们指出。
点分治,是处理树上路径的一个极好的工具。
一般如果需要大规模处理树上路径,点分治是一个不错的选择。
一. 点分治的基本思想
点分治,顾名思义就是基于树上的节点进行拆分。对于点的拆开其实就是对于树的拆开。所以我认为点分治的本质其实是将一棵树拆分成许多棵子树处理,并不断进行。这应该也是点分治的精髓。
对于树上路径而言,所有路径都可以由两个点的最近路径来表示。两个点的最近路径又可以由这两个点到他们的最近公共祖先节点(root)的距离之和表示。
列如这样一棵简单的二叉树,他所有的路径都可以用点对来表示(1,5),(2,4)等等,(5,7)的路径又可以表示成(5,1)+(1,7)
这样的路径表示就是点分治的核心。
于是在寻找树上路径时我们可以以所有子树的根节点为根,来遍历树上路径。以上图为例(假设所有的边权都是1)
首先我们1号节点为根节点,先将它的左子树遍历完,得到(1,2),(1,5),(1,4)
这样的三条路径。再遍历右子树,得到(1,3),(1,6),(1,7)
三条路径。这样的三条路径可以与之前的三条路径互相组合,(1,2)+(1,3)=(2,3)...
这样可以得到3*3条新的路径。对于多叉树也是同样的原理,只是多遍历一些子树罢了。
我们可以得到一个重要的结论,对于确定的根节点,任意两个属于不同子树的节点可以组合出一条新的路径,但属于一棵子树的却不能组合,(1,2),(1,5)
属于同一棵子树,若将他们组合得到的路径等于将(1,2)的路径计算了两次,这样的路径明显是不合法的。
上述的那些路径与路径的组合显然还不是这棵树的全部路径,(4,5)的路径就不包含在其中,于是我们在以1号节点为根的路径搜索完毕后,就可以将其删去,以其的子树节点2号点为根,重复上述过程。这就是点分治的大致思路了,接下来就是一些重要的细节。
二. 求取子树的重心
并不是所有的树都是像上面的二叉树那样分布均匀,如果树是一棵链会有怎样的后果呢。
若我们还以节点1为根节点来进行点分治的话,将以1号为根的所有路径遍历完后,将其删去再以2为根节点,这样的过程要进行n次,那么这和暴力求解遍历也没有什么区别了复杂度极高。但是若是我们以这棵链的4号节点为根节点呢,这样的是不是就平衡了,左右子树都是三个节点。于是我们就有了标题的操作,找重心,每次将根节点删去后,不是盲目的以子树的第一个节点为根,而是找这棵子树的重心节点为根,这样就能大大降低复杂度了具体复杂度的分析我也不会 。
三. 时间复杂度分析
时间复杂度: O ( ( n + m ) l o g n ) O((n + m)logn) O((n+m)logn),其中 n n n 为树的大小, m m m 视具体求解而定,对于一般情况以及本题,求解答案时的复杂度在于排序,本题使用的是桶排序 O ( n ) O(n) O(n), 于是复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),若使用其他排序方法则复杂度要多一个 l o g log log, O ( n ( l o g n ) 2 ) O(n(logn)^2) O(n(logn)2)。
首先我们要知道重心的性质:以树的重心为根时,所有子树的大小都不超过整棵树大小的一半。
设当前树的根为 u u u,也是树的重心,节点数为 n n n ,当每次我们递归到下一层时,下一层子树以 v v v 为根的节点数量至多为 n / 2 n/2 n/2,所以最多会递归 l o g n logn logn 层 ,节点数就为 1 1 1 。
而每层的所有不同子树的节点数之和 ∑ s i z v < n \sum siz_{v} < n ∑sizv<n (因为在计算父亲节点(重心)时,会将父节点去掉),每层的求重心和求距离时间复杂度都是 O ( n ) O(n) O(n)。
最终我们求解答案时要将所有到根的节点的距离排序,使用的是桶排序算法,复杂度 O ( m ) = O ( n ) O(m) = O(n) O(m)=O(n)。(这里以例题一为例)
所以最终的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。
四. 例题
其实对于不同的题而言,求取树上路径的方式会有不同的地方,根据题目变化而变化,对于太大的树肯定不能用过于的暴力的方式,就点分治模板而言,这一部分的代码会有些许不同。接下来结合两道模板题进行讲解,代码中都会有详细的注释。
第一题P3806 【模板】点分治1
// https://www.luogu.com.cn/problem/P3806
//给定一棵有 n 个点的树,询问树上距离为 k 的点对是否存在。
#include <queue>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1e4 + 5, K = 1e7 + 5, M = 120;
const int inf = 1 << 30;
struct edge{
int to,nex,dis;
}e[N*2];
int head[N],quer[M],ans[M],tot;//quer:记录当前询问 ans:记录当前询问是否有答案,
int d[N],cnt;//用于存储当前询问的根节点(重心)的所有子树节点到该根节点的距离,cnt:不同子节点产生的路径距离的编号
int dis[N],n,m;//dis[i]:节点i离当前根节点的距离
bool vis[N],judg[K];//vis:当前点是否被删除, judg:距离当前根节点为i的点是否存在
void add(int from,int to,int w){
e[++ tot].nex = head[from];
e[tot].dis = w;
e[tot].to = to;
head[from] = tot;
}
int siz[N], root, Min = inf;//Min:最小的最大子树 的 大小,siz:每次都更新子树大小,root:根节点(重心)
void get_root(int u,int fa,int num){//找重心,num:整颗子树的大小,每次分治都是新的树,所以树的大小是单独子树的大小
siz[u] = 1;
int Max = 0;
for(int i = head[u]; i; i = e[i].nex){
int v = e[i].to;
if(v == fa || vis[v])continue;
get_root(v,u,num);
siz[u] += siz[v];
Max = max(Max, siz[v]);
}
Max = max(Max, num - siz[u]);
if(Max < Min) Min = Max, root = u;
}
void get_dis(int u,int fa){//获取子节点到根节点的距离
if(dis[u] <= 1e7) d[cnt ++] = dis[u];//记录下当前子树的树上路径距离,当遍历完整颗子树后才能放入judg数组
for(int i = head[u]; i; i = e[i].nex){
int v = e[i].to;
if(vis[v] || v == fa) continue;
dis[v] = dis[u] + e[i].dis;
get_dis(v,u);
}
}
void get_res(int u){
queue<int>q;//因为judg数组的大小为1e7,重置时用memset会超时,所以保存judg数组的记录,清空时按队列顺序重置
for(int i = head[u]; i; i = e[i].nex){
int v = e[i].to;
if(vis[v]) continue;
dis[v] = e[i].dis;
cnt = 0;// 用于记录v这颗子树的节点到根节点的路径条数
get_dis(v, u);//获取子树节点到根节点的距离
//judg[dis]记录的是其他子树节点(之前遍历过的子树)到根节点距离为dis的长度是否存在
//d[dis]记录的是本次遍历的子树节点到根节点距离为dis的距离是否存在
for(int j=0;j<cnt;j++)
for(int k=0;k<m;k++)
if(quer[k] >= d[j]) ans[k] |= judg[quer[k] - d[j]];
//当询问的距离 >= d[j]时 若询问的距离-d[j]的距离存在,那么judg[dis]就能和d[dis]组合出长度为quer[dis]的路径
//只有当两个距离dis是不同子树到根节点距离时才能组合,同一棵子树是不能的
for(int j = 0; j < cnt; j ++){//将本棵子树记录下的路径距离存进judg数组,对下一棵子树而言,本次的距离就是可以组合的
q.push(d[j]);
judg[d[j]] = 1;
}
}
//以root为根节点的路径全部遍历完,此时我们应将root删去,以其的子树重新寻找重心来寻找新的树上路径,之前的judg数组应清空
while(!q.empty()){
judg[q.front()] = 0;
q.pop();
}
}
void Divide(int u){ // 每次传入的u都是子树的重心
vis[u] = judg[0] = 1;
get_res(u); // 求解以 u 根的子树
for(int i = head[u]; i; i = e[i].nex){
int v = e[i].to;
if(vis[v]) continue; // 因为每次是找重心为根所以不能只以!=fa防止遍历到父亲节点,其余vis同理
Min = inf, root = 0;
get_root(v,0,siz[v]); // 寻找子树的重心
Divide(root); // 进行递归求解子树
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i = 1; i < n; i ++){
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
for(int i = 0; i < m; i ++){
scanf("%d",&quer[i]); // 储存答案离线查询
}
get_root(1, 0, n);
Divide(root);
for(int i = 0; i < m; i ++){
if(ans[i])printf("AYE\n");
else printf("NAY\n");
}
return 0;
}
#include<stdio.h>
#include<algorithm>
using namespace std;
const int N = 2e4+5;
const int INF = 1<<30;
struct edge
{
int nex,to,dis;
}e[N*2];
int head[N],tot;
void add(int from,int to,int dis)
{
e[++tot].to = to;
e[tot].dis = dis;
e[tot].nex = head[from];
head[from] = tot;
}
int root,siz[N],vis[N],minsize=INF;
void getroot(int u,int fa,int size)//获取根节点(重心)
{
siz[u] = 1;
int maxsize = 0;
for(int i=head[u];i;i=e[i].nex)
{
int v = e[i].to;
if(v==fa||vis[v])continue;
getroot(v,u,size);
siz[u] += siz[v];
maxsize = max(maxsize,siz[v]);
}
maxsize = max(maxsize,size-siz[u]);//自己的所有子树求完后,自己的父亲节点那个方向的子树还没有求,所有要判断size-siz[u]
if(maxsize<minsize)
{
root = u;//记录根节点
minsize = maxsize;
}
}
int sum1=0,sum2=0;//分子,分母
int num[10],temp[10],dis[N];//num[i]代表%3余i的路径个数,temp作用等同于num,和dis[i]都用作临时统计
void getdis(int u,int fa)//获取本子树到根节点的距离,与距离的模数
{
temp[dis[u]%3]++;//用作临时存储,本棵子树所有点到根节点的路径长度%3的结果
for(int i=head[u];i;i=e[i].nex)
{
int v = e[i].to;
if(vis[v]||v==fa)continue;
dis[v] = dis[u]+e[i].dis;
getdis(v,u);
}
}
void solve(int rt)//以下所有本子树,代指以rt为根的子树
{
dis[rt] = 0;
for(int i=head[rt];i;i=e[i].nex)
{
int v = e[i].to;
if(vis[v])continue;
dis[v] = dis[rt]+e[i].dis;//每次进行getdis时,dis[rt]都是新的值,不需要对所有dis数组重置
getdis(v,rt);
//计算出结果,这棵子树上所有可能的被3整除的路径,除了本身长度就是3的,剩下有三种可能与其他子树组成合法路径
sum1 += temp[1]*num[2];//本子树路径长度%3=1的与其他子树路径长度%3=2的可以组合成一条新的路径
sum1 += temp[2]*num[1];//同理
sum1 += temp[0]*num[0];//同理,之所以不需要单独计算本子树dis=3的路径,是因为在最开始时我就使num[0]=1.即根节点到根节点路径长度为0,0%3=0
num[0] += temp[0];//将本子树的结果并入整体,对下一棵子树来说,本子树的结果就是可以组合的
num[1] += temp[1];
num[2] += temp[2];
temp[0] = temp[1] = temp[2] = 0;//重置
}
}
void divide(int rt)
{
num[0] = vis[rt] = 1;//删去旧的根节点
solve(rt);//以新的根节点进行分治
num[0] = num[1] = num[2] = 0;//重置
for(int i=head[rt];i;i=e[i].nex)
{
int v = e[i].to;
if(vis[v])continue;
minsize = INF;
getroot(v,0,siz[v]);//在新的子树中寻找根节点
divide(root);
}
}
int main()
{
int n,i,u,v,w;
scanf("%d",&n);
for(i=0;i<n-1;i++)
{
scanf("%d%d%d",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
getroot(1,0,n);
divide(root);
sum2 = n*n;//因为两个人可以选择的点的数量都是n,那么所有路径的可能就是n*n
//此时sum1求出来的值只是两个点的一种可能,也就是说如果(1,3)是合法路径,那么(3,1)也是,所以需要*2,不要忘记路径长度为0的点也是合法的(i,i)共有n个
sum1=sum1*2+n;
int x = __gcd(sum1,sum2);//因为答案要求最简分式,那么求一下gcd就行了
printf("%d/%d",sum1/x,sum2/x);
return 0;
}
/*
样例1
10
1 2 5
1 4 10
1 5 9
1 6 5
1 7 3
2 3 8
5 8 6
8 9 9
8 10 2
答案:2/5
*/
可以看到两道题的solve函数部分有一些不同,其他地方都几乎一模一样,这就是上面所说的由于题目求解问题的不同造成的求路径方式的不同导致的。
由于代码中已经做了较为详细的注释,这里就不再赘述了,将这两道模板完成后,可以多练练手相信很快就能学会点分治算法。最后还希望大佬们能指出这篇博客的不足之处,我会尽快修改。
发布时间:2021.8.31
更新:2023.8.06
发现队友在学,于是更新一下对于点分治的新理解
新增了时间复杂度分析 和 修改了例题一的代码(新码风以及新注释)