Link-Cut Tree(Insider Preview)
2018-04-02 19:42
204 查看
以下内容仅供指教,请勿当真。
如有侵权,请与我联系(我相信是没有的)。
Link-Cut Tree
总览
1. 动态树问题
2. 树链剖分
3. LCT
4. Splay
入门
1. LCT 的形态
2. 概念与操作
①偏爱子结点/边/路径
②访问操作:access
③置根操作:makeroot
④link 操作
⑤cut 操作
3. e.g. Luogu 2147 [SDOI 2008] 洞穴勘测
findroot 操作
4. 实现5
①Splay 结点
②parent 指针
③rotate 操作和 Splay 操作
④access 操作
⑤makeroot 和 findroot 操作
⑥link 和 cut 操作
⑦初始化
5. 时间复杂度
进阶
e.g. Luogu 3203 弹飞绵羊
①维护 size
②获取结点在原树中的深度
e.g. Luogu 3690 [模板] Link-Cut Tree
①解决 cut 的合法性问题
②维护异或和
e.g. Luogu 2387 [NOI 2014] 魔法森林
①边权转点权
②维护最小生成树
开局一个 Markdown,内容全靠编。——Orange
参考资料
不保证能看懂,但保证讲清楚所有细节。
动态树问题是一类要求维护森林的连通性的题的总称。这类问题要求:
维护某个点到根的某些数据。
对树进行切分。
对树进行合并。
操作子树。
……
解决这一问题的的子问题(不包括操作子树)的基础数据结构就是 Link-Cut Tree(LCT)。
2. 树链剖分
静态的树链剖分将树分成了若干条链1。最简单有效的剖分方式为重链剖分:对于某个结点,将连向最大子树的边称为重边,其余边称为轻边,每一条链都由重边组成(称为重链),而每两条链之间由轻边连接。这样可以保证任意一个结点到根结点的路径上经过的轻边数量为 O(logn)O(logn),因此我们可以用数据结构维护每条重链,每次至多修改 O(logn)O(logn) 次数据结构。
3. LCT
LCT 的核心思想也是将树分成若干条链,对于每一条链,我们都用 Splay 来进行维护。由于 LCT 解决的是动态树问题,所以链并不是一成不变的,而是需要不断地进行拼接,因此 Splay 是我们的不二之选。
4. Splay
复习一下,为什么使用 Splay:虽然说 Splay 是一棵二叉搜索树,但是它不一定非要用作一棵二叉搜索树,而是可以看作一个支持均摊 O(logn)O(logn) 进行分裂和合并的数组。
我们有一棵树:
![](pic\LCT 1.png)
看上去它是一棵以 11 为根的树,但是没有关系,在 LCT 中,我们不管原树有根还是无根,我们只需要知道原树的根的编号就可以了,换句话说,原树的根是哪个和 LCT 要做的事情无关。
那我们怎么维护原树呢?刚刚说了,我们将原树分成了一条条链,我们只需要维护每条链和链之间的关系就好了。对于每一条重链,我们用一棵 Splay 来维护;将 Splay 看作一个数组,我们保存的结点在原树中的深度和在 Splay 中的下标的增减性是一致的:
![](pic\LCT 2.png)
拆分成的三条链
![](pic\LCT 3.png)
三条链对应的 Splay 可能的形态
例如,上图中,结点 11,22,33 构成一棵 Splay,中序遍历后的深度是递增的。注意,Splay 的形态可能和链在原树中的形态不一样(可能不再是链)。
那么 LCT 的链之间用什么维护呢?还记得树链剖分中的 toptop 吗?我们称一条链中深度最小的点为这条链的 toptop。在 LCT 中,对于每棵 Splay,我们从 Splay 的根结点(没错,Splay 的根结点)向与原树中这棵 Splay 对应重链的 toptop 相邻2的结点连一条有向边(这个结点不在这条链上)。
![](pic\LCT 4.png)
LCT 的实际形态。上图中,双向边代表 Splay 的边,单向边代表连接 Splay 的边。
不难发现,如果将每一个 Splay 缩成一个点,那么各点之间的关系仍然是树形关系。我们称这棵 Splay 组成的树的根结点(注意它是棵 Splay)的中序遍历的第一个点为对应连通块在 LCT 中的根结点 rootroot。再次强调,这个“LCT 中的根结点”和原树中的根结点没有一点关系,哪怕原树是棵无根树,还是有 LCT 中的根结点一说。当然,如果我们维护的是一个森林,那么森林中的每一个连通块在 LCT 中都会存在一个对应的根结点。
2. 概念与操作
①偏爱子结点/边/路径
根据上面的描述,LCT 实际上也是对原树进行了树链剖分。对于一个结点,我们称原树中与它在同一条链上的它的儿子(如果存在)为偏爱子结点(preferred child);称连接一个结点和它的偏爱子结点的边为偏爱边(preferred edge);称一条链为偏爱路径(preferred path)。
以上概念和轻重链剖分中的重儿子、重边和重链完全类似,且在 LCT 的形态中说得很清楚了,所以我们立马可以得到以下结论:
每个点在且仅在一条偏爱路径上。
所有的偏爱路径包含了这棵树上所有的点。
(上面不是废话吗,LCT 的形态中已经说得很清楚了)
②访问操作:access
访问(access)操作是 LCT 的核心操作,也是体现 LCT “动态”的操作。
假设现在的 LCT 中只有一个连通块,且根是 rootroot,我们定义 access(x)access(x) 为:令 xx 到 rootroot 的路径成为一条偏爱路径,且令 xx 的子结点和 rootroot 的其它子结点都不在这条偏爱路径上。
![](pic\LCT 2.png)
假设的一开始的偏爱路径(原树为前面的那棵树,忽略了非偏爱边)
![](pic\LCT 5.png)
access(6)access(6) 后的偏爱路径(忽略了非偏爱边)
然而实际上,我们是如何实现 access 操作的呢?
假设我们正在进行 access(x)access(x),我们通过旋转 xx 让 xx 成为 Splay 的根结点,这样,xx 的右子树便是它的重儿子了,将它断开,然后让它向 xx 连一条非偏爱边。
根据之前 LCT 的形态,我们是可以知道 xx 在原树中的父结点的(因为现在它是它所在的 Splay 的根,保存了它在原树中相邻的结点)。它的父结点对应的偏爱路径无非就样:
![](pic\LCT 6.png)
除了 xx 到 33 的边不是偏爱边,其它边都是偏爱边
我们找到 33 结点,对它进行 Splay 操作3,那么 33 的右子树就是 33 之前的偏爱儿子了(44 结点)。我们断开它,从 44 向 33 连一条非偏爱边,然后把 xx 接到 33 的右子树上,就得到了新的 Splay。不断重复这个过程,直到对 xx 进行 Splay 操作后发现不存在一条从 xx 出发的非偏爱边。
需要注意的是,在 access 途中要随时维护信息,不过现在还没有说到代码,所以就暂时不用管啦。另外,access(x)access(x) 之后 xx 并不是对应 Splay 的根,如果需要,可以对 xx 进行 Splay 操作。
③置根操作:makeroot
前面的注释和 access 的介绍中也提到过,LCT 中的每一个连通块都有一个根结点,且需要支持换根操作。假设目前 LCT 的根为 rootroot(再次强调是 LCT 的根,与原树的根无关,原树甚至没有根),我们想让 xx 成为新的根,那么我们首先 access(x)access(x)。
在此操作后,我们发现只需要将 xx 所在的偏爱路径进行翻转就可以了。因为这样做之后, xx 所在偏爱路径各个结点的深度正好调换了顺序,而其它偏爱路径各结点的相对深度并不发生改变,因此我们利用 Splay 的翻转操作,将 xx 所在的整个偏爱路径翻转即可。
![](pic\LCT 7.png)
翻转 xx 所在偏爱路径后,其它偏爱路径的相对深度不改变
为了方便,我们直接对 xx 进行 Splay 操作,则 xx 成为了当前 Splay 的根,我们对它打上翻转标记4即可。
④link 操作
顾名思义,link 操作将两个不在同一连通块的点连接起来。
假设这两个点为 xx 和 yy,我们首先调用 makeroot(x)makeroot(x),然后我们实际上令 xx 成为 yy 的子结点就好了。我们从 xx 向 yy 连一条非偏爱边即可。
⑤cut 操作
顾名思义,cut 操作将两个相邻结点断开。
假设这两个点为 xx 和 yy,我们首先调用 makeroot(x)makeroot(x),然后我们再调用 access(y)access(y),然后实际上我们只需要把 xx 到 yy 的偏爱边断开就好了。我们对 yy 进行 Splay 操作,则 xx 一定为 yy 的左儿子。将这条边断开,维护 yy 的信息即可。
3. e.g. Luogu 2147 [SDOI 2008] 洞穴勘测
我们通过一道例题来引入具体实现和基本应用。
题目大意:有一个结点数为 nn 的森林,有 mm 次操作,操作分为三种:连接两个结点,删去两条边,查询两个点的连通性。保证操作合法,任意一次操作后森林还是森林。
这道题要我们做的是动态维护森林的连通性,支持加边删边,这正是 LCT 擅长的。
findroot 操作
findroot 操作允许我们找到某个结点所在连通块在 LCT 中的根结点。思路也很简单:直接 access(x)access(x),然后对 xx 进行 Splay 操作,然后沿着 Splay 结点的左儿子走,找到 Splay 中序遍历的第一个结点就好了。
那么这道题的做法就是:对于 Query 操作,我们调用 findroot,检查两点在 LCT 中的根结点是否一样(一样就说明在同一连通块,否则就说明在不同连通块);对于 Connect 和 Destroy 操作,我们使用 link 和 cut 就好了。
4. 实现5
至此,我们已经完全可以口胡通过上面的例题了。但是怎么写???
①Splay 结点
我们将 LCT 中的所有结点都定义为 Splay 结点:
也就是说,LCT 中的 Splay 结点和普通的 Splay 结点没有太大差异,只是多了一个 parent 指针。但另外,可以发现 Splay 结点少了两个成员函数6:
②parent 指针
Splay 结点的 parent 指针有什么用呢?
事实上,我们在维护 LCT 时并不真正地连接非偏爱边,而是直接将 parent 指针当作保存非偏爱边的容器。当一个点不是 Splay 的根结点时,parent 指针指向它在 Splay 中的父结点;当一个点是 Splay 的根结点时,parent 指针会被当作非偏爱边。如何判断 parent 指针是非偏爱边还是真正指向 Splay 中的父结点的指针呢?只需要判断这个点是不是 Splay 的根结点就好了:
③rotate 操作和 Splay 操作
由于我们并不知道要操作的结点在 Splay 的中序遍历中到底是第几个元素,所以我们不能像以前的 Splay 那样从上往下旋转,我们只能从当前结点不断往上旋转,直到当前结点为 Splay 的根。
首先考虑如何判断当前结点是它父结点的左儿子还是右儿子:
然后我们考虑旋转。由于是自底向上的,所以我们定义 rotate(x)rotate(x) 为将 xx 旋转至 xx 的父结点的位置:
在此过程中,除了过去需要维护的东西,我们还需要维护一个结点的父结点。父结点发生改变的有 33 个结点:被旋转的结点,它的某个子结点和它的父结点。
同理,我们定义 Splay(x)Splay(x) 表示将 xx 旋转至它所在 Splay 的根结点:
如果需要将 Splay 对应序列反转(reverse),只需要调用:
不过需要保证
需要注意的是,上面的参数全部都是指针类型的(
也就是说,结点之间的关系(
④access 操作
在知道了如何进行 Splay 相关的操作后,问题也就变得简单了:
我们保存上一条链的指针
⑤makeroot 和 findroot 操作
终于,我们写完了最底层的代码,所以下面的代码就不用解释了,参考前面的描述。
需要注意要在最后对根结点进行 Splay 操作,以保证时间复杂度。
⑥link 和 cut 操作
无需解释,参考前面的描述。
⑦初始化
LCT 的初始形态是怎样的呢?如果一开始树没有边,我们让每个结点都单独成为一个连通块即可。为了安全,我们为
完整代码见代码仓库。
5. 时间复杂度
可以证明,使用 Splay 维护 LCT 的均摊时间复杂度是 O(nlogn)O(nlogn) 的。很明显的是,LCT 常数巨大。
具体的证明高深莫测,限于水平和偏重点,我就不证了,建议你也不要入坑去证。
分块经典题目,同时也是 LCT 经典题目。
对于每一个位置,我们将它看作一个结点,另外我们虚拟一个 n+1n+1 号结点,表示一条就跳出去的情况。由于每一个点只能跳到唯一的位置,因此我们可以建成一棵树。这样,询问就等价于问每一个点到根结点的距离。
如何使用 LCT 来搞呢?如果我们额外维护子树大小,我们就可以知道有多少个结点的中序遍历在当前结点的前面,也就可以做了。
①维护 size
这道题需要维护子树大小,虽然子树大小不是 LCT 的必需品,但是它是做题的必需品。下面是新的结点定义:
(把 reverse 写进
最主要需要维护 size 的地方是 rotate 操作:
其它地方,仅当需要连接或者删去 Splay 中的边时,才需要维护 size。**由于 link 操作连接的是非偏爱边,所以不需要维护 size;**access 和 cut 操作需要断开或者连上 Splay 中的边,所以也需要维护 size:
②获取结点在原树中的深度
首先需要对原树的根结点进行 makeroot 操作,然后对需要计算的结点进行 access 和 splay 操作,这样它的左子树大小就是深度比自己浅的结点个数,正是本题需要求的东西。
e.g. Luogu 3690 [模板] Link-Cut Tree
题目大意:给你一个森林,要你维护森林连通性,不保证删边或者加边操作合法。森林中的点有点权,有查询路径上点权异或和的询问,还要求支持单点修改点权。
首先我们解决 link 操作不合法的问题,很简单,只需要用 findroot 判断是否在同一连通块就好了。
①解决 cut 的合法性问题
cut 操作合法的先决条件是待操作结点要相邻。我们首先 makeroot(x)makeroot(x),然后检查 findroot(y)findroot(y) 是否为 xx,如果不是,显然它们不在同一连通块。否则我们 Splay(y)Splay(y)(注意在 findroot 中已经 access(y)access(y) 了),然后检查 xx 和 yy 的中序遍历之间是否有别的结点,只需要看 yy 的左子树大小是否为 11 即可。如果不需要维护大小呢?那就检查两个地方:一看 yy 的左儿子是不是 xx,二看 xx 是不是没有右儿子。检查之后我们才能 cut。
注意别写错了。
②维护异或和
事实上,我们只需要维护 Splay 中对应子树的异或和就行了,而且只需要沿用之前那道题的做法,改一下 maintain 函数就好了。
就是说,调用 maintain 函数的时机是一样的,只是 maintain 函数的内容变了。这启示我们,LCT 可以很方便地维护点上的信息。
相比之下,修改点权和查询点权异或和就很简单了,随便写写都能过。记住在 Splay 发生改变时进行 maintain 操作。
e.g. Luogu 2387 [NOI 2014] 魔法森林
题目大意:给定一张 nn 个点 mm 条边的图,每条边有两个边权 aa 和 bb。找一条 11 到 nn 的路径使得 max{a}+max{b}max{a}+max{b} 最小,如果不存在输出 −1−1。
很容易往最小生成树上面去想,然后考虑一个套路做法。我们枚举 aa 的最大权值,然后看对于每个 aa 答案是多少。具体地说,我们可以把边按照 aa 从小到大排序,依次加入图中,每次再看看 11 到 nn 的最优答案。
如何看呢?我们直接令 aa 为当前的最大边权,然后找一条从 11 到 nn 的路径使得路径上最大的 bb 最小就好了。利用非法解不会最优的思想,我们可以用 SPFA 在 O(nm2)O(nm2) 的时间复杂度内解决这个问题。然后恭喜 TLE。(我不会打大优化,所以就不讨论如何优化这个算法了)
我们考虑使用 LCT 来维护这个东西。
①边权转点权
一个不可否认的事实是,LCT 真的没有办法维护边上的信息,所以我们要想个办法把边上的信息换到点上。
由于树有 nn 个点,n−1n−1 条边,所以我们自然会想到把边的信息放到儿子结点上,但是这在 LCT 中是行不通的,因为 LCT 本身可以换根,一条边连接的两个点的父子关系会发生变化。
正确的做法是为每一条边都虚拟一个结点,点权为原边权,而原点权为 00。加边时,我们从原来的两个端点分别向边对应的端点连边即可。
②维护最小生成树
事实上,原问题已经被转换成了一个动态维护最小生成树的问题:只不过原始的动态维护最小生成树需要首先求一次 MST,但这道题是从零开始维护的。
如何维护 MST 呢?稍微有点经验的人都知道,加入一条边时,如果两点不在同一连通块,那么直接连上就行;如果两点在同一连通块,那么一定会形成一个环。我们删去环上的最大边即可。
如何使用 LCT 来维护呢?对于每个 Splay 结点,我们保存它所在子树的最大边权以及相应的编号即可。
maintain 操作的执行时机和之前完全一样。剩下查询信息的代码就很简单了,善用 makeroot,access 和 splay 即可。详见代码仓库。
最小生成树加边:
到目前为止,你可以认为相邻的结点就是 toptop 的父结点,但实际上 LCT 需要进行换根操作,原树是否有根无关紧要,所以说成相邻才是正确的。 ↩
如无特殊说明,对某个点进行 Splay 操作表示将它旋转至它所在的 Splay 的根结点。 ↩
不知道什么东西?你可以出门右转先学一下 Splay,不过不保证你学的代码能用在接下来 LCT 的实现上。 ↩
LCT 的实现有很多,选择最适合自己的就好。如果你不会具体实现,又觉得我太弱或者代码太丑,请跳过这一部分。我的代码在访问元素时将以指针为主。 ↩
这只是我的习惯,如果你并不习惯在 Splay 结点中直接写成员函数也没关系,我只是想告诉你 LCT 的 Splay 结点在不用维护别的数据的情况下不需要引入 size。 ↩
如有侵权,请与我联系(我相信是没有的)。
Link-Cut Tree
总览
1. 动态树问题
2. 树链剖分
3. LCT
4. Splay
入门
1. LCT 的形态
2. 概念与操作
①偏爱子结点/边/路径
②访问操作:access
③置根操作:makeroot
④link 操作
⑤cut 操作
3. e.g. Luogu 2147 [SDOI 2008] 洞穴勘测
findroot 操作
4. 实现5
①Splay 结点
②parent 指针
③rotate 操作和 Splay 操作
④access 操作
⑤makeroot 和 findroot 操作
⑥link 和 cut 操作
⑦初始化
5. 时间复杂度
进阶
e.g. Luogu 3203 弹飞绵羊
①维护 size
②获取结点在原树中的深度
e.g. Luogu 3690 [模板] Link-Cut Tree
①解决 cut 的合法性问题
②维护异或和
e.g. Luogu 2387 [NOI 2014] 魔法森林
①边权转点权
②维护最小生成树
开局一个 Markdown,内容全靠编。——Orange
Link-Cut Tree
参考资料参考资料
不保证能看懂,但保证讲清楚所有细节。
总览
1. 动态树问题动态树问题是一类要求维护森林的连通性的题的总称。这类问题要求:
维护某个点到根的某些数据。
对树进行切分。
对树进行合并。
操作子树。
……
解决这一问题的的子问题(不包括操作子树)的基础数据结构就是 Link-Cut Tree(LCT)。
2. 树链剖分
静态的树链剖分将树分成了若干条链1。最简单有效的剖分方式为重链剖分:对于某个结点,将连向最大子树的边称为重边,其余边称为轻边,每一条链都由重边组成(称为重链),而每两条链之间由轻边连接。这样可以保证任意一个结点到根结点的路径上经过的轻边数量为 O(logn)O(logn),因此我们可以用数据结构维护每条重链,每次至多修改 O(logn)O(logn) 次数据结构。
3. LCT
LCT 的核心思想也是将树分成若干条链,对于每一条链,我们都用 Splay 来进行维护。由于 LCT 解决的是动态树问题,所以链并不是一成不变的,而是需要不断地进行拼接,因此 Splay 是我们的不二之选。
4. Splay
复习一下,为什么使用 Splay:虽然说 Splay 是一棵二叉搜索树,但是它不一定非要用作一棵二叉搜索树,而是可以看作一个支持均摊 O(logn)O(logn) 进行分裂和合并的数组。
入门
1. LCT 的形态我们有一棵树:
![](pic\LCT 1.png)
看上去它是一棵以 11 为根的树,但是没有关系,在 LCT 中,我们不管原树有根还是无根,我们只需要知道原树的根的编号就可以了,换句话说,原树的根是哪个和 LCT 要做的事情无关。
那我们怎么维护原树呢?刚刚说了,我们将原树分成了一条条链,我们只需要维护每条链和链之间的关系就好了。对于每一条重链,我们用一棵 Splay 来维护;将 Splay 看作一个数组,我们保存的结点在原树中的深度和在 Splay 中的下标的增减性是一致的:
![](pic\LCT 2.png)
拆分成的三条链
![](pic\LCT 3.png)
三条链对应的 Splay 可能的形态
例如,上图中,结点 11,22,33 构成一棵 Splay,中序遍历后的深度是递增的。注意,Splay 的形态可能和链在原树中的形态不一样(可能不再是链)。
那么 LCT 的链之间用什么维护呢?还记得树链剖分中的 toptop 吗?我们称一条链中深度最小的点为这条链的 toptop。在 LCT 中,对于每棵 Splay,我们从 Splay 的根结点(没错,Splay 的根结点)向与原树中这棵 Splay 对应重链的 toptop 相邻2的结点连一条有向边(这个结点不在这条链上)。
![](pic\LCT 4.png)
LCT 的实际形态。上图中,双向边代表 Splay 的边,单向边代表连接 Splay 的边。
不难发现,如果将每一个 Splay 缩成一个点,那么各点之间的关系仍然是树形关系。我们称这棵 Splay 组成的树的根结点(注意它是棵 Splay)的中序遍历的第一个点为对应连通块在 LCT 中的根结点 rootroot。再次强调,这个“LCT 中的根结点”和原树中的根结点没有一点关系,哪怕原树是棵无根树,还是有 LCT 中的根结点一说。当然,如果我们维护的是一个森林,那么森林中的每一个连通块在 LCT 中都会存在一个对应的根结点。
2. 概念与操作
①偏爱子结点/边/路径
根据上面的描述,LCT 实际上也是对原树进行了树链剖分。对于一个结点,我们称原树中与它在同一条链上的它的儿子(如果存在)为偏爱子结点(preferred child);称连接一个结点和它的偏爱子结点的边为偏爱边(preferred edge);称一条链为偏爱路径(preferred path)。
以上概念和轻重链剖分中的重儿子、重边和重链完全类似,且在 LCT 的形态中说得很清楚了,所以我们立马可以得到以下结论:
每个点在且仅在一条偏爱路径上。
所有的偏爱路径包含了这棵树上所有的点。
(上面不是废话吗,LCT 的形态中已经说得很清楚了)
②访问操作:access
访问(access)操作是 LCT 的核心操作,也是体现 LCT “动态”的操作。
假设现在的 LCT 中只有一个连通块,且根是 rootroot,我们定义 access(x)access(x) 为:令 xx 到 rootroot 的路径成为一条偏爱路径,且令 xx 的子结点和 rootroot 的其它子结点都不在这条偏爱路径上。
![](pic\LCT 2.png)
假设的一开始的偏爱路径(原树为前面的那棵树,忽略了非偏爱边)
![](pic\LCT 5.png)
access(6)access(6) 后的偏爱路径(忽略了非偏爱边)
然而实际上,我们是如何实现 access 操作的呢?
假设我们正在进行 access(x)access(x),我们通过旋转 xx 让 xx 成为 Splay 的根结点,这样,xx 的右子树便是它的重儿子了,将它断开,然后让它向 xx 连一条非偏爱边。
根据之前 LCT 的形态,我们是可以知道 xx 在原树中的父结点的(因为现在它是它所在的 Splay 的根,保存了它在原树中相邻的结点)。它的父结点对应的偏爱路径无非就样:
![](pic\LCT 6.png)
除了 xx 到 33 的边不是偏爱边,其它边都是偏爱边
我们找到 33 结点,对它进行 Splay 操作3,那么 33 的右子树就是 33 之前的偏爱儿子了(44 结点)。我们断开它,从 44 向 33 连一条非偏爱边,然后把 xx 接到 33 的右子树上,就得到了新的 Splay。不断重复这个过程,直到对 xx 进行 Splay 操作后发现不存在一条从 xx 出发的非偏爱边。
需要注意的是,在 access 途中要随时维护信息,不过现在还没有说到代码,所以就暂时不用管啦。另外,access(x)access(x) 之后 xx 并不是对应 Splay 的根,如果需要,可以对 xx 进行 Splay 操作。
③置根操作:makeroot
前面的注释和 access 的介绍中也提到过,LCT 中的每一个连通块都有一个根结点,且需要支持换根操作。假设目前 LCT 的根为 rootroot(再次强调是 LCT 的根,与原树的根无关,原树甚至没有根),我们想让 xx 成为新的根,那么我们首先 access(x)access(x)。
在此操作后,我们发现只需要将 xx 所在的偏爱路径进行翻转就可以了。因为这样做之后, xx 所在偏爱路径各个结点的深度正好调换了顺序,而其它偏爱路径各结点的相对深度并不发生改变,因此我们利用 Splay 的翻转操作,将 xx 所在的整个偏爱路径翻转即可。
![](pic\LCT 7.png)
翻转 xx 所在偏爱路径后,其它偏爱路径的相对深度不改变
为了方便,我们直接对 xx 进行 Splay 操作,则 xx 成为了当前 Splay 的根,我们对它打上翻转标记4即可。
④link 操作
顾名思义,link 操作将两个不在同一连通块的点连接起来。
假设这两个点为 xx 和 yy,我们首先调用 makeroot(x)makeroot(x),然后我们实际上令 xx 成为 yy 的子结点就好了。我们从 xx 向 yy 连一条非偏爱边即可。
⑤cut 操作
顾名思义,cut 操作将两个相邻结点断开。
假设这两个点为 xx 和 yy,我们首先调用 makeroot(x)makeroot(x),然后我们再调用 access(y)access(y),然后实际上我们只需要把 xx 到 yy 的偏爱边断开就好了。我们对 yy 进行 Splay 操作,则 xx 一定为 yy 的左儿子。将这条边断开,维护 yy 的信息即可。
3. e.g. Luogu 2147 [SDOI 2008] 洞穴勘测
我们通过一道例题来引入具体实现和基本应用。
题目大意:有一个结点数为 nn 的森林,有 mm 次操作,操作分为三种:连接两个结点,删去两条边,查询两个点的连通性。保证操作合法,任意一次操作后森林还是森林。
这道题要我们做的是动态维护森林的连通性,支持加边删边,这正是 LCT 擅长的。
findroot 操作
findroot 操作允许我们找到某个结点所在连通块在 LCT 中的根结点。思路也很简单:直接 access(x)access(x),然后对 xx 进行 Splay 操作,然后沿着 Splay 结点的左儿子走,找到 Splay 中序遍历的第一个结点就好了。
那么这道题的做法就是:对于 Query 操作,我们调用 findroot,检查两点在 LCT 中的根结点是否一样(一样就说明在同一连通块,否则就说明在不同连通块);对于 Connect 和 Destroy 操作,我们使用 link 和 cut 就好了。
4. 实现5
至此,我们已经完全可以口胡通过上面的例题了。但是怎么写???
①Splay 结点
我们将 LCT 中的所有结点都定义为 Splay 结点:
typedef struct SplayNode { bool flag; // reverse SplayNode* ch[2]; SplayNode* parent; SplayNode() : flag() {} void pushdown() { if (flag) { std::swap(ch[0], ch[1]); ch[0]->flag ^= 1; ch[1]->flag ^= 1; flag = false; } } } Node;
也就是说,LCT 中的 Splay 结点和普通的 Splay 结点没有太大差异,只是多了一个 parent 指针。但另外,可以发现 Splay 结点少了两个成员函数6:
maintain和
comp,前者用于维护一个结点对应子树的 size,后者用来判断应该选择左子树还是右子树。
②parent 指针
Splay 结点的 parent 指针有什么用呢?
事实上,我们在维护 LCT 时并不真正地连接非偏爱边,而是直接将 parent 指针当作保存非偏爱边的容器。当一个点不是 Splay 的根结点时,parent 指针指向它在 Splay 中的父结点;当一个点是 Splay 的根结点时,parent 指针会被当作非偏爱边。如何判断 parent 指针是非偏爱边还是真正指向 Splay 中的父结点的指针呢?只需要判断这个点是不是 Splay 的根结点就好了:
int isRoot(Node* node) { return node->parent->ch[0] != node && node->parent->ch[1] != node; }
③rotate 操作和 Splay 操作
由于我们并不知道要操作的结点在 Splay 的中序遍历中到底是第几个元素,所以我们不能像以前的 Splay 那样从上往下旋转,我们只能从当前结点不断往上旋转,直到当前结点为 Splay 的根。
首先考虑如何判断当前结点是它父结点的左儿子还是右儿子:
int trace(Node* node) { return node->parent->ch[1] == node; // 左 0 右 1 }
然后我们考虑旋转。由于是自底向上的,所以我们定义 rotate(x)rotate(x) 为将 xx 旋转至 xx 的父结点的位置:
void rotate(Node* r) // 旋转至 r 的父结点的位置 { Node* father = r->parent; // 父结点 Node* grand = father->parent; // 爷结点 int d = trace(r); if (!isRoot(father)) grand->ch[grand->ch[1] == father] = r; // 爷结点的儿子变成了 r father->ch[d] = r->ch[d ^ 1]; father->ch[d]->parent = father; // r 的子结点的 parent 发生了变化 father->parent = r; // r 的父结点的 parent 发生了变化 r->ch[d ^ 1] = father; r->parent = grand; // r 的父结点发生了变化 }
在此过程中,除了过去需要维护的东西,我们还需要维护一个结点的父结点。父结点发生改变的有 33 个结点:被旋转的结点,它的某个子结点和它的父结点。
同理,我们定义 Splay(x)Splay(x) 表示将 xx 旋转至它所在 Splay 的根结点:
void pushdown(Node* r) // 下传根到 r 这条链上的标记 { if (isRoot(r)) return void(r->pushdown()); pushdown(r->parent); r->pushdown(); } void splay(Node* r) // 旋转 r 至根 { pushdown(r); while (!isRoot(r)) // 从下往上旋转 { Node* father = r->parent; // 如果是一字型,先旋转上面;如果是之字形,先旋转下面 if (!isRoot(father)) rotate(trace(r) == trace(father) ? father : r); rotate(r); } }
如果需要将 Splay 对应序列反转(reverse),只需要调用:
void reverse(Node* r) { r->flag ^= 1; }
不过需要保证
r为 Splay 的根结点,所以反转操作都需要紧跟在 splay 操作之后。
需要注意的是,上面的参数全部都是指针类型的(
Node*),而非指针的引用(
Node*&)。为什么呢?我们在 LCT 中是这样保存结点的:
Node nodes[maxn];
也就是说,结点之间的关系(
parent,
ch指针)可能会变,但是结点本身是不会改变的,换句话说,变的是结点的内容,而结点的位置却没有改变,所以不需要(也不能)传入引用。
④access 操作
在知道了如何进行 Splay 相关的操作后,问题也就变得简单了:
void access(int code) // 之前的 node->ch[1] 的 parent 没变 { Node* pre = null; Node* node = nodes + code; while (node != null) { splay(node); node->ch[1] = pre; pre = node; node = node->parent; } }
我们保存上一条链的指针
pre,一开始为
null。我们每次首先对当前结点进行 Splay 操作,然后让它的右子树从之前的变成
pre,相当于是接上了上一条链(我们是从下往上接的,上一条链指上次进行了操作的下面那条链,看代码吧),断开了之前的右子树。但是由于之前的右子树的
parent并未改变(根据定义,非偏爱边从 Splay 的根结点指向 toptop 的相邻结点的 Splay 结点),所以我们不用管它。最后令
pre = node,
node = node->parent,往上跳即可。直到不能再往上跳(
node == null),说明我们已经跳到了根结点的父结点(
null),退出即可。
⑤makeroot 和 findroot 操作
终于,我们写完了最底层的代码,所以下面的代码就不用解释了,参考前面的描述。
void makeroot(int code) // 指定结点成为对应连通块在 LCT 中的根 { Node* node = nodes + code; access(code); splay(node); // 注意这个点也是 Splay 的根 reverse(node); } int findroot(int code) // 寻找某个结点对应连通块在 LCT 中的根 { Node* node = nodes + code; access(code); splay(node); while (node->ch[0] != null) node = node->ch[0]; return splay(node), node - nodes; // note }
需要注意要在最后对根结点进行 Splay 操作,以保证时间复杂度。
⑥link 和 cut 操作
无需解释,参考前面的描述。
void link(int x, int y) { makeroot(x); // x 已是 Splay 的根结点 (nodes + x)->parent = nodes + y; } void cut(int x, int y) { Node* node = nodes + y; makeroot(x); access(y); splay(node); (nodes + x)->parent = node->ch[0] = null; }
⑦初始化
LCT 的初始形态是怎样的呢?如果一开始树没有边,我们让每个结点都单独成为一个连通块即可。为了安全,我们为
null结点实际分配内存。
LinkCutTree() { null = new Node; null->parent = null->ch[0] = null->ch[1] = null; } void init() { for (int i = 1; i <= n; i++) nodes[i].ch[0] = nodes[i].ch[1] = nodes[i].parent = null; }
完整代码见代码仓库。
5. 时间复杂度
可以证明,使用 Splay 维护 LCT 的均摊时间复杂度是 O(nlogn)O(nlogn) 的。很明显的是,LCT 常数巨大。
具体的证明高深莫测,限于水平和偏重点,我就不证了,建议你也不要入坑去证。
进阶
e.g. Luogu 3203 弹飞绵羊分块经典题目,同时也是 LCT 经典题目。
对于每一个位置,我们将它看作一个结点,另外我们虚拟一个 n+1n+1 号结点,表示一条就跳出去的情况。由于每一个点只能跳到唯一的位置,因此我们可以建成一棵树。这样,询问就等价于问每一个点到根结点的距离。
如何使用 LCT 来搞呢?如果我们额外维护子树大小,我们就可以知道有多少个结点的中序遍历在当前结点的前面,也就可以做了。
①维护 size
这道题需要维护子树大小,虽然子树大小不是 LCT 的必需品,但是它是做题的必需品。下面是新的结点定义:
struct Node { int size; bool flag; Node* parent; Node* ch[2]; Node() : size() {} void pushdown() { if (flag) { ch[0]->flag ^= 1; ch[1]->flag ^= 1; std::swap(ch[0], ch[1]); flag = false; } } void maintain() { size = ch[0]->size + ch[1]->size + 1; } void reverse() { flag ^= 1; } };
(把 reverse 写进
Node中与写外面没有区别)
最主要需要维护 size 的地方是 rotate 操作:
void rotate(Node* r) { Node* father = r->parent; Node* grand = father->parent; int d = trace(r); if (!isRoot(father)) grand->ch[trace(father)] = r; r->parent = grand; father->ch[d] = r->ch[d ^ 1]; r->ch[d ^ 1]->parent = father; father->maintain(); r->ch[d ^ 1] = father; father->parent = r; r->maintain(); }
其它地方,仅当需要连接或者删去 Splay 中的边时,才需要维护 size。**由于 link 操作连接的是非偏爱边,所以不需要维护 size;**access 和 cut 操作需要断开或者连上 Splay 中的边,所以也需要维护 size:
void access(int code) { Node* pre = null; Node* node = nodes + code; while (node != null) { splay(node); node->ch[1] = pre; node->maintain(); pre = node; node = node->parent; } } void cut(int x, int y) { Node* node = nodes + y; makeroot(x); access(y); splay(node); (nodes + x)->parent = node->ch[0] = null; node->maintain(); }
②获取结点在原树中的深度
首先需要对原树的根结点进行 makeroot 操作,然后对需要计算的结点进行 access 和 splay 操作,这样它的左子树大小就是深度比自己浅的结点个数,正是本题需要求的东西。
int depth(int x) { makeroot(n); access(x); splay(nodes + x); return (nodes + x)->ch[0]->size; }
e.g. Luogu 3690 [模板] Link-Cut Tree
题目大意:给你一个森林,要你维护森林连通性,不保证删边或者加边操作合法。森林中的点有点权,有查询路径上点权异或和的询问,还要求支持单点修改点权。
首先我们解决 link 操作不合法的问题,很简单,只需要用 findroot 判断是否在同一连通块就好了。
void link(int x, int y) { makeroot(x); if (findroot(y) == x) return; (nodes + x)->parent = nodes + y; }
①解决 cut 的合法性问题
cut 操作合法的先决条件是待操作结点要相邻。我们首先 makeroot(x)makeroot(x),然后检查 findroot(y)findroot(y) 是否为 xx,如果不是,显然它们不在同一连通块。否则我们 Splay(y)Splay(y)(注意在 findroot 中已经 access(y)access(y) 了),然后检查 xx 和 yy 的中序遍历之间是否有别的结点,只需要看 yy 的左子树大小是否为 11 即可。如果不需要维护大小呢?那就检查两个地方:一看 yy 的左儿子是不是 xx,二看 xx 是不是没有右儿子。检查之后我们才能 cut。
void cut(int x, int y) { makeroot(x); if (findroot(y) != x) return; Node* node = nodes + y; splay(node); if (node->ch[0] != nodes + x || (nodes + x)->ch[1] != null) return; // note (nodes + x)->parent = node->ch[0] = null; // note node->maintain(); }
注意别写错了。
②维护异或和
事实上,我们只需要维护 Splay 中对应子树的异或和就行了,而且只需要沿用之前那道题的做法,改一下 maintain 函数就好了。
void maintain() { sum = weight ^ ch[0]->sum ^ ch[1]->sum; }
就是说,调用 maintain 函数的时机是一样的,只是 maintain 函数的内容变了。这启示我们,LCT 可以很方便地维护点上的信息。
相比之下,修改点权和查询点权异或和就很简单了,随便写写都能过。记住在 Splay 发生改变时进行 maintain 操作。
void change(int pos, int val) { access(pos); Node* node = nodes + pos; splay(node); node->weight = val; node->maintain(); } int query(int x, int y) { makeroot(x); access(y); Node* node = nodes + y; splay(node); return node->sum; }
e.g. Luogu 2387 [NOI 2014] 魔法森林
题目大意:给定一张 nn 个点 mm 条边的图,每条边有两个边权 aa 和 bb。找一条 11 到 nn 的路径使得 max{a}+max{b}max{a}+max{b} 最小,如果不存在输出 −1−1。
很容易往最小生成树上面去想,然后考虑一个套路做法。我们枚举 aa 的最大权值,然后看对于每个 aa 答案是多少。具体地说,我们可以把边按照 aa 从小到大排序,依次加入图中,每次再看看 11 到 nn 的最优答案。
如何看呢?我们直接令 aa 为当前的最大边权,然后找一条从 11 到 nn 的路径使得路径上最大的 bb 最小就好了。利用非法解不会最优的思想,我们可以用 SPFA 在 O(nm2)O(nm2) 的时间复杂度内解决这个问题。然后恭喜 TLE。(我不会打大优化,所以就不讨论如何优化这个算法了)
我们考虑使用 LCT 来维护这个东西。
①边权转点权
一个不可否认的事实是,LCT 真的没有办法维护边上的信息,所以我们要想个办法把边上的信息换到点上。
由于树有 nn 个点,n−1n−1 条边,所以我们自然会想到把边的信息放到儿子结点上,但是这在 LCT 中是行不通的,因为 LCT 本身可以换根,一条边连接的两个点的父子关系会发生变化。
正确的做法是为每一条边都虚拟一个结点,点权为原边权,而原点权为 00。加边时,我们从原来的两个端点分别向边对应的端点连边即可。
②维护最小生成树
事实上,原问题已经被转换成了一个动态维护最小生成树的问题:只不过原始的动态维护最小生成树需要首先求一次 MST,但这道题是从零开始维护的。
如何维护 MST 呢?稍微有点经验的人都知道,加入一条边时,如果两点不在同一连通块,那么直接连上就行;如果两点在同一连通块,那么一定会形成一个环。我们删去环上的最大边即可。
如何使用 LCT 来维护呢?对于每个 Splay 结点,我们保存它所在子树的最大边权以及相应的编号即可。
struct Node { Node* parent; Node* ch[2]; bool flag; int weight; int max; int idx; int maxIdx; void pushdown() { if (flag) { std::swap(ch[0], ch[1]); ch[0]->flag ^= 1; ch[1]->flag ^= 1; flag = false; } } void maintain() { if (weight >= std::max(ch[0]->max, ch[1]->max)) { max = weight; maxIdx = idx; } else if (ch[0]->max > ch[1]->max) { max = ch[0]->max; maxIdx = ch[0]->maxIdx; } else { max = ch[1]->max; maxIdx = ch[1]->maxIdx; } } void reverse() { flag ^= 1; } };
maintain 操作的执行时机和之前完全一样。剩下查询信息的代码就很简单了,善用 makeroot,access 和 splay 即可。详见代码仓库。
最小生成树加边:
if (e.from == e.to) continue; // note if (tree.findroot(e.from) != tree.findroot(e.to)) { tree.link(e.from, n + i); tree.link(e.to, n + i); } else { int code = tree.qMax(e.from, e.to) - n; if (edges[code].b <= e.b) continue; tree.cut(code + n, edges[code].from); tree.cut(code + n, edges[code].to); tree.link(n + i, e.from); tree.link(n + i, e.to); }如果没有特殊说明,链指一条包含至少一个点的且深度单调的简单路径,即祖先后代链。 ↩
到目前为止,你可以认为相邻的结点就是 toptop 的父结点,但实际上 LCT 需要进行换根操作,原树是否有根无关紧要,所以说成相邻才是正确的。 ↩
如无特殊说明,对某个点进行 Splay 操作表示将它旋转至它所在的 Splay 的根结点。 ↩
不知道什么东西?你可以出门右转先学一下 Splay,不过不保证你学的代码能用在接下来 LCT 的实现上。 ↩
LCT 的实现有很多,选择最适合自己的就好。如果你不会具体实现,又觉得我太弱或者代码太丑,请跳过这一部分。我的代码在访问元素时将以指针为主。 ↩
这只是我的习惯,如果你并不习惯在 Splay 结点中直接写成员函数也没关系,我只是想告诉你 LCT 的 Splay 结点在不用维护别的数据的情况下不需要引入 size。 ↩
相关文章推荐
- 【动态树初探】link-cut tree
- 【BZOJ3282】Tree (Link-Cut Tree)
- cogs1889 [SDOI2008]Cave 洞穴勘测 link-cut tree
- 【UOJ207】共价大爷游长沙(Link-Cut Tree,随机化)
- Windows 10 Pro_Ent Insider Preview x86 x64 10147中文版激活
- 【link-cut tree】
- 【BZOJ2002】弹飞绵羊(Link-Cut Tree)
- 【BZOJ4530】大融合(Link-Cut Tree)
- 【BZOJ2049】[Sdoi2008]Cave 洞穴勘测【Link-Cut Tree】
- 【学习心得】Link-cut Tree
- Link-Cut Tree
- BZOJ 3282 Tree ——Link-Cut Tree
- 【BZOJ4736】温暖会指引我们前行(Link-Cut Tree)
- 【BZOJ4530】大融合(Link-Cut Tree)
- link-cut tree
- 【动态树初探】link-cut tree
- BZOJ 题目2049: [Sdoi2008]Cave 洞穴勘测(link cut tree)
- BZOJ 题目1036: [ZJOI2008]树的统计Count(Link Cut Tree,修改点权求两个最大值和最大值)
- 【BZOJ3651】网络通信【Link-Cut Tree】
- Hnoi2010 bounce Link-Cut Tree