LCT的进阶应用
关于LCT的进阶应用
不光是写算法思路,因为已经有很多人写过了,更重要的是代码写法中的细节,不然LCT各种奇怪应用的细节够你一题调一个小时
本篇文章将助你考试一遍过样例!
当然,还有一些基础应用比如维护路径信息或者维护联通性,但是代码都相对比较模板,所以也没什么好写
一. LCT维护边双连通分量
有些题目中会动态加边并有形如“x到y之间有多少必经边” “某个边双内有多少点”的询问,这时候需要用LCT来维护边双联通分量
思路讲解
注意到,如果把所有边双缩成点,那么一定会构成一个森林
此时如果新增了一条边 \(x,y\),一种情况是 \(x,y\) 不在同一棵树里,此时直接link(x,y)
即可
一种情况是 \(x,y\) 在同一棵树中,但不在同一个边双中,那么就会形成一个新的边双
找出 \(x,y\) 所在的边双所缩成的点 \(fx,fy\),然后把 \(fx\) 到 \(fy\) 这条路径上的所有点缩成一个新点,并让这个新点来记录新的边双中的点的权值之和
实现时可以用并查集来记录每个点在哪个边双中
注意,维护边双时不支持删边操作,因为无法快速确定删除某个边双内部的一条边后会分裂成几个新的边双
代码剖析
相信大家都已经记住原版LCT是怎么写的了
这里来看一下维护边双的LCT和原版有哪些区别
1. 并查集维护每个点在哪个边双中
int Fa[N];
int find(int x) { return Fa[x] == x ? x : Fa[x] = find(Fa[x]); }
//记得初始化并查集
2. access操作写法发生变化
inline void access(int x) {
for (int i = 0; x; i = x, x = find(fa[x])) { //x每次跳到find(fa[x])而不是fa[x]
splay(x); ch[x][1] = i;
if (i) fa[i] = x; //一定要把新的重儿子i的父亲设为x
pushup(x);
}
}
3. 新增操作merge:将x到y的路径缩为一个点
queue<int> q;
inline void merge(int x, int y) {
split(x, y); //将x到y的路径提取出来
q.push(y);
while (!q.empty()) { //用bfs在二叉树上遍历x到y路径上的所有点
int u = q.front(); q.pop();
Fa[find(u)] = y; //维护并查集
if (ch[u][0]) q.push(ch[u][0]);
if (ch[u][1]) q.push(ch[u][1]);
ch[u][0] = ch[u][1] = 0;
}
val[y] = sum[y]; //y成为这个边双的代表元
//如果还有其它信息也是要全部让y来存
}
4. 连边 \(x,y\) 时的分类讨论
伪代码如下 维护两点联通性可以再开一个并查集,也可以用findroot
void LINK(int x, int y) {
int fx = find(x), fy = find(y);
if (fx == fy) return; //x,y已在同一边双中
if (x,y不在同一棵树中) {
link(x,y); //如果用另一个并查集维护联通性记得更新并查集
} else {
merge(fx,fy); //是fx,fy 不是x,y!!!
}
}
5. 进行任何修改/询问操作时都一定是对 \(fx=find(x)\) 进行,而不是 \(x\) 本身!
例题
二. LCT维护子树信息
LCT一般用来维护路径信息而不是子树信息,因为LCT维护子树信息非常不方便。。。
但是有些毒瘤题可能在询问子树信息的同时还有加边删边操作,或者可能有换根操作,就只能被迫使用LCT了
思路讲解
假设现在需要用LCT维护原树中一个子树的 \(siz\)
为了方便查询,一般会把原树中一条重链的顶端 \(x\) 的子树信息存储在 (\(x\) 在LCT中所在的二叉树的根) 的位置
如上图,原树中的 \(siz[1]\) 实际上存在LCT中的 \(siz[3]\) 里
但是一个点子树的 \(siz\) 不仅包含自己所在的那条重链的 \(siz\) 啊 如何维护轻子树的大小?
我们记 \(lsiz[x]\) 表示LCT中 \(x\) 的所有轻子树的 \(siz\) 之和,这样 \(siz[x]\) 就等于 \(siz[lson]+siz[rson]+lsiz[x]+1\)
如果我们希望查询 \(x\) 点的子树信息,只需要access(x)
,然后此时 \(x\) 就会位于一条重链的底端,那么 \(siz[lson]\) 和 \(siz[rson]\) 都为 \(0\),\(siz[x]=lsiz[x]+1\),而此时LCT中 \(x\) 的轻子树一定一一对应着原树中 \(x\) 的子树,所以此时的 \(siz[x]\) 就是 \(x\) 在原树中的 \(siz\)
如果需要查询以 \(x\) 为根时整棵子树的信息,只需makeroot(x)
,然后直接查询
接下来的问题就在于如何维护这个 \(lsiz\) 了 来看一下代码
代码剖析
1. pushup操作写法发生变化
inline void pushup(int x) {
sum[x] = sum[ch[x][0]] + sum[ch[x][1]] + lsum[x] + val[x]; //lsum表示轻子树的权值和
}
2. access操作写法发生变化
inline void access(int x) {
for (int i = 0; x; i = x, x = fa[x]) {
splay(x);
lsum[x] += (siz[ch[x][1]] - siz[i]); //ch[x][1]变为轻儿子,而i不再是轻儿子
ch[x][1] = i; pushup(x);
}
}
3. link操作写法发生变化
inline void link(int x, int y) {
makeroot(x); makeroot(y); //x,y都要makeroot!
fa[x] = y;
lsum[y] += sum[x]; //x成为y的轻儿子
pushup(y);
}
4. 单点修改 \(x\) 时先 makeroot(x)
!
例题
3. LCT维护形态树+权值树
这类题目一般是要求维护树上路径信息,但是经常会对路径上的点权做一些奇怪的操作
如[BZOJ3159]决战:路径翻转操作
[GDSOI2017]中学生数据结构题:路径循环移位操作
思路讲解
众所周知,LCT上的Splay以原树中的节点深度为关键字,随便你怎么rotate
,只要节点的相对次序不变,那么就只有辅助树的形态会发生改变,而对应的原树形态不变
在makeroot(x)
操作中,我们翻转了以 \(x\) 为根的Splay,节点的相对次序发生了改变,所以其实在原树中就体现为原树的根变成了 \(x\)
但是在这种题里,如果你还是为了维护权值胡乱操作辅助树Splay,那说不定什么时候你就不小心把哪个点变成原树的根了。。。
所以我们要再建出一棵辅助树来维护权值,使得在这棵辅助树上进行操作一定不会影响原树形态,我们把这棵辅助树叫做权值树,而LCT那棵叫形态树
这里我选择了非旋Treap来维护权值树
现在需要解决的问题就是 我们希望形态树和权值树是时刻对应的
比如说,现在LCT被分为这样几棵Splay:\([1,2],[3,5,6],[4,7]\),那么此时权值树一定也是被这样划分为3棵的
还有一个东西也要对应,就是形态树的中序遍历序列要时刻与权值树的中序遍历序列相同,这样才能够保证形态树中某棵平衡树排名第 \(k\) 的点和权值树中对应平衡树的第 \(k\) 个点是同一个点,方便进行修改
比如说此时形态树中3棵平衡树的中序遍历分别为 \([1,2],[6,3,5],[7,4]\),那么权值树也要一样,绝不可能是 \([1,2],[5,3,6],[4,7]\)
为了实现这个功能,我们需要在LCT更改轻边重边时同样维护权值树的连边
同时为了方便查询,还需要动态维护每棵形态树中的平衡树对应着权值树中的哪一棵平衡树
用 \(rt[x]\)来维护这个信息,一定要保证形态树中每棵平衡树的树根的 \(rt[x]\) 是权值树对应的那棵平衡树的树根,这样才能正确修改和查询
代码剖析
1. 权值树该怎么写怎么写,写一个正常的平衡树就行
2. splay操作写法发生变化
inline void splay(int x) {
q[top=1] = x;
int i; for (i = x; !isroot(i); i = fa[i]) q[++top] = fa[i];
swap(rt[i], rt[x]); //x将会变成平衡树的根,把原来根的rt值给x
while (top) pushdown(q[top--]);
while (!isroot(x)) {
int y = fa[x], z = fa[y];
if (!isroot(y)) ((ch[z][1] == y) ^ (ch[y][1] == x)) ? rotate(x) : rotate(y);
rotate(x);
}
}
3. 划重点!access操作写法
inline void access(int x) {
for (int i = 0; x; i = x, x = fa[x]) {
splay(x);
if (ch[x][1]) {
VAL::split(rt[x], siz[x] - siz[ch[x][1]], rt[x], rt[ch[x][1]]);
//x的重儿子不再是ch[x][1],把它的子树从x对应的平衡树中删去
}
if (i) {
rt[x] = VAL::merge(rt[x], rt[i]);
//i成为x的重儿子,把它的子树加入x对应的平衡树
//注意merge顺序
}
ch[x][1] = i; pushup(x);
}
}
3. makeroot操作写法发生变化
inline void makeroot(int x) {
access(x); splay(x); rev(x);
VAL::Rev(rt[x]); //为了保证中序遍历相同,权值树也要翻转
}
4. 对于任何修改/查询,先split(x,y),然后在权值树的对应平衡树上修改/查询
inline void Add(int x, int y, ll v) { split(x, y); VAL::Add(rt[y], v); }
inline void Rev(int x, int y) { split(x, y); VAL::Rev(rt[y]); }
inline ll Qsum(int x, int y) { split(x, y); return VAL::sum[rt[y]]; }
inline ll Qmax(int x, int y) { split(x, y); return VAL::mx[rt[y]]; }
inline ll Qmin(int x, int y) { split(x, y); return VAL::mn[rt[y]]; }
例题
4. LCT维护边权信息
LCT怎么维护边权?LCT维护不了边权。
但是可以把边拆成点,然后就变成维护点权了(
多用于维护生成树
思路讲解
新科技:KrusLCT算法 \(O(m\log m)\) 求最小生成树!
对于一条边 \((u,v)\),新建一个点 \(w\),把 \(w\) 的点权设为边权,\(u,v\) 的点权视题目设成正无穷,负无穷,\(0\) 之类的,然后连边 \((u,w)\),\((w,v)\)
这样有什么好处呢?如果我想要查询 \(x\) 到 \(y\) 路径上的最大边权,只需要在LCT上查询 \(x\) 到 \(y\) 的最大点权就好了
所以可以口胡出一个最小生成树算法:
按顺序考虑每一条边 \(u,v\):若 \(u,v\) 不连通,则在LCT中连上 \(u,v\)
否则找出LCT上 \(u,v\) 路径上的最大点权,若这个"点"权大于当前边 \(u,v\) 的边权,就把那条边断开,连上这条边
当然这就是LCT维护边权的一个应用,其实理解起来非常简单
代码剖析
实在是没有什么好写的了 因为和普通的LCT没有什么区别,唯一的区别就是连边/删边要删两条?
1. 我到底应该删哪两条边?
//使用 map<pair<int, int> , int> !!!