[学习笔记] LCT


1. 模板

一些注意的要点都加在代码里了,博主 \(\rm chinglish\) 一直可以,所以语法错误请鳖吐槽(

#include 
#define print(x,y) write(x),putchar(y)

template 
inline T read(const T sample) {
	T x=0; char s; bool f=0;
	while((s=getchar())>'9' || s<'0')
		f |= (s=='-');
	while(s>='0' && s<='9')
		x = (x<<1)+(x<<3)+(s^48),
		s = getchar();
	return f?-x:x;
}

template 
inline void write(T x) {
	static int writ[50],w_tp=0;
	if(x<0) putchar('-'),x=-x;
	do writ[++w_tp]=x-x/10*10,x/=10; while(x);
	while(putchar(writ[w_tp--]^48),w_tp);
}

#include 
using namespace std;

const int maxn = 1e5+5;

int n,m;
struct node {
	bool tag;
	int s,v,son[2],fa;
} t[maxn];

int dir(int o) {
	return (t[t[o].fa].son[1] == o)?1:
		   (t[t[o].fa].son[0] == o)?0:-1;
}

bool isRoot(int o) { 
	return (t[t[o].fa].son[0]^o) &&
		   (t[t[o].fa].son[1]^o);
	/* it can be replaced by dir() */
}

void add(int o,int f,int d) {
	if(o) t[o].fa=f;
	if(~d && f) t[f].son[d]=o;
}

void printNode(int o) {
	int lc=t[o].son[0], rc=t[o].son[1];
	printf("node %d son(%d %d) fa(%d) tag(%d) %d\n",o,lc,rc,t[o].fa,t[o].tag,t[o].s);
}

void pushUp(int o) {
	if(!o) return;
	int lc=t[o].son[0], rc=t[o].son[1];
	t[o].s = t[lc].s^t[rc].s^t[o].v;
}

void rev(int o) {
	if(!o) return;
	swap(t[o].son[0],t[o].son[1]);
	t[o].tag ^= 1;
}

void pushDown(int o) {
	if(!o || !t[o].tag) return;
	rev(t[o].son[0]), rev(t[o].son[1]);
	t[o].tag=0;
}

void rotate(int o) {
	int f=t[o].fa, ff=t[f].fa;
	int d=dir(o), fd=dir(f);
	add(t[o].son[d^1],f,d);
	add(f,o,d^1);
	add(o,ff,fd);
	pushUp(f), pushUp(o);
}

/*
	为啥必须在 splay() 中先从上到下 pushDown() 呢?
	朴素 splay 中,实际不需要在 rotate()/splay() 中 pushDown(),这是因为 find() 已经将所有的标记下放
	朴素 splay 中,对于某个子树,如果暂时没有翻转(假设祖先有翻转标记),而对其进行操作,实际上也是正确的
	但对于 LCT,在操作子树时,必须时时保证左右儿子的信息正确
*/

void pushAll(int o) {
	static int stk[maxn], tp;
	stk[tp=1] = o;
	while(!isRoot(o)) stk[++tp] = (o=t[o].fa);
	while(tp) pushDown(stk[tp--]);
}

void splay(int o) {
	pushAll(o); 
	for(int f; f=t[o].fa, !isRoot(o); rotate(o))
		if(!isRoot(f)) rotate(dir(f)==dir(o)?f:o);
	// pushUp(o); // is it necessary??
}

void access(int o) {
	for(int ch=0; o; o=t[ch=o].fa)
		splay(o), t[o].son[1]=ch, pushUp(o); 
	/* splay o to guarantee that the dfs order of u(the original value of o) is the biggest */
}

void makeRoot(int o) {
	access(o); splay(o);
	rev(o); // reverse the DFS order of the splay that o be at
	/* but do not change the others */
	/* because the edges between them are virtual, the tags won't be pushdown */
}

int findRoot(int o) {
	access(o); splay(o);
	while(t[o].son[0]) o=t[o].son[0];
	splay(o); // splay the real root to the aux-root
	return o;
}

/* after access(), x might not be the aux-root */
/* so we just splay y to the aux-root */

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

void link(int x,int y) {
	makeRoot(x);
	if(findRoot(y)^x) t[x].fa=y;
}

void cut(int x,int y) {
	makeRoot(x);
	if(findRoot(y)==x && t[y].fa==x && !t[y].son[0])
		t[y].fa=t[x].son[1]=0, pushUp(x);
}

int main() {
	n=read(9), m=read(9);
	for(int i=1;i<=n;++i)
		t[i].s=t[i].v=read(9);
	int opt,x,y;
	while(m --) {
		opt=read(9);
		x=read(9), y=read(9);
		if(!opt) split(x,y), print(t[y].s,'\n');
		if(opt==1) link(x,y);
		if(opt==2) cut(x,y);
		if(opt==3) splay(x), t[x].s=t[x].v=y;
	}
	return 0;
}

这里再说明一下为什么 findRoot()while() 中不需要 pushDown(),其实也就是为啥根一定在左子树:考虑在 access() 操作中,假设在原树上从 \(o\) 到当前原树的根的链为 \(p\),那么实际形成了从 \(o\) 到当前原树的根的一条相对于 \(p\) 并不连续 的链 \(q\)(不连续是因为有些存在于 \(p\) 的节点在 \(q\) 上节点的左子树中,且注意 \(q\) 是由 "父亲-右儿子" 关系拼接而成的链)。那么在之后的 splay() 中,\(o\) 会沿着链 \(q\) 向上旋,这就相当于 直线型双旋,所以将当前原树的根旋下去的时候,一定在 \(o\) 的左子树,且应该离得很近。

另外,对于判断连通性,如果只有加边没有删边可以考虑直接用并查集维护,因为 findRoot() 有点慢。

2. 例题

2.1. 维护边权

例 1. \(\text{Query on a Tree}\)

转化边权经典题。具体就是为每条边建一个虚点 \(t\),将 \(u,v\) 接在 \(t\) 两侧。查询类似于 \(\rm splay\),就像下面这样:

void query(int x,int y) {
	makeRoot(x); access(y);
	splay(x), splay(y,x);
	print(t[t[y].son[0]].s,'\n');
}

\(x\) 为整棵树的根,那么 \(x\)\(y\) 之间的路径就是辅助树上 \(\rm dfs\) 序小于 \(y\) 的点,那么将 \(y\) 转到 \(x\) 的儿子,选取 \(y\) 的左子树即为答案。需要注意在 access(y) 之后,如果 splay(y), splay(x) 并不一定保证 \(y\) 变成 \(x\) 的右儿子,因为 \(x\) 旋成根的最后一步可能是直线型双旋。

不过事实上这题的查询根本不需要这么麻烦,在 access(y) 之后直接查询 \(\rm splay\) 即可,因为 \(x,y\) 的点权对答案没有影响。

例 2. \(\text{[NOI 2014] }\)魔法森林

对于这种 "满足 \(a\) 且满足 \(b\)" 的限制应该都可以转化为将 \(a\) 有序化再找 \(b\) 的最值。

2.2. 维护树链信息

例 1. \(\text{[HNOI 2010] }\)弹飞绵羊

弹力值可以抽象成一条边,且容易发现这些边构成的图形成树形结构。对于此题,由于不需要求取任意两点之间的路径,而只用计算 \(x\) 到原树根的一条链,所以根本不用换根。

例 2. \(\text{[SHOI 2014] }\)三叉神经树

由于每个节点都有三个儿子(不算叶子节点),所以我们可以确定有 \(1/2\)\(1\) 儿子的节点状态是特殊的。举个例子,当修改叶子节点 \(u\)\(0\) 时,其必只能影响 \(u\) 向上到第一个不为 \(2\)\(1\) 儿子的节点 \(p\)(不包含此节点)的一条链的 传出信息 与状态,再影响 \(p\) 的状态。

现在我们的问题就变成了快速求取点 \(p\). 维护 \(x_{0/1}\) 表示深度最深的状态不为 \(1/2\) 的节点,这个可以上树剖,是两只 \(\log\) 的,也可以用 \(\rm lct\) 砍一只 \(\log\). 而且由于不用换根,代码变得短小精悍(

维护时有个需要注意的点:修改一条链的状态显然用 \(\rm lazy\) 标记实现,可怎么维护 \(x_{0/1}\) 呢?事实上,直接在 add() 中将它们 swap() 一下即可。首先的 \(\rm observation\) 是修改的链(也可以看成一棵 \(\rm splay\))的状态是相同的,所以 \(x_{0/1}\) 显然只有一个有值。其次就举个例子,状态均为 \(1\) 的子树的修改一定是 \(+1\),所以 \(x_0\leftarrow x_1\).

所以实际上这道题可以拓展到 \(k\) 叉,只要保证非叶子节点的儿子数量相同,特殊状态确定即可。

例 3. \(\text{[ZJOI 2011] }\)道馆之战

数据结构写起来就是爽啊。题目细节:\(\rm Link.\)

考虑询问就是树上取一条链 \((x,y)\),询问 \(x\) 走到 \(y\) 最多的冰块。因为是静态的,所以树剖和 \(\rm lct\) 都可以做。考虑 \(\rm lct\)

  • \(\text{lx}_{o,0/1}\):点 \(o\) 统治的子树 \(\rm dfs\) 序最小的点从 \(0/1\) 房间开始的最大冰块数;
  • \(\text{rx}_{o,0/1}\):点 \(o\) 统治的子树 \(\rm dfs\) 序最大的点从 \(0/1\) 房间开始的最大冰块数;
  • \(\text{d}_{o,0/1,0/1}\):点 \(o\) 统治的子树 \(\rm dfs\) 序最小的点从 \(0/1\) 房间开始到 \(\rm dfs\) 序最大的点的 \(0/1\) 房间的最大冰块数。

例 4. 动态维护 \(\rm lca\)

混入其中

首先将指定根 makeRoot() 一下,然后对于查询 \((x,y)\),将 \(x\)access(),此时根节点所处 \(\rm splay\) 就是 \(x\) 到根的路径,再对 \(y\) 进行 access(),记录下 \(o\) 变成零之前的最后一个数值(可以查看代码了解 \(o\) 的定义)。

2.3. 维护边双连通分量

例 1. \(\text{[AHOI 2005] }\)航线规划

"关键航线" 可以转化为,将原图的边双缩点形成新图(容易发现这是一棵树),新图上 \(u,v\) 的边双形成的链的点个数减一。

于是逆序加边,当形成环时缩点,用 \(\rm lct\) 维护即可。正确性就考虑被缩的一坨点实际上就是一棵 \(\rm splay\),它们向下都没有儿子指向,只有虚儿子指向它们。

另外由于题目保证了一个性质:无论航线如何被破坏,任意时刻任意两个星球都能够相互到达。所以这道题也可以用树剖做。先跑出最后剩下的图的一棵生成树进行树剖,还是逆序加边,将每条边形成的环上的边权(本来为 \(1\))赋值成零即可。

2.4. 维护子树信息

例 1. \(\text{[BJOI 2014] }\)大融合

也就是说每个点还需要维护虚儿子的信息,这个信息需要满足 可加性。大概在 access()link() 中维护。

例 2. \(\text{P4299 }\)首都

容易发现首都即为树的重心,一个很好的性质是将两棵子树合并成的新子树的重心一定在原来两个重心之间的路径上,所以可以考虑提出原重心之间的路径,二分出新的重心。代码长这样:

int getThat(int o) {
	bool isOdd = (t[o].s&1);
	int havi=1e9, ls, rs, adl=0, adr=0, mid=(t[o].s>>1);
	while(o) {
		pushDown(o);
		int lc=t[o].son[0], rc=t[o].son[1];
		ls = t[lc].s+adl, rs = t[rc].s+adr;
		if(ls<=mid && rs<=mid) {
			if(isOdd) { havi=o; break; }
			else havi = min(havi,o);
		}
		if(ls

最后一个需要注意的点是 link() 操作中不能只 makeRoot(x) 再直接接到 \(y\) 上,因为我们需要更新 \(y\),所以还要将 \(y\) 旋到 \(\rm splay\) 的根。

\(\text{To be continued...}\)