Link-Cut-Tree详解
概述
LCT树是一类动态树,其用于动态维护多个连通无环图之间的关系,允许动态删除边或者增加边(新增的边不允许构成环)。在我眼中,LCT是类似线段树和Splay树的万金油数据结构,非常强悍,值得一学。LCT的所有操作的摊还时间复杂度均为O(log2(n)),其甚至优于更加简单的树链剖分。
算法说明
假设我们有一组独立的树(即连通无环图)。LCT中的边分为两类,偏好边(Prefered)和普通边。如果一个结点x下某个子结点y被访问,我们称y为x的偏好结点,连接父结点和偏好结点的边称为偏好边,x对y的偏好一直保持到另外一个x的儿子z被访问,这是x的偏好结点成为z,而x与y之间的边也就成为了普通边。一个结点最多只有一个偏好结点。就像Splay树的核心操作是splay一样,LCT也有自己的核心操作,称为access,用于访问某株树中的某个结点和其所有父结点。
实际上一株树上会有多条由偏好边相连得到的路径,每条路径都没有交集(原因是偏好结点只允许有一个)。可以发现一条偏好路径上是没有相同深度的结点的,这是一个有用的信息,我们可以以偏好路径上结点的深度作为关键字用Splay树保存整条路径上的所有结点信息,每条偏好路径都对于一个Splay树。允许存在只有一个结点的路径,那么可以保证所有树中的结点都被维护在某株Splay树中。这样我们发现继续维护整株原树的信息有些多余,我们可以通过为每个结点添加一个属性routeFather来表示其所在路径最高点的实际父结点(原树中)。一个结点在原树的父结点或者是该结点在其所在偏好路径对应的Spaly树上的前驱结点,或者是其routeFather。
下面说明如何实现LCT的各项关键操作:
access
access(x)用于访问所有x和所有原树中x的祖先结点,这会导致在x和x所在原树中的根结点r之间构建一条偏好路径。access还有没提到的效果,就是会移除x对所有子结点的偏好,也就是access构建的路径的两端为x和r,这在对路径做操作时会尤其有用。
access(x)实现需要预先将x旋转到顶部,并移除其右孩子(其偏好结点)。之后对x的父亲结点y做splay操作,旋转到顶端,之后移除y的右子树,并将x作为y的右子树,之后对父亲结点y做相同操作,直到遇到抵达原树根所在路径。
access(x) y = NIL while(x != NIL) splay(x) x.right.father = NIL x.right.routeFather = x x.right = y y.father = x y = x x = x.routeFather
findRoot
findRoot(x)用于寻找x所在原树中的根。可以先access(x)以在x与原树根r之间建立路径,之后沿着路径寻找最小的结点,该结点必定是r。
findRoot(x) access(x) splay(x) while(x.left != NIL) x = x.left splay(x) //执行splay以摊还费用 return x
makeRoot
makeRoot(x)用于将x作为原树的根。
注意到我们树的信息实际上是通过Splay树保存的,而所谓的根就是最小的结点。我们可以通过access操作在x和原树根r之间建立一条偏好路径,之后翻转这条路径即可。
makeRoot(x)
access(x)
splay(x)
reverse(x)
其中reverse可以使用惰性标记来实现,以降低时间复杂度。但是不要忘记在直接访问左右孩子之前需要下压标记。
cut
cut(x, y)用于删除x与y之间的边。
需要先将makeRoot(x),将x作为根,之后access(y),建立起从x到y的路径。之后旋转y到顶部,此时x为y的左孩子,切断二者的联系,同时将y作为新树的根。
cut(x, y) makeRoot(x) access(y) splay(y) y.left.father = NIL y.left = NIL
join
join(x, y)建立x与y之间的边。
需要先makeRoot(x),将x作为其所在原树的根,并将x的routeFather设置为y,从而将x连接到y之下。
join(x, y)
makeRoot(x)
splay(x)
x.routeFather = y
getRoute
getRoute(x, y)用于获取x与y之间的路径,返回该路径对于Splay树的根结点。这个方法的返回值用于对路径做操作。
可以这样实现,先通过makeRoot将x作为根,之后access(y),此时建立了x与y之间的路径,在splay(x)将x旋转为树根并返回。
getRoute(x, y) makeRoot(x) access(y) splay(x) return x
时间复杂度
不难发现所有LCT操作,如果移除了access方法后,实际上就是对Splay树的操作,即摊还时间复杂度为O(log2(n))。因此我们只需要证明access方法的时间复杂度即可。
先说明access的次数。这里假设我们已经了解了树链剖分的基于轻重链的时间复杂度的分析,如果不了解,可以查看我的另外一篇博客《树链剖分详解》。我们了解到每次access内部循环发生时,必然会发生偏好结点切换,偏好结点由轻孩子切换为轻孩子,或者由轻孩子切换为重孩子,或者由重孩子切换为轻孩子。由于在access构建的路径上最多有log2(n)个轻孩子,因此我们能保证偏好结点由轻孩子切换为轻孩子和由重孩子切换为轻孩子的次数至多为log2(n)次。接下来说明轻孩子切换为重孩子的次数,在整个程序流程中,重孩子切换发生的次数的上限为轻孩子切换为重孩子的次数加上边的总数(可以假设初始时所有重孩子都是偏好孩子,之后每次重孩子要再次变成偏好结点,必定对应之前发生的某次该重孩子变成非偏好结点)。假设发生了k次操作,则重孩子切换最多发生n+klog2(n)次,平摊下来,每次操作重孩子切换最多发生(n/k+log2(n))次,而由于建立树的操作存在(即将原本不相连的孤立结点通过边连接为一株树),因此k>=n-1,从而重孩子切换的平摊次数为O(1+log2(n))=O(log2(n))次。到此说明了每次access操作内部循环平摊下来发生了O(log2(n))次。
接下来说明一次access中对应的O(log2(n))次splay操作的总时间复杂度为O(log2(n))。对于splay的时间复杂度的说明可以看我的另外一篇博客《Splay树分析》,这里不加赘述。我们认为所有routeFather引用也是Splay树的一条边,此时势能的定义依旧,s(x)为x代表的Splay子树所有结点的数目,d(x)则等价于log2(s(x))。我们记x0=x,x1=x0.routeFather,....,xk=xk-1.father,而x'i表示xi经过splay操作后对应的结点。而第i次splay操作的摊还时间复杂度为
$$ T_i\le 3\left(d\left(x_i'\right)-d\left(x_i\right)\right)+O\left(1\right) $$
因此我们对时间复杂度进行加总得到
$$ \sum_{i=0}^k{T_i}\le 3\sum_{i=0}^k{\left(d\left(x_i'\right)-d\left(x_i\right)\right)}+O\left(\log_2\left(n\right)\right) $$ $$ \le 3\sum_{i=0}^k{\left(d\left(x_i'\right)-d\left(x'_{i-1}\right)\right)}+O\left(\log_2\left(n\right)\right) $$ $$ =d\left(x'_k\right)-d\left(x'_0\right)+O\left(\log_2\left(n\right)\right)=O\left(\log 2\left(n\right)\right) $$
到此时间复杂度的证明完成。而一次access操作的摊还时间复杂度为O(log2(n))*O(1)+O(log2(n))=O(log2(n))。对应的所有LCT的操作的时间复杂度也是O(log2(n))。考虑到LCT是完全建立在splay之上的,而splay已经拥有常数大的特征了,因此LCT的常数是更甚,需要小心。