树上分治主要是针对于树上路径问题的,一种使用分治思想解决的方法。主要分为树分治(树上点分治、边分治)以及动态树分治两个部分。
点分治
适合处理大规模的树上路径信息问题。
例题
luogu P3806【模板】点分治 1 给定一颗 n 个点的带点权树,m 次询问,每次询问给出 k,询问树上距离为 k 的点对是否存在。
n ≤ 1e5,m ≤ 100,k ≤ 1e9
先随意选择一个节点作为根节点 rt:这样,所有完全位于其子树中的路径可以分为两种—— 一种是经过当前根节点的路径,一种是不经过当前根节点的路径;对于经过当前根节点的路径,又可以分为两种,一种是以根节点为一个端点的路径,另一种是两个端点都不为根节点的路径;而后者又可以由两条属于前者链合并得到。
因此,对于枚举的根节点 rt :我们可以先计算在其子树中且经过该节点的路径对答案的贡献,再递归其子树对不经过该节点的路径进行求解。
对于这个题目,对于经过根节点 rt 的路径,我们先枚举其所有子节点 ch ,以 ch 为根计算 ch 子树中所有节点到 rt 的距离。记节点 i 到当前根节点 rt 的距离为 , 表示之前处理过的子树中是否存在一个节点 v 使得 = d 。若一个询问的 k 满足 = true ,则存在一条长度为 k 的路径。在计算完 ch 子树中所连的边能否成为答案后,我们将这些新的距离加入 tf 数组中。
一般来说,清空 tf 数组不应使用 memset
,这会导致 TLE;正确的做法是将之前使用过的位置加入一个数组中,清空的时候使用这个数里的记录值清空,才可以保证时间复杂度。
点分治过程中,每一层的所有递归过程合计对每个点处理一次,假设共递归 h 层,则总时间复杂度为 O(nh)。但是若我们每次选择子树的重心作为根节点,可以保证递归层数最少,时间复杂度为 O(nlogn)。每一次在确定根节点之前统计子树大小,并且找到一个根,使得最大子树大小最小,就找到了重心。
请注意在重新选择根节点之后一定要重新计算子树的大小,否则一点看似微小的改动就可能会使时间复杂度错误或正确性难以保证。
例题的代码(来自 OI Wiki):
1 |
|
点分治的经典例题还有这个题目:luogu P4178 Tree
找重心
可以看到,找到正确的重心是保障算法复杂度的关键;首先下定义:一棵树的最大子树最小的点有一个名称,叫做重心;它有一个特点是以它为根的每一个子树的大小都不超过 n/2,这可以使用反证法来证明;正因为重心有这样的特点,所以每一次都选取重心进行递归,可以保障复杂度是 O(nlogn)。
找到子树也是依靠了这个特性:每次 DFS 整棵树,并且统计最大子树,就可以确定树的重心:
1 | void findrt(int u,int fa){ |
这个过程的复杂度是 O(n) 的。
分治过程
不同的题目可能具体实现不同,但是大概结构如下:
1 | void divide(int u){ |
有一个要提的地方是:在对于子树的 findrt
之前的传递全树大小的位置,直接传递了 sx[v]
;实际上这个地方应该传递的是 size=sz[v]>sz[u]?totsz-sz[u]:sz[v]
,但是这个错误的写法在这里并不会影响算法的正确性或者复杂度。数学证明可以看这里:传送门
动态点分治
上面的点分治只可以处理静态的问题,如果问题是动态的,也就是要求待修改状态的话,上面的方法就不能直接使用了;对于动态点分治问题来说,修改的只有点权值,整棵树的结构是不变的——这意味着我们每一次进行点分时选到的重心也是不变的;又因为遍历连通块是 O(n) 的,点分治的复杂度仅和上述的递归深度相关。
“树上的动态点分治相当于序列上的线段树”
点分树
简单的说,把上面说的点分治里每一层找到的重心之间连边,就构成了一颗高度为 logn 树,也就是点分树。
官方的说,就是通过更改原树形态使树的层数变为稳定 logn 的一种重构树;是点分治过程中选择的分治中心点构成的树形结构;常用于解决与树原形态无关的带修改问题,也就是上面说的那种动态问题。
得到点分树,就可以通过点分治每次找重心的方式来对原树进行重构:将每次找到的重心与上一层的重心缔结父子关系,这样就可以形成一棵 logn 层的树。
1 |
|
这里有一个技巧:每次用递归上一层的总大小 tot 减去上一层的点的重儿子大小,得到的就是这一层的总大小。这样求重心就只需一次 DFS 了;
实现修改
在查询和修改的时候,我们在点分树上暴力跳父亲修改;由于点分树的深度最多是 O(nlogn) 的,这样做复杂度能得到保证。
在动态点分治的过程中,需要一个结点到其点分树上的祖先的距离等其他信息,由于一个点最多有 logn 个祖先,我们可以在计算点分树时额外计算深度或使用 LCA,预处理出这些距离或实现实时查询;因为一个结点到其点分树上的祖先的距离不一定递增,所以不能累加;除此之外,一个结点在其点分树上的祖先结点的信息中可能会被重复计算:此时我们需要消去重复部分的影响。一般的方法是对于一个连通块用两种方式记录:一个是其到分治中心的距离信息,另一个是其到点分树上分治中心父亲的距离信息。
例题
「ZJOI2007」捉迷藏 给定一棵有 n 个结点的树,初始时所有结点都是黑色的。你需要实现以下两种操作:
- 反转一个结点的颜色(白变黑,黑变白);
- 询问树上两个最远的黑点的距离;
数据范围:n ≤ 1e5,m ≤ 5e5
求出点分树,对于每个结点 维护两个可删堆[1]
分治树深度 logn,堆操作时间复杂度是l O(logn),总时间复杂度是 O(nlog²n);
例题的代码(来自 OI Wiki):
1 |
|
还有一个经典的例题:洛谷 P6329 【模板】点分树 | 震波
边分治
边分治和点分治一样属于树分治的一部分,和点分治也有足够的相似之处:选取一条边,将树尽量均匀地分为两个部分;但是相比于点分治,边分治对于与度数相关的问题有着很大的优势,同时边分治也是解决树上最优化问题的一种重要的算法。
但是这样存在一个问题:当一棵树是有多个大小相近的子树的时候比如菊花图,复杂度就会变差:
在这种情况下,无论怎么划分复杂度都会变成 O(n²);考虑到如果根节点的孩子减少的话,就可以缓解这种压力,最优的树型就是二叉树;所以可以重构这颗树,利用插入虚点的方法将一颗多叉树转化为二叉树从而保证分治的复杂度 O(nlogn) ;但是因为插入了 O(n) 个虚点,最多会引入两倍的常数。
这种重构树的建树方法和线段树的建树很像;新插入的虚点维护的数据可以根据题目要求来确定——比如当统计路径长度时,将原边边权赋为 1, 将新建的边边权赋为 0 即可;几乎所有的点分治的题边分都能做,但是常数上有差距。
至于分治的过程,和点分治依然是相似的:每次分治时找到一条分治中心边,使这条边两端的两个联通块中较大的一个尽量小;在以分治中心边为界限分开而得到的两个连通块中,统计路径经过分治中心边的答案;然后将分治中心边断开,递归分治中心边两端的两个联通块。
找中心边
和点分治非常相似:通过统计一条边的两侧的子树的大小,找到较大的一侧子树大小最小的边;在递归的时候应当将当前的中心边打上删除标记,以避免统计错误。
1 | inline void findct(int u, int fa) { |
代码中的 sum
是调用该函数时设置的当前连通块的大小。
树重构
重构树的递归过程是:先重构子树,再将重构完成的子树们二分连接到虚点上,效果大概如下图所示:
下面是一种参考的树重构的实现方法:
1 | inline void rebuild(int u, int fa) { |
但是上面的实现并不是边分治中重构树的唯一选择;大可采用其他的实现来完成这项工作。
例题
SP2666 QTREE4 - Query on a tree IV 给定一棵 n 个点的带边权的树,点从 1 到 n 编号;每个点可能有两种颜色:黑或白;一开始所有的点都是白色的;定义 dist(a, b) 为 a-b 路径上的权值之和;可以进行操作:
C a
:反转 a 点的颜色;A
:查询 dist(a, b) 的最大值,a b 都是白色,且可相同;查询时若树上无白色点,输出
They have disappeared.
;N, Q ≤ 1e5,边权c ∈ [-1e3, 1e3]。
当然,这个题目也可以使用点分治来做;但是这里使用边分治的方法来解决:在中心边位置维护两个堆,分别表示左右子树的各个白点距离;单独维护每个分治结构的答案,就可以在一个统计最大值的时候顺带把子分治结构的最大值也计算进来,这样询问的时候只需要询问根分支结构的答案即可;
在加点的的过程中,记录下这个点会影响到的堆的数据:变白要把这个点放进堆里,变黑只需要打标记;在每一次更新答案的时候,从堆顶把黑点全部删除;如果用数组或者 vector 来存的话,这个更新要根据倒序,因为倒序才是分治结构从底向根的顺序。
参考代码(Code by KSkun, 2018/3):
1 |
|
不只是这个题目,很多的点分治的问题都可以使用边分治解决,这里就不贴其他题了。
推荐题目
除了文章中出现的讲解了的和没讲解的例题之外,还可以做一做下面这些题目:
推荐的题目参考了网络上的资料。
参考资料
因为这篇文章参考了大量其他巨佬神犇的博客和资料,所以基本上没有什么原创性可言;借物表如下:
- https://oi-wiki.org/graph/tree-divide/
- https://www.cnblogs.com/bztMinamoto/p/9489473.html
- https://oi-wiki.org/graph/dynamic-tree-divide/
- https://www.cnblogs.com/HocRiser/p/8505627.html
- https://www.cnblogs.com/Khada-Jhin/p/10154994.html
- https://ksmeow.moe/edge_based_divide_and_conquer/
都是因为 DDL,理解万岁,理解万岁(
比起朴素堆只能删除堆顶元素,还可以删除其他元素;一般是使用一个临时堆存储暂时不在堆顶但是需要删除的元素,当这些元素到达堆顶的时候再一并弹出。 ↩︎