LCT 学习笔记

\(\text{LCT 学习笔记}\)

简介

正在学习省选知识的你一定见过这样一类问题:给定一棵树,支持动态修改点权与树上路径区间查询。这类问题用树链剖分是可以容易维护的。然而如果我们需要维护的是一个森林并支持动态加删边操作,就不得不使用 LCT 来处理这一问题了,我们一般称其为动态树问题。

Link/Cut Tree 是一种数据结构,我们用它来解决 动态树问题

Link/Cut Tree 又称 Link-Cut Tree,简称 LCT,但它不叫动态树,动态树是指一类问题。

实质

考虑树链剖分复杂度正确的实质是将一棵树剖成了一些个轻重链,用线段树维护这些个链的信息来保证复杂度的正确性。对于动态树问题,我们希望同样维护一些个链来保证时间复杂度的正确,但由于需要支持连删边的操作,我们希望维护的这些个实链是指定且可以灵活变化的。我们选择用 Splay 来维护这些个实链。

辅助树

在 LCT 中,每棵 Splay 维护一个实链,各个 Splay 间有边相连,维护着整棵树上的信息。

需要知道的是辅助树有如下性质:

  • 辅助树有多棵 Splay 组成,每个 Splay 维护原树中的一条路径,且中序遍历这棵 Splay 得到的点序列,从前到后对应原树从上到下的一条路径。

  • 原树每个节点和辅助树一一对应。

  • 辅助树中各个 Splay 之间有边相连,每棵 Splay 的根节点的父亲并非为空,而是指向原树中该 Splay 维护的实链的父亲节点,而这个父亲节点并不会有该 Splay 根节点这一儿子。我们认为 Splay 树上的边都是实边,而每棵 Splay 根节点单向指向父亲的边称作虚边。换句话说,虚边有认父不认子的性质。

例如,对于这棵原树:

LCT1

其辅助树可以为:

LCT2

那么容易发现辅助树与原树的关系:

  • 原树的每条实链与每个 Splay 一一对应。
  • 每个 Splay 显然可以容易地实现换根操作。
  • 那么虚实链的变换就可以在辅助树上完成,具体的操作会在下文中阐述。

Splay 基本操作

get

作用是判断当前节点是父亲的哪个儿子。

int get(int p) {
	return rc(fa(p)) == p;
}

isrt

判断当前节点是不是根。

int isrt(int p) {
	return lc(fa(p)) != p && rc(fa(p)) != p;
}

push_up/push_down

LCT 中需要实现区间反转,因此需要下方懒标记的操作。这里只给出 push_down 的代码:

void tag_down(int p) {
    swap(lc(p), rc(p));
    tg(p) ^= 1;
}
void push_down(int p) {
    if (tg(p)) {
        tag_down(lc(p));
        tag_down(rc(p));
        tg(p) = 0;
    }
}

update

作用是在 Splay 操作前将当前点到根节点路径上的懒标记全部下放以保证当前维护信息的正确。

void update(int p) {
    if (!isrt(p)) update(fa(p));
    push_down(p);
}

rotate

和 Splay 函数中的操作相似。需要留意的是,对于当前节点是否为根节点的判断需要使用 isroot 函数。

void rotate(int p) {
    int y = fa(p), z = fa(y), c = get(p);
    if (!isrt(y)) e[z].son[get(y)] = p;
    fa(e[p].son[c ^ 1]) = y;
    e[y].son[c] = e[p].son[c ^ 1];
    e[p].son[c ^ 1] = y;
    fa(y) = p;
    fa(p) = z;
    push_up(y);
    push_up(p);		
}

Splay

和正常的 Splay 操作是相似的。

void splay(int p) {
    update(p);
    int f = fa(p);
    while (!isrt(p)) {
        if (!isrt(f)) rotate(get(p) == get(f) ? f : p);
        rotate(p);
        f = fa(p);
    }
}

LCT 基本操作

access

这个操作比较重要。对于 access(x),用处是将 \(x\) 到根节点的路径上所有的边改为实边,并把与这些边相邻的所有边改为虚边。换句话说就是将 \(x\) 到根节点的 Splay 提出来。

对于这样一棵树:

LCT1

其辅助树可以为:

LCT1

那么我们希望的结果是:

LCT1

由于我们要由下到上来更新,那么我们首先要把点 \(N\) 旋转到 Splay 的根。旋转后由于要把与这些边相邻的所有边改为虚边,因而要把原先的实儿子改为虚儿子,那么实儿子显然是 \(N\) 此时的后继,也就是右儿子。那么将 rc(N) 改为空即可。于是辅助树变为:

oi-wiki.org/ds/images/lct-access-4.svg

同理继续操作即可。因此 access 操作只有以下五步:

  1. \(x\stackrel{\text{Splay}}{\longrightarrow} \text{rt}\)

  2. \(\text{lc}(x)=t\)

  3. 更新当前 \(x\) 的信息。

  4. \(t=x\)

  5. \(x=\text{fa}(x)\)

代码:

void access(int p) {
	int x = 0;
	while (p) {
		splay(p);
		rc(p) = x;
		push_up(p);
		x = p;
		p = fa(p);
	}
}

mkrt

考虑这样一件事:当我们需要查询一条路径上的信息时,要希望这条路径上的所有点都在一个 Splay 内。mkrt 操作就是使点 \(x\) 变为原树的根,这样方便我们进行下一步操作。

首先我们显然需要 access(x) 以使 \(x\) 与根在同一 Splay 内。如果我们直接将根暴力赋为 \(x\),显然此时 \(x\) 到根节点的路径在 Splay 中被反向了,那么将 \(x\) 换到根节点后对 Splay 打上反转标记即可。

void mkrt(int p) {
	access(p);
	splay(p);
	tag_down(p);
}

find

作用是找出 \(x\) 节点所在原树的根节点。那么我们把 \(x\) 这棵 Splay 提出来后显然这个树的根节点就是整个树中最靠左的点了。

int fnd(int p) {
    access(p);
    splay(p);
    while (lc(p)) {
        push_down(p);
        p = lc(p);
    }
    splay(p); // 是为了保证复杂度
    return p;
}

split

split(x, y) 的作用是将 \(x\to y\) 的路径这棵 Splay 提出来。那显然是容易的。

void split(int x, int y) {
	mkrt(x);
	access(y);
	splay(y); //将 y 赋为当前树的根
}

就是将 \(x,y\) 连边,那么不妨让 \(x\)\(y\) 的虚儿子。

void link(int x, int y) {
	mkrt(x);
	if (fnd(y) != x) fa(x) = y;
}

cut

就是将 \(x,y\) 删边,那么先 mkrt(x),对于 \(y\) 合法性的检验,进行 access(y)splay(x) 操作后显然如果 \(x,y\) 间有边,一定是实边。又因为 \(x\) 在原树和辅助树里都是根,那么 \(y\) 一定是 \(x\) 的右儿子并且 \(x\) 没有左儿子。

void cut(int x, int y) {
	mkrt(x);
	access(y);
	splay(x);
	fa(y) = rc(x) = 0;
	push_up(x);
}

这里给出 LCT 的完整模板代码:

#include <bits/stdc++.h>
#define N 200005
using namespace std;
int n, m;
struct Node {
	int son[2];
	int fa, sm, tg, vl;
} e[N];
#define lc(i) e[i].son[0]
#define rc(i) e[i].son[1]
#define fa(i) e[i].fa
#define sm(i) e[i].sm
#define tg(i) e[i].tg
#define vl(i) e[i].vl
int tot;
void nw(int x) {
	++tot;
	vl(tot) = sm(tot) = x;
}
int get(int p) {
	return rc(fa(p)) == p;
}
int isrt(int p) {
	return lc(fa(p)) != p && rc(fa(p)) != p;
}
void push_up(int p) {
	sm(p) = sm(lc(p)) ^ sm(rc(p)) ^ vl(p);
}
void tag_down(int p) {
	swap(lc(p), rc(p));
	tg(p) ^= 1;
}
void push_down(int p) {
	if (tg(p)) {
		tag_down(lc(p)), tag_down(rc(p));
		tg(p) = 0;
	}
}
void update(int p) {
	if (!isrt(p)) update(fa(p));
	push_down(p);
}
void rotate(int p) {
	int y = fa(p), z = fa(y), c = get(p);
	if (!isrt(y)) e[z].son[get(y)] = p;
	fa(e[p].son[c ^ 1]) = y;
	e[y].son[c] = e[p].son[c ^ 1];
	e[p].son[c ^ 1] = y;
	fa(y) = p;
	fa(p) = z;
	push_up(y);
	push_up(p);
}
void splay(int p) {
	update(p);
	int f = fa(p);
	while (!isrt(p)) {
		if (!isrt(f)) rotate(get(f) == get(p) ? f : p);
		rotate(p);
		f = fa(p);
	}
}
void access(int p) {
	int x = 0;
	while (p) {
		splay(p);
		rc(p) = x;
		push_up(p);
		x = p;
		p = fa(p);
	}
}
void mkrt(int p) {
	access(p);
	splay(p);
	tag_down(p);
}
int fnd(int p) {
	access(p);
	splay(p);
	while (lc(p)) {
		push_down(p);
		p = lc(p);
	}
	splay(p);
	return p;
}
void split(int x, int y) {
	mkrt(x);
	access(y);
	splay(y);
}
void link(int x, int y) {
	mkrt(x);
	if (fnd(y) != x) fa(x) = y;
}
void cut(int x, int y) {
	mkrt(x);
	access(y);
	splay(x);
	if (fa(y) == x && !lc(y)) fa(y) = rc(x) = 0;
	push_up(x);
}

int main() {
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		int x;
		cin >> x;
		nw(x);
	}
	while (m--) {
		int o, x, y;
		cin >> o >> x >> y;
		if (o == 0) {
			split(x, y);
			cout << sm(y) << '\n';
		}
		else if (o == 1) link(x, y);
		else if (o == 2) cut(x, y);
		else {
			splay(x);
			vl(x) = y;
			push_up(x);
		}
	}
	return 0;
}

需要留意的一点是:LCT 维护的是点权信息,在维护边权信息时通常把一条边看作一个 "虚点" 去维护即可。

posted @ 2025-04-06 21:51  长安19路  阅读(48)  评论(0)    收藏  举报