【笔记】图论-树链剖分
树链剖分,听起来确实是一种很高级的算法,但其实它并没有想象中的那么难以理解,事实上,个人觉得,树剖其实根本没有什么太大的思维难度(老实说我觉得怕不是背包都比它要难理解),只是码量大亿点,但只要熟练掌握了,打代码其实也并没有那么难
所谓树链剖分,是用来解决一类树上问题的,它将一棵树剖成很多条链,把树上问题转化成序列问题,然后用其它一些数据结构,比如线段树来维护树上路径的信息
举个例子,比如给定一棵树,每个点有自己的权值,要求查询某两点之间的路径上的所有点的点权和,看起来很简单是不是?用倍增在跳的时候顺便统计就行了,那么如果再要求支持改变某个点的点权呢,或是给某两点之间的路径上的所有点的点权全部加上一个数呢?倍增很明显就不行了吧,这时候就要用到树剖了
思路
树链剖分一般指的是重链剖分,在讲解它的思路之前,我们先要明确几个概念
- 重儿子:一个点的所有儿子中子树节点数量最多的儿子,如果有多个,那就随便选一个
- 轻儿子:一个点所有儿子中除重儿子以外的其他儿子,也就是说,对于一个点,重儿子是唯一的,但轻儿子不唯一
- 重边:一个点到它的重儿子之间的边
- 轻边:一个点到它的轻儿子之间的边
- 重链:一大堆重边组成的一条链
这么说起来可能有点抽象,还是拿张图最容易理解
在这张图中,蓝色节点表示轻儿子,橙色节点表示重儿子,相应的,蓝色边表示轻边,橙色边表示重边,由绿框框起来的就是重链,特别的,单独一个点也可以叫做重链
这个过程完了之后,整棵树就会被完全剖分成一条一条的重链
接下来是重点
对于这一条一条的重链,很明显我们还是不能直接用线段树去维护,因为每条链中的节点编号并不是连续的,所以,我们还要引入一个东西——DFS 序
这个东西就是树剖把树上问题转化成序列问题的关键,所谓 DFS 序呢,就是在对这棵树进行 DFS 的时候,标记每个点是第几个到达的,其实也就是强连通分量 Tarjan 算法里的 dfn 数组,但树剖不太一样,因为我们需要让一条重链上所有点的 DFS 序连续,这样才好让这条链变成一个区间,所以,我们在对这棵树进行 DFS 的时候,优先遍历重儿子,这样就可以保证一条重链上的点的 DFS 序连续,因为是先把这条重链一拉到底之后再遍历其他的重链
对于上面那棵树,它的 DFS 序如下(拿蓝框框起来的就是)
我们可以用每个节点的 DFS 序建一棵线段树,这样,一条重链就是一个连续的区间了,也便于维护
代码实现
首先我们先来看一些变量
- dep[]:记录每个点的深度
- son[]:记录每个点的重儿子
- size[]:记录以当前点为根的子树的节点个数
- id[]:记录每个点的 DFS 序
- rk[]:记录每个 DFS 序对应的点
- top[]:记录当前点所在重链的顶部(下称链头),也就是深度最浅的点
其实树链剖分一共只需要两次 DFS 就可以解决了,第一次求出每个点的深度和重儿子(dep[],son[],size[]),第二次记录每个点的 DFS 序,相当于是连重边(id[],rk[],top[]),代码很短,也很好理解
1 | void dfs1(int u,int father)//第一次 DFS |
在第二个 DFS 中,之所以下面遍历轻儿子的时候把轻儿子所在的重链顶部设成是轻儿子,是因为当前节点和轻儿子并不在一条重链里,自然也就无法充当它所在的重链的顶部
代码是真的很短了,但这其实只是树剖本身的实现,不要忘了,它是用来解决一类问题的,剖的过程确实很简单,但要维护却比较恼火,上面提到过,可以用线段树来维护重链上的信息,这玩意本来码量就很大,再加上有时候还会结合 LCA,就更令人难受了
接下来,我会讲一下如何用树剖求 LCA,当然,一般来说用树剖求 LCA,就一定会有路径查询和路径修改,我会顺带着把这两个也讲一下的
树剖+LCA
如果是做过树剖的题的人,应该知道在这种题中一般会有这种要求,即求这棵树上从到的最短路径上所有点权之和,以及将这棵树上从到的最短路径上所有点的点权加上一个数,而这个最短路径,很明显就是要求 LCA
树剖求 LCA,思路其实和倍增差不多,都是往上跳,直到跳到同一个点结束,但树剖与倍增又有一点不同,倍增是往上跳个祖先,而树剖则是直接跳到链头的父亲,因为如果和在同一条重链上,那么可以肯定他们中有一个是对方的祖先,比较一下深度就行了,如果不在同一条重链上,那么我们就先让他们跳到同一条重链上,再按照前面的方法执行
首先,我们比较和链头的深度,避免跳过头,接下来,我们把链头深度较深的那一个(假设是)跳到他链头的父亲,因为如果只跳到链头,那么很明显这个点所在的重链并没有变,只有跳到链头的父亲才是到了另外一条重链,如此循环,直到和的链头是同一个点,也就是它们处于同一条重链上,这时深度浅的那一个就是
老规矩,用上面那张图手动模拟一下
假设我们求 14 和 16 号点的 LCA,过程如下:
- 首先比较各自链头的深度,很明显是 16 号点的链头深度更深,因此把 16 号点跳到链头的父亲 11 号点
- 再次比较 14 号点和 11 号点的链头深度,这次是 14 号点,跳到链头的父亲,为 7 号点
- 发现 7 号点和 11 号点的链头相同,也就是它们处于同一条重链,退出循环
- 因为 7 号点深度比 11 号点的深度更浅,所以
当然,一般来说如果只是单纯求 LCA 还用不到树剖上场,如果必须要用树剖,那就肯定是加了路径修改和路径查询,这两个也是导致树剖码量大的一个很重要的原因。
其实只要掌握了树剖求 LCA 的方法,修改和查询也不是什么难事,上面说过,我们是用节点的 DFS 序建的线段树,因此每条重链都是一个连续的区间,而重链上的每个点到链头很明显也是连续的,结合刚刚求 LCA 的跳法,我们只需要在(或)跳到链头的父亲之前先修改(或查询)到链头的点,并在最后出于同一重链上时把到之间的点进行修改(或查询)就行了,具体的可以看一下代码
1 | int lca_query(int u,int v)//查询 |
(感觉其实不算很难啊)
一般来说,树剖的题基本上就是路径修改,路径查询,单点修改,单点查询,修改子树,查询子树这几种,后面四种都可以直接用线段树来完成,因为一个点的子树的 DFS 序也是一个完整的区间,比如上图中 7 的子树的 DFS 序就是从 3 到 8,由于之前记录了每棵子树的节点数量,所以这里只需要修改从 id[i] 到 id[i]+size[i]-1 这个区间就行了
另外,树剖求 LCA 的时间复杂度是,且常数较小,不容易被卡掉,预处理也是级别的,所以还算比较优秀了
参考代码
这道题要求支持路径修改,路径查询,子树修改,子树查询四种操作,上面都已经讲过了,就直接贴代码了
感受树剖的码量吧!
1 |
|
其实不算很难懂啦,关键就是码量太大了。
相关题目
- P3038 [USACO11DEC]Grass Planting G(树剖维护边权,其实可以把边权转化成点权)
- P4092 [HEOI2016/TJOI2016] 树(跟上面说的不太一样,但可以用树剖解决)
- P4315 月下“毛景树”(差点把我心态搞崩,码量不是一般的大)
- P2146 [NOI2015] 软件包管理器(树剖经典题)
- P2486 [SDOI2011] 染色(有些思维难度,加油!)
- P3178 [HAOI2015] 树上操作(板子题)
其实真正开始做树剖的题就会发现,其实很多代码是通用的,完全可以把上一道题的代码复制过来稍微改一下就行了,但我个人不推荐这样做,毕竟要想熟练掌握代码,最好的方法就是多打几遍嘛,而且这样还可以练手速,做题做多了之后就可以越打越快(我有一次曾经一天内做了 8 道树剖题,手都快打废了)