LCT(link cut tree)
LCT (link cut tree)
问题引入
维护一棵森林,每个结点有权值,有四种操作。
-
查询两点之间的结点权值。
-
修改点权。
-
连接点 \(u, v\),若两点已经联通则无需连接。
-
删除边 \((u, v)\)。
分析
若没有后两个操作,很显然可以用树剖解决,现在我们需要引入一个数据结构来解决连边断边操作,那就是 link cut tree。
实链剖分
重链剖分是以子树大小对树进行剖分,而我们现在需要一种剖分能适应维护动态树问题,那就是实链剖分。对于一个点连向的所有儿子,我们自己选择一条边作为实边(其儿子称为实儿子),其他边作为虚边(其儿子称为虚儿子),对于实边构成的链,我们称作实链。而我们就是使用 splay 来维护每一条实链。能成功维护动态树问题正是因为实链的灵活多变。
性质
-
我们维护实链在 splay 中以深度为关键字,也就是说中序遍历任何实链得到的序列对应原树中从上到下连续的一条路径。
-
splay 中的结点编号与原树中结点编号一一对应。
-
各个实链对应的 splay 并不是独立的,除开根结点所在的实链以外,任意实链所对应 splay 的根(\(u\))的父亲指向该实链在原树中深度最小结点的父亲(\(v\)),但是 \(v\) 的儿子结点却不指向 \(u\),也就是 “认父不认子”。
重要函数
首先声明变量,将 splay 放入结构体维护。
struct splay
{
int fa, ch[2];
// fa 表示该节点的父亲结点,ch[0] 表示该节点的左儿子,ch[1] 表示该节点的右儿子
int val, sum;
// val 表示该结点的权值,sum 表示该节点所有子节点的权值和
bool rev; // 表示翻转懒标记
} t[N];
接下来是 splay 的一些基本操作。
inline void upd(int p)
{ // 更新 p 结点的 sum
t[p].sum = t[t[p].ch[0]].sum ^ t[t[p].ch[1]].sum ^ t[p].val;
}
inline void pushdown(int p)
{
if (t[p].rev)
{ // 下放翻转标记
swap(t[p].ch[0], t[p].ch[1]);
t[t[p].ch[0]].rev ^= 1;
t[t[p].ch[1]].rev ^= 1;
t[p].rev = 0;
}
}
inline bool is_top(int p)
{ // 判断 p 是否是当前 splay 的根结点
return t[t[p].fa].ch[0] != p &&
t[t[p].fa].ch[1] != p;
}
inline int dir(int p)
{ // 判断 p 是其父亲的左/右结点
return t[t[p].fa].ch[1] == p;
}
inline void rotate(int p)
{ // 旋转 p 到其父亲
int y = t[p].fa, z = t[y].fa, k = dir(p);
t[p].fa = z; if (is_top(y) == 0) t[z].ch[dir(y)] = p;
// 此处是与普通 splay 的不同之处
// 每次 rotate 只能修改 p 所在 splay 的形态,对于所在 splay 根结点的父亲 z 我们则不能修改其儿子指向
// 也就是说当 y 是该 splay 的根结点时 y 的父亲结点 z 不能修改其儿子指向
t[y].ch[k] = t[p].ch[k ^ 1], t[t[p].ch[k ^ 1]].fa = y;
t[p].ch[k ^ 1] = y, t[y].fa = p;
upd(y); upd(p); // 注意此时 y 是 p 的儿子,需要先更新 y 再更新 p
}
inline void splay(int p)
{
int x = p; S.push(p);
for (; ! is_top(p); p = t[p].fa)
S.push(t[p].fa); // 将旋转会经过的点记录下来
while (S.size())
{ // 考虑到标记需要从上到下 pushdown,于是我们需要用栈记录,后入栈的先出栈,即深度小的先 pushdown
pushdown(S.top());
S.pop();
}
for (; ! is_top(x); rotate(x))
{ // 将 p 旋转到其 splay 的根结点上
if (! is_top(t[x].fa))
rotate(dir(x) == dir(t[x].fa) ? t[x].fa : x);
}
}
接下来是 LCT 最重要的函数:\(\operatorname{access}(p)\)
它表示将 \(p\) 到原树中根结点(下文称为 \(root\))的路径拉成实链,即将 \(p\) 到原树中根结点的路径放入同一颗 splay。
inline void access(int p)
{
int lst = 0;
for (; p; lst = p, p = t[p].fa)
{
splay(p); // 首先,将 p 旋到其 splay 的根结点上
t[p].ch[1] = lst; // 将 p 的右儿子删去 并 将自己的右儿子指向上一颗 splay 的根
upd(p); // 更新其子树信息
}
}
什么意思呢?就是说旋转到当前 splay 的根结点之后的 \(p\) 的右儿子一定是深度大于 \(p\) 的结点(splay 就是根据深度维护这些实链的),这些结点一定不在 \(p\) 到 \(root\) 的路径上,也就是说 \(p\) 的右儿子与 \(p\) 不在同一颗 splay 上(不在同一实链),删去 \(p\) 的右儿子(但是 \(p\) 的右儿子仍然认 \(p\) 这个父亲,也就是照样满足性质 3)即可。接下来将 \(p\) 跳其父亲 \(fa_p\) 上,也对应着跳到了另一颗 splay 上,此时 \(fa_p\) 一定在 \(p\) 到 \(root\) 的路径上,同样的,将 \(fa_p\) 旋转到其 splay 的根结点上,将 \(fa_p\) 的右儿子删去,再将右儿子指向 \(p\)(因为 \(p\) 与 \(fa_p\) 在同一 splay 上),\(fa_p\) 再次跳其父亲结点 ....
我们发现不断将 \(p\) splay 后跳父亲结点直到跳到 \(root\) 上也就完成了 \(\operatorname{access}\) 的操作。
inline int find_root(int p)
{ // 找到 p 所在原树的 root
access(p); splay(p);
// 将 root 到 p 的路径拉到同一条实链(splay)上
// 将 p 旋转到其 splay 的根结点
while (t[p].ch[0])
{
pushdown(p); // 下传 p 的标记
p = t[p].ch[0]; // root 深度一定最小,不断跳左儿子即可找到 root
}
splay(p); // 为了保证复杂度,最后将 root 旋到其 splay 的根结点上
return p; // 返回 root
}
inline void make_root(int p)
{ // 将 p 为 p 所在原树的 root
access(p); splay(p); t[p].rev ^= 1;
// 将 root 到 p 的路径拉到同一条实链(splay)上
// 将 p 旋转到其 splay 的根结点
// 将一整条链进行翻转,即在 p 上打翻转标记
// 此时 p 成了深度最小的点,也就成为了原树的 root
}
inline void link(int u, int v)
{ // 将 (u, v) 连接
make_root(u); // 将 u 作为 所在原树的 root
if (find_root(v) == u) return; // root 相同代表在同一颗树上,不必进行此操作了
t[u].fa = v; // 将 u 连到 v 上(但是 v 的儿子结点不指向 u,所以不必进行 upd 操作)
}
inline void cut(int u, int v)
{ // 删除边 (u, v)
make_root(u); // 将 u 作为所在原树的 root
if (find_root(v) != u || t[v].fa != u || t[v].ch[0]) return;
// 若 u, v 不直接相连则不必进行此操作
// 判断的原理是:
// 1. 两点不在同一颗树上(root 不同)
// 2. v 在 splay 上的父亲不是 u(深度不止差 1,表明在原树中 (u, v) 一定不直接相连)
// 3. v 在 splay 上拥有左儿子(理由同上)
t[v].fa = 0; t[u].ch[1] = 0; // 删除边 (u, v)
upd(u); // u 的儿子被删除,更新其信息
}
inline void change(int u, int val)
{ // 将 u 的点权修改为 val
splay(u); // 将 u 旋转到其 splay 的根结点上
t[u].val = val; // 此时修改权值不会影响它父亲以上的结点(父亲不认它)
upd(u); // 更新权值
}
inline void split(int u, int v)
{ // 将 u 到 v 的路径提取出来,并将 v 作为该路径的根结点
make_root(u); access(v); splay(v);
// 将 u 作为所在原树的 root
// 将 u 到 v 的路径拉到同一条实链(splay)上
// 将 v 旋到该 splay 的根结点上
}
拥有以上操作我们就可以进行删边加边操作。以上操作的平摊时间复杂度均为 \(\mathcal O(\log n)\) 。