点分治的理解与适用范围
点分治 被归到 数据结构 一章必然存在其原因,我们已知常用的类似 线段树 或者 树状数组 这些都可以实现 区间 上的维护,然而点分治 虽然是在 树 上进行的,但并非完全是图论,因为 点分治 也能维护区间的一些特性,只是由“序列上的区间”转化为了“树上两点之间的路径”。同时,这一过程需要是静态的而不能进行修改。
假设有一个由带边权的无向图构成的树,点 xxx 到点 yyy 路径上边的权值之和称为这两个点之间的距离,问长度不超过 kkk 的路径有多少条。如果用很 暴力 的方法是不是要从每个点出来跑一边图啊,那么是不是复杂度很爆炸,然而点分治可以用 O(NlogN)O(NlogN)O(NlogN) 的时间复杂度解决这个问题:
假设我们指定 ppp 节点为这个数的根,那么树上的路径可以分为两类:
1.1.1. 经过根节点 ppp ;
2.2.2. 包含于 ppp 的一棵子树中,即“不经过根节点”。
然而针对第二种情况,我们显然可以通过 递归 使其变成一个子问题,然后我们就处理时候就相当于在对链处理了。
比如这样的一条链:
我们是不是可以设置一个根节点(这里假设 333 为根节点),我们把这个图看做两部分,分别为 5−4−35 - 4 - 35−4−3 和 3−2−13 - 2 - 13−2−1 ,然后我们注意到只需分别讨论出这两部分的边的特质,就可以合在一起算了。
如果再加上一条链变成
我们同样以点 333 作为根节点,然后跑这个图,假设先跑了 3−2−13 - 2 - 13−2−1 这边,我们是不是可以把其中的答案先存到一个 vector
里面,然后再跑 3−4−53 - 4 - 53−4−5 这条边,将其中的长度和 vector
中的数据匹配组合,然后再存入 vector
中,然后再跑 3−6−73 - 6 - 73−6−7 这条边并将其中的长度和 vector
中已经存的路径长度进行匹配计算贡献。因此对于一次这样的操作,我们每个点都只会访问一遍,时间复杂度为 O(N)O(N)O(N) 。
现在考虑为什么选用点 333 来做根节点呢?因为点 333 是这个 树的重心 。我们考虑为什么要用 重心 作为根节点,比如假设我们现在要处理的 树 是一条 链 ,我们随便找了一个点,比如说是点 111
是不是后面的还是一条链,还是要每个点当成根节点来跑一遍。可是如果我们选择了点 333 作为根节点,
那么是不是只需要再以点 222 和点 444 为根节点来跑这个图。那么是不是相当于我们如果每次以重心为根节点,这个 树/子树 都最多只会有logNlogNlogN 层了,然后每层的总复杂度是 O(N)O(N)O(N) ,一共是不是就是 O(NlogN)O(NlogN)O(NlogN) 了。
这个图太小了,我们再画一个大的:
我们一开始是不是会先用这个点 888 当根节点把一整个树都遍历一遍,时间复杂度为 O(N)O(N)O(N)
第二次的时候会把点 444 和点 121212 当成根节点,遍历的区间分别为 111 到 777 和 999 到 151515 ,每个的时间复杂度是 O(N2)O(\frac{N}{2})O(2N) ,加起来就还是 O(N)O(N)O(N)
第三次我们分别以点 222 ,点 666 ,点 101010 和点 141414 作为根节点,分别为 111 到 333 ,555 到 777 ,999 到 111111 和 131313 到 151515 ,那么每个的时间复杂度是 O(N4)O(\frac{N}{4})O(4N) ,加在一起又是 O(N)O(N)O(N)
因此我们可以理解为每层子树的时间复杂度为 O(N)O(N)O(N) ,然后尽量保证层数比较少,以链作为例子我们注意到当我们每次以树的重心作为根节点的时候效率最高,一共会有 logNlogNlogN 层,至于为什么一定是用树的重心呢?因为只有在使用树的重心为根节点的时候,我们才能保证每次遍历时最大的子树最小,保证分的层数最少,而且在树为链的时候层数最多,为 logNlogNlogN 层。
代码模板
举例:假设是一个由 nnn 个点 n−1n - 1n−1 条边组成的树,求长度不超过 kkk 的路径有多少条,其中 nnn 小于等于 10410^4104 ,每条边的长度小于等于 10310^3103 , kkk 为 2312^{31}231 以内的数。
我们读入时候可以用 vector
里套 pair
来建这个图
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}) ;
}
表示点 xxx 有一条长度为 zzz 指向点 yyy 的边,点 yyy 有一条长度为 zzz 指向点 xxx 的边。点分治比较核心的过程为
void clac(int x)
{
cnt = 0 ; sz(x , x) ;
//cnt表示记录子树的节点数量
rot = x ; dp(x , x) ;
// 先假装根为x,在dp函数中确定子树的重心为根并更新节点rot
t.clear() ;
for(auto [y , w] : e[rot]) //跑图,y是这个边指向的点,w是这个边的权值
{
if(!ok[y])//ok数组表示这个点有没有当过根节点
{
int p = t.size() ;
gt(y , rot , w) ;
//在gt函数中实现把点y到rot的距离加入vector数组t中
for(int i = p ; i < t.size() ; i ++)
{
//p是原本vector的大小,现在把这之后的和之前的匹配贡献
if(t[i] <= k) ans ++ ;
ans += ask(k - t[i]) ;
//这里用树状数组维护是有点类似权值线段树的权值树状数组
}
while(p < t.size()) add(t[p ++] , 1) ;
//将这个子链的贡献加入树状数组
}
}
for(auto i : t) add(i , -1) ;
//把这个子树在树状数组中的贡献清空
ok[rot] = 1 ;
//标记当前rot节点成为过根节点
for(auto [y , w] : e[rot])
if(!ok[y]) clac(y);
//如果这个边指向的节点没有成为过根节点,那么跑这个子树
}
我们假设这个 xxx 为根节点,但也只是暂时这样,然后找到 真正的重心 作为 根节点 ,然后这个 ttt 就是一个 vector
用来存以该节点为根节点后跑过的边的长度。
其他函数如下:
树状数组维护部分
int lowbit(int x) {return x & -x ;}
void add(int x , int y)
{
if(x == 0)
{
c[x] += y ;
return ;
}
while(x <= 10000000)
{
c[x] += y ;
x += lowbit(x) ;
}
}
int ask(int x)
{
if(x > 10000000) x = 10000000 ;
if(x < 0) return 0 ;
int t = c[0] ;
for( ; x ; x -= lowbit(x)) t += c[x] ;
return t ;
}
这里的 树状数组 有点维护 权值 的意思,可以理解为小于等于 xxx 的数有多少个,根据数据范围我们注意到 10410^4104 条边,每个长度为 10310^3103 ,最长的路径为 10710^7107 ,当询问的 kkk 大于 10710^7107 时,我们可以认为他问的就是 小于等于 10710^7107 的长度有多少个。
求子树大小部分
int sz(int x , int fa)
{
cnt ++ ;
d[x] = 1 ;
for(auto [y , w] : e[x])
if(y != fa && ! ok[y])
d[x] += sz(y , x) ;
return d[x] ;
}
这里我们注意到在最开始调用的时候并没有设置赋值给谁,然后我们发现 int
声明的函数也可以不赋值给谁,单独调用。这里的 d[x]
求出来的是这个子树中以 xxx 为根节点的子树的大小。每递归访问一个节点后 cnt ++ ;
,因此记录了这个子树的大小,我们注意到访问这个节点的时候要求 !ok[y]
,就是说之前做过根节点的就不会再被访问了,然后树上任意两点的路径是唯一的,因此不会有多余的遍历,也恰好证明了前文的复杂度。
DP找到真正的重心作为根节点
void dp(int x , int fa)
{
f[x] = cnt - d[x] ;
//d[x]是原来那个访问顺序时子树的大小,用cnt减一下就得到了另一边的子树大小,记录为f[x]
for(auto [y , w] : e[x])
if(fa != y && !ok[y])
{
f[x] = max(f[x] , d[y]) ;
//这里还没有更新rot,d[y]也还是原来访问顺序的子树大小
dp(y , x) ;
}
if(f[x] < f[rot]) rot = x ;
//如果当前节点x的最大子树小于rot节点的最大子树,那么更新x为新的根节点
}
这里访问节点时也要求满足 !ok[y]
,所以也不会有很多余的复杂度。
把rot到点x的距离加入vector中
void gt(int x , int fa , int dis)
{
t.push_back(dis) ;
for(auto [y , w] : e[x])
if(fa != y && !ok[y])
gt(y , x , dis + w) ;
}
在主函数中调用时
因为我们一开始是随便找一个节点假装是 根 的,所以调用时就可以直接:
clac(1) ;
这种。
然后这个过程有点像比如我们一开始的 111 是根节点
然后我们跑这个图,先把第一条链的贡献加入 vector
中
然后第二条链的贡献和之前的匹配,并把第二条链的贡献也加入 vector
中
然后将最后一条链的也和 vector
中的贡献匹配计算,同时也都要用 权值树状数组 维护,跑完这个子树最后再都记得清空。