LCT | 动态树

基本概述

对于原树进行实链剖分,原树上每一个节点仅向其中一个儿子连实边,向其他儿子连虚边。

根据我们维护信息的需求,边的虚实是可以动态变化的,我们对于每条实边构成的链用一个 DS 维护。而轻边相当于连接了两个相邻的 DS。实链剖分之后用平衡树来维护一条链,而由于平衡树正好是树的形态,所以我们可以直接将其放入新树上,用轻边连接相邻平衡树。重链剖分之后用线段树维护,但是线段树是一个很好的分治结构,却不是一个好的树的结构,所以不能直接放到新树,因此需要用 dfs 序来辅助转化到线段树上,因此也导致其多了一个 \(\log\)

实现

我们需要动态维护这个树的结构,支持加减边,动态维护路径信息。

先定义一些基本数组 \(ch_{u,0/1}\) 表示 \(u\) 节点在平衡树上的左右儿子,他们左右儿子的 \(fa\)\(u\)

平衡树之间的虚边,满足认父不认子。也就是某平衡树的根节点 \(rt\)\(fa_{rt}\) 连向的点正是原树中平衡树对应的那一条实链最顶端点的父节点,但是由于不在一个平衡树内,所以父节点的 \(ch\) 中没有 \(rt\)。注意这个 \(rt\) 在原树中并不一定是 \(fa_{rt}\) 的儿子,\(fa\) 的儿子应该是这个平衡树上深度最浅的点。

Splay 部分

\(\rm Nroot\)

Nroot(u) 用来判断 \(u\) 是不是 Spaly 的根,根据虚边认父不认子的规则,我们只需要看它父亲的左右儿子中是否有它即可。如果有它,就代表它不是 Splay 的根。

bool Nroot(int x){  return ch[fa[x]][0]==x||ch[fa[x]][1]==x; }

\(\rm rotate\)

rotate(x) 代表对于 \(x\) 进行一次 Splay 上的旋转。

假设目前要旋转 \(x\),它的父亲和爷爷分别是 \(y\)\(z\)。根据认父不认子,我们通过 \(\rm Nroot\) 来判断只要 \(y\) 不是平衡树的根节点,就把 \(z\) 的儿子修改为 \(x\)。如果没判断的话会导致不同平衡树之间认儿子了。

更新很简单,就是你手画一下旋转之后的情况,然后修改三个点的儿子信息,和三个点的父亲信息。同时记得判断节点非空再赋 \(fa\) 的值。最后别忘记 pushup(y),再 pushup(x)

void rotate(int x){
	int y=fa[x],z=fa[y],k=get(y,x),w=ch[x][!k];
	if(Nroot(y)) ch[z][get(z,y)]=x;
	ch[x][!k]=y; ch[y][k]=w; 
	if(w) fa[w]=y;
	fa[x]=z; fa[y]=x;
	pushup(y); pushup(x);
}

这里的 get(x,y) 就是在 \(y\)\(x\) 右儿子的时候返回 \(1\),否则返回 \(0\)

\(\rm Splay\)

Splay(u) 的时候就需要将 \(u\) 点变成所在平衡树的根节点。

由于有修改操作,所以我们会维护若干 tag,在平衡树形态变化的时候需要 pushdown。先将 \(u\) 到所在平衡树的根路径自上而下 pushdown。只需要一直跳 \(fa\),跳到根节点然后从上面开始 pushdown 即可。

只要 \(u\) 不是根就重复执行旋转。取 \(y,z\) 分别为 \(u\) 的父亲和爷爷,当然如果不存在爷爷就可以直接一步 rotate 到位了。如果 \(u,y,z\) 成一字型,我们连转两次 \(u\),否则先转一次 \(y\),再转 \(u\)

	void splay(int x){
		int y=x,top=0; st[++top]=y;
		for(;Nroot(y);y=fa[y]) st[++top]=fa[y];
		while(top) pushdown(st[top--]);
		while(Nroot(x)){
			y=fa[x]; int z=fa[y];
			if(Nroot(y)) rotate((lc(z)==y)^(lc(y)==x)?x:y);
			rotate(x);
		}
		pushup(x);//(*)
	}

注意:上面打星号的位置,记得上述不断翻转结束之后,需要再 pushup 一次。虽然我们 rotate 里面就有 pushup,但是防止 \(u\) 直接是根了,pushdown 导致信息变动,但是进不去 rotate 。

注意:根据上述理论,每一个 Splay 维护的是一条从上到下按在原树中深度严格递增的路径,且中序遍历 Splay 得到的每个点的深度序列严格递增。所以注意 Splay(p) 之后只是保证 \(p\) 为平衡树的根节点,但并不是原树的根节点。需要处于最上方的平衡树且没有左子树保证深度最低才是根节点。

动态树的操作

\(\rm access\)

access(u) 是最重要的操作,将原树中 \(rt-u\) 路径上的边全部变成实边。

分析一下,\(rt-u\) 的路径上一定是若干实链通过若干条虚边接在一起。我们需要将虚边全部变成实边,也就是连成一条实链,同时路径上其他每个点(包括 \(u\))的其他边都要变成轻边。

如图是原树的一个局部情况,我们需要将中间那条虚线边变轻。同时由于我们是要把 \(rt-u\) 变成实边,所以只和深度小于 \(u\) 的部分有关系,所以 \(u\) 下方紫色点无用,同理另一个紫色点也要舍弃。舍弃就是直接修改儿子信息就行了,由于认父不认子,我们的 \(fa\) 数组还保留就完美符合要求。

为了把深度大于 \(u\) 的部分都舍弃,我们先 splay(u),把 \(u\) 变成所在平衡树的根,然后就可以通过舍弃右子树来达成目的。对于 \(fa\) 也是同理,直接 \(\rm splay\) 之后舍弃右子树,同时右子树连接上 \(u\) 点,这样子就完成了边从虚到实的转化。

	void access(int x){
		for(int y=0;x;y=x,x=fa[x]){
			splay(x); rc(x)=y; pushup(x);
		}
	}

\(\rm makeroot\)

makeroot(u) 换根操作,就是把 \(u\) 变成原树的根节点。

\(\rm access\) 一下打通 \(u\) 和根节点的路径。这个时候两者在同一平衡树内。我们再 \(\rm splay\) 一下,这个时候 \(u\) 成为了最顶端平衡树的根节点,此时 \(u\) 的左子树内的点都是深度比 \(u\) 浅的点,而 \(u\) 的右子树内没有点,因为 \(u\) 在原树上就是这条链的最深点。因此直接交换左右子树就可以做到让 \(u\) 变成最浅点了!这就直接打一个翻转标记就行了,平衡树经典操作。

void makeroot(int x){ 
  access(x); splay(x); Reverse(x); 
}
void Reverse(int x){ 
    swap(lc(x),rc(x)); r[x]^=1; 
}

\(\rm findroot\)

findroot(u) 就是找到 \(u\) 所在树的根节点(一般整个图是一个森林),类似于并查集的操作。

在 LCT 维护树,只合并不分离的情况下,我们甚至可以直接用并查集维护联通性,因为 \(\rm makeroot\) 常数还是比较大的。

还是老套路, \(\rm access +\rm splay\),这样子根节点就和 \(u\) 在一个平衡树内了,根据深度最小,我们不断跳左儿子即可。同时一定要每一步都 \(\rm pushdown\)。为了保证树的形态,我们最好最后再把根通过 \(\rm Splay\) 转回去。

	int findroot(int x){
		access(x); splay(x);
		pushdown(x);
		while(lc(x)) x=lc(x),pushdown(x);
		splay(x); return x;
	}

注意这个是需要一路 pushdown 下去的。

\(\rm split\)

split(u,v) 就是需要提取出路径 \((u,v)\),也就是将路径上的边都变成实边。

只需要把 \(u\) 换成根节点之后,对 \(v\) 做一次 \(\rm access\) 就行了。

void split(int x,int y){ 
    makeroot(x); access(y); splay(y); 
}

\(\rm link\)

link(u,v) 代表连接边 \((u,v)\)

对于保证 \(u,v\) 不联通情况,直接 makeroot(u),再 \(fa_u\gets v\)。注意第一步的原因是变根过程中暗含了 \(\rm splay\) 操作,只有平衡树的根节点才能将 \(fa\) 赋值为实链顶的父亲。而且为了保证不损失原先 \(fa\) 信息,我们必须将 \(u\) 变成根节点之后,才能赋 \(fa\)。如果直接赋 \(fa\),就两个父亲了。

对于没有保证的,我们需要先 makeroot(u),再看一下 findroot(v),如果不连通,再对于 \(fa_u\) 赋值为 \(v\)

	bool link(int x,int y){
		makeroot(x);
		if(findroot(y)==x) return 0;
		fa[x]=y;
		return 1;
	}

\(\rm cut\)

cut(u,v) 就是断开树上 \(u,v\) 两点之间的边。

如果保证有边的话,我们直接 split(u,v) 后,\(u,v\) 在同一平衡树内。此时 \(u\) 必然是 \(v\) 的左儿子,直接双向断开即可,需要断开 \(fa\) 数组和 \(ch\) 数组。

如果不保证 \((u,v)\) 边存在的话,需要 makeroot(u),然后判断一下 findroot(v) 保证两者在同一联通块内,再判断 \(fa_v\) 看看 \(u,v\) 是否是父子关系,接下来两个判定单独拿出来是不成立的,但是由于之前 findroot(v) 的时候,联通了 \((u,v)\) 并且先把 \(v\) 转到了平衡树的根节点,又把 \(u\) 转回到了平衡树的根节点,所以这个时候只要 \(fa_v=u\) 的话,\(v\)\(u\) 的右儿子。那么只需要判定 \(v\) 没有左子树,如果有左子树就代表原树中 \(u,v\) 之间还有别的点,于是最后判定 \(v\) 没有左子树就可以了。三条判定如果记不住,就直接 std::map 存一下连的边就行了。

	bool cut(int x,int y){
		makeroot(x);
		if(findroot(y)!=x||lc(y)||fa[y]!=x) return 0;
		rc(x)=fa[y]=0;
		pushup(x);
		return 1;
	}

应用

简化版 LCT

P3203 [HNOI2010] 弹飞绵羊

发现每个点只会跳向一个点,符合树上节点只有一个父亲,考虑用 LCT 来维护。我们从每个点向它跳向的点连边。

本题是一个轻量级版本的 lct,也就是一个只有树合并分离的 LCT 了。

发现不用换根等等一系列操作。

对于询问就是查询 \(rt-x\) 链节点个数。不需要用传统 \(split\) 函数了,因为只需要求到根的信息,直接 \(access+splay\) 解决。

\(link\) 函数,由于保证了 \(x\) 向外连的时候一定是树根,于是直接 \(splay\),然后赋值 \(fa\)

\(cut\) 函数,\(access+splay\) 之后去掉左儿子即可(双向断)。同时注意更改的 \(fa\) 信息是 \(ch_{x,0}\) 的而不是 \(x+k_x\) 的。

维护子树信息

子树信息分为轻儿子子树和重儿子子树,重儿子子树就是 \(\rm Splay\) 之后的右子树。

对于每个点记录所有轻儿子信息和,把 \(u\) 转成根后,就是平衡树上右子树了。
在 access 中 \(ch(x,1)=y\) 的时候 \(O(1)\) 修改。

P4219 [BJOI2014] 大融合

LCT 维护子树信息模板题。

这里要维护的就是子树和了。

我们额外维护 \(gs_u\) 代表 \(u\) 的所有轻儿子的信息总和。pushup 内 \(s\) 的累加要带上 \(gs\)

\(access\) 往上跳的过程中也要动态调整 \(gs\)\(link\) 的时候由于加入轻儿子,所以也要修改 \(gs\),同时注意我们无法一层层往上更新,所以在 link 中更新 \(gs_v\),必须先 \(\operatorname{makeroot(v)}\)

每次查询 \((x,y)\) 就是先断开 \((x,y)\) 边,然后各自 \(\operatorname{makeroot}\) 求出两边各自的信息乘在一起,然后再连接上 \((x,y)\) 边。

此外,还有一种做法。根据最终形态建立出森林,然后求出 dfs 序。同时同一个并查集维护树上联通块,我们可以在每次加边的时候合并。同时大概要维护一个链加,求子树大小。可以用树状数组实现树上差分,子树大小就是子树求和了。

加边删边并查集

P2387 [NOI2014] 魔法森林

暴力:枚举 A 精灵数量 \(a\),把所有满足 \(\le a\) 限制的边按照 \(b\) 升序排序依次加入并查集判断连通性。

骗分做法:三分 \(a\) ,并查集求 \(b\) 最小值。或者两层二分 \(a~b\),然后并查集。但是正确性无法保证。

此时可以像 P5443 [APIO2019] 桥梁,那样操作分块+可回退并查集。其实感觉这题更本质一点,那题是针对询问和修改各有一套做法然后二者平衡一下,似乎询问操作二者还有点区别。但是这题其实让我弄懂了询问和操作本质没有区别,二者是完全一样的!映射到桥梁那题中这题里的 \(A\) 就像询问,\(B\) 就像操作,我们发现其实可以 对枚举 \(A\) 加入 \(B\),显然也可以枚举 \(B\) 加入 \(A\),这是完全对称的!!这也就证明了那题中的询问和操作的做法其实本质是一样的本题对边关于 \(a\) 排序,然后块内对 \(b\) 排序,把所有之前 \(\le a\) 的边拿一个指针维护依次加入,问题就完美解决了。

回到 LCT,这是动态树经典应用:不断地加边,判环,取最优者。同时这题还有一个小技巧就是边化点。定义一条边 \(i\) 的权值为 \(b_i\),本题做法就是从小到大枚举 \(a\), 然后尝试加入该边。如果 \(u~v\) 本来就不连通自然是加入成功了,如果二者联通,那么就在 \(u-v\) 路径上找到权值最大的点,如果该权值大于目前要加入边的权值,就把该边删除,加入新边。每次加完边后判断一下 \(1-n\) 是否联通,然后更新答案。这样整个 LCT 森林维护的就是在当当前约束 \(a\) 之下任意两点之间的最小 \(b\) 路径。

P4172 [WC2006] 水管局长

本题条件可以转化为求最小生成树上 \(\rm path(x,y)\) 上的最大边的边权。

时光倒流,删边转化为加边。每次加入一条边如果形成环,我们只需要在环上找最大边删掉就行了。用 LCT 维护这个过程,时间复杂度 \(O(n\log n)\)

综合题

P8265 [Ynoi Easy Round 2020] TEST_63

集训的例题,质量挺高的,加深了对于 LCT 的理解。本题不是把 LCT 当工具使用,直接调用各个函数,而是对于其各个函数的实现进行了内部更改。

发现本题这些操作有点像 LCT,于是我们考虑用 LCT 的虚实边来维护树上的轻重边。也就是对应映射一下。那么每个 Splay 上面维护的便是一条重链的信息。

考虑将 \(y\) 变成 \(rt\) 的操作,是先 \(\operatorname{access}\)\(\operatorname{splay}\),再翻转。一次 \(\operatorname{access}\) 之后 \(y-rt\) 的所有边都变成了实边,对应一下也就是所有都变成了重边,但是显然不太符合实际情况。我们需要找到那些轻边在 LCT 中把他们变成虚边。因为根据轻边定义 \(sz_u > 2 sz_{son}\),所以对于每条轻边必然存在一个 \(k\) 使得 \(sz_u \ge 2^k > sz_{son}\)。可以枚举 \(k\) 通过 Splay 上二分找到 \(sz_u \ge 2^k > sz_{son}\),然后直接修。

对于统计重链第 \(k\) 大,在每次上述修改虚实边后一条重链会断成两段,我们将两段加入权值线段树中即可。

同时补充一下,对于上述 \(sz\) 的维护是在 LCT 中维护子树信息。做法是对于每个点记录所有轻儿子信息之和 \(gs\) ,改在 \(\operatorname{access}\)\(ch(x,1)=y\) 的时候 \(O(1)\) 修改即可。子树大小就是 \(gs(u)+s(ch(u,1))+1\)。同时本题规定了在子树大小相同的时候,选择下接重链上编号更大的那个作为重儿子,于是我们还要记录最大编号。

然后有一个卡常小技巧就是用两个优先队列来代替 set,一个优先队列负责添加元素,一个优先队列负责删元素,这样子常数小了好多。

下面是对于 LCT 一些传统的函数内部细节的说明。

\(\operatorname{push}\) 中维护的信息 \(sz\) 信息不只是平衡树上信息了,还要加上其他轻儿子信息 \(gs\)。维护 \(val\) 代表实链异或和,也就是重链异或和。同时记录一下该重链中最大编号。

void pushup(int u){
   s[u]=1+gs[u]+s[lc]+s[rc];
   Mx[u]=max({Mx[lc],Mx[rc],u});
   val[u]=val[lc]^val[rc]^u;
}

\(\operatorname{access}\) 中由于要修改 Splay 中右子树信息,这一项的修改涉及到了重链断裂成两个重链,同时新的重链生成。轻儿子的信息修改。于是我们记录下一路需要断开的边的位置,然后从上到下倒着修改一下。需要用到函数 modify(u,v)表示断开 \(u\) 原先的右子树,连上 \(v\)。首先,把原先保存的两条重链权值删掉。因为重链会发生变化。然后维护轻儿子信息,也就是轻儿子的子树信息和,还有 \(sz\) 的排名保存下来。然后在权值线段树中加入新产生的两条重链,注意第二条重链要在 \(\operatorname{pushup}\) 更新过新的重链信息后再加入权值线段树。当然对于 \(\operatorname{access}\) 造成的重链数量变多,我们在 \(\operatorname{link}\)\(\operatorname{cut}\)\(\operatorname{makeroot}\) 这些调用 \(\operatorname{access}\) 的函数里面再修改,而不是立刻修改。

void modify(int u,int v){
    seg.update(1,0,V,val[u],-1); if(v) 
    seg.update(1,0,V,val[v],-1);
    if(rc) S[u].ins(s[rc],Mx[rc]);
    if(v) S[u].del(s[v],Mx[v]); 
    gs[u]+=s[rc]; gs[u]-=s[v];
    if(rc) seg.update(1,0,V,val[rc],1);
    rc=v; pushup(u); seg.update(1,0,V,val[u],1); 
}

然后 \(\operatorname{cut}\) 的时候,要注意我们先要根据子树大小看看谁是谁的父亲,然后本来要判断一下 \(v\) 是否是 \(u\) 的轻儿子,但是我们可以通过 access(u)\(v\) 先变成 \(u\) 的轻儿子,这样子后面的操作就统一了。

\(\operatorname{link}\) 稍微修改一下,先记录一下轻儿子信息即可。

\(\operatorname{cut}\)\(\operatorname{link}\)\(\operatorname{makeroot}\) 函数后面都要跟着一个 change
函数,就是上面那个枚举 \(k\) 然后 Splay 上二分修改轻儿子的操作。

其余未提及函数均按照原先写法即可。

posted @ 2024-01-06 23:49  Mirasycle  阅读(50)  评论(0)    收藏  举报