链剖分总结

来解决树上DS问题。

因为没有能够直接高效维护树型结构的DS,于是把树剖分成链,然后拿序列上的DS去维护每一条链的信息。

树链剖分有很多种:轻重链剖分,长链剖分,虚实链剖分。

轻重链剖分

这里是轻重链剖分。常数很小

其实不一定要用线段树维护,但用线段树维护是最常见的。

支持换根,路径修改,路径查询,子树修改,子树查询,查两点LCA。

钦定子树大小最大的儿子为当前节点\(u\)的重儿子\(son_u\),其他儿子为轻儿子,连向重儿子的边称为重边,连向轻儿子的边称为轻边,重边连起来成为重链。

一些性质:

  • 树上每个节点都属于且仅属于一条重链。

  • 重链链顶一定不是重儿子,显然的。

  • 剖分时重边优先遍历,那么一条重链中的\(dfn\)是连续的。\(dfn\)自身也有性质,一棵子树内的\(dfn\)是连续的。

  • 任意一个节点到根节点的路径上最多经过\(\log n\)条轻边和重链,那么树上任意一条路径都可以被拆成\(\log n\)条轻边和重链。

维护路径信息

由于链上的\(dfn\)连续,那么就是维护区间信息,再让\(u,v\)两点不断往上跳。

用线段树一次是\(O(\log^2 n)\)的。

维护子树信息

子树内\(dfn\)连续,那么就是维护区间信息。

用线段树一次是\(O(\log n)\)的。

求LCA

两点不断沿着重链往上跳,当跳到同一条重链上时,深度较浅的是LCA。

向上跳重链时先跳重链顶端深度较深的。

\(O(\log n)\)的。

换根

换根后的路径与子树操作

luogu 遥远的国度

支持换根,路径赋值,查子树最小值。

\(Sol\)

注意到路径赋值其实和换根没什么关系,直接做就行。

换根肯定不能真换根,不然会炸。

考虑换根后新根和当前查询的点\(x\)的关系,大力分讨:

  • 新根是\(x\),那么输出整棵树的答案。

  • 新根在原树中不在\(x\)的子树内,那么\(x\)的子树答案不变。

  • 新根在原树中在\(x\)的子树内,设新根在原树中\(x\)的儿子\(y\)的子树内,那么答案就是整棵树扣掉\(y\)的子树的答案。

如何求\(y\)?若新根不断向上跳重链可以跳到和\(x\)在同一条重链上,且仍在\(x\)的子树内,那么\(y=son_x\)。同时\(y\)也可能是\(x\)的轻儿子,此时\(y\)是一条重链的链顶,所以跳的时候不断判断当前所在重链的链顶的父亲是否是\(x\)即可。

代码
#include<bits/stdc++.h>

using namespace std;
typedef long long ll;

const int maxn=1e5+10;
const ll inf=0x7fffffff;
int n,m,tot=0,cnt=0,rt,head[maxn],id[maxn],dep[maxn],fa[maxn],son[maxn],tp[maxn],siz[maxn];
ll w[maxn],wt[maxn];
struct edge{
	int v,nxt;
}e[maxn<<1];

inline void add(int u,int v){
	e[++tot].v=v;
	e[tot].nxt=head[u];
	head[u]=tot;
}

struct TREE{
	ll mi,tag;
}t[maxn<<1];

#define ls(k) k<<1
#define rs(k) k<<1|1

inline ll min(ll x,ll y){
	return x<y?x:y;
}

inline void pushup(int k){
	t[k].mi=min(t[ls(k)].mi,t[rs(k)].mi);
}

inline void pushdown(int k){
	if(t[k].tag){
		t[ls(k)].tag=t[rs(k)].tag=t[k].tag;
		t[ls(k)].mi=t[rs(k)].mi=t[k].tag;
		t[k].tag=0;
	}
}

void build(int k,int l,int r){
	if(l==r){
		t[k].mi=wt[l];
		return;
	}
	int mid=(l+r)>>1;
	build(ls(k),l,mid);
	build(rs(k),mid+1,r);
	pushup(k);
	return;
}

void update(int k,int l,int r,int ql,int qr,int v){
	if(ql<=l&&r<=qr){
		t[k].tag=t[k].mi=v;
		return;
	}
	pushdown(k);
	int mid=(l+r)>>1;
	if(ql<=mid) update(ls(k),l,mid,ql,qr,v);
	if(mid<qr) update(rs(k),mid+1,r,ql,qr,v);
	pushup(k);
	return;
}

ll query(int k,int l,int r,int ql,int qr){
	if(ql<=l&&r<=qr) return t[k].mi;
	pushdown(k);
	int mid=(l+r)>>1;
	ll res=inf;
	if(ql<=mid) res=min(res,query(ls(k),l,mid,ql,qr));
	if(mid<qr) res=min(res,query(rs(k),mid+1,r,ql,qr));
	pushup(k);
	return res;
}

void dfs1(int u,int f){
	fa[u]=f;
	dep[u]=dep[f]+1;
	son[u]=-1;
	siz[u]=1;
	for(int i=head[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==f) continue;
		dfs1(v,u);
		siz[u]+=siz[v];
		if(son[u]==-1||siz[son[u]]<siz[v]) son[u]=v;
	}
}

void dfs2(int u,int p){
	tp[u]=p;
	id[u]=++cnt;
	wt[id[u]]=w[u];
	if(son[u]==-1) return;
	dfs2(son[u],p);
	for(int i=head[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==fa[u]||v==son[u]) continue;
		dfs2(v,v); 
	}
}

void updline(int u,int v,int c){
	while(tp[u]!=tp[v]){
		if(dep[tp[u]]<dep[tp[v]]) swap(u,v);
		update(1,1,n,id[tp[u]],id[u],c);
		u=fa[tp[u]];
	}
	if(dep[u]>dep[v]) swap(u,v);
	update(1,1,n,id[u],id[v],c);
}

int findson(int u,int v){
	while(tp[u]!=tp[v]){
		if(dep[tp[u]]<dep[tp[v]]) swap(u,v);
		if(fa[tp[u]]==v) return tp[u];
		u=fa[tp[u]];
	}
	if(dep[u]>dep[v]) swap(u,v);
	return son[u];
}

ll qsubt(int u){
	if(u==rt) return query(1,1,n,1,n);
	if(id[u]>id[rt]||id[rt]>id[u]+siz[u]-1)  return query(1,1,n,id[u],id[u]+siz[u]-1);
	int sn=findson(rt,u);
	ll res=min(query(1,1,n,1,id[sn]-1),id[sn]+siz[sn]<=n?query(1,1,n,id[sn]+siz[sn],n):inf);
	return res;	
}

int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<n;++i){
		int u,v;
		scanf("%d%d",&u,&v);
		add(u,v);
		add(v,u);
	}
	for(int i=1;i<=n;++i) scanf("%d",&w[i]);
	scanf("%d",&rt);
	dfs1(1,0);
	dfs2(1,1);
	build(1,1,n);
	for(int i=1;i<=m;++i){
		int opt;
		scanf("%d",&opt);
		if(opt==1){
			int tmp;
			scanf("%d",&tmp);
			rt=tmp;
		}
		else if(opt==2){
			int x,y,v;
			scanf("%d%d%d",&x,&y,&v);
			updline(x,y,v);
		}
		else if(opt==3){
			int u;
			scanf("%d",&u);
			printf("%lld\n",qsubt(u));
		}
	}
	return 0;
}

换根后的LCA

CF Jimie and tree

主要就是求换根后的LCA。

以下记\(lca(u,v)\)表示原树上的LCA,\(LCA(u,v)\)表示换根后的LCA,\(rt\)表示新根。

换根后求\(x,y\)的LCA需要大力分讨(以下默认\(dep_x\le dep_y\)):

第一类:\(lca(x,y)=x\)时。即\(x,y\)在原树上时祖先后代关系。

  1. \(rt\)\(x\)的子树内也在\(y\)的子树内,那么\(LCA(x,y)=y\)

  2. \(rt\)\(x\)的子树内却不在\(y\)的子树内,那么\(LCA(x,y)=lca(y,rt)\)

  3. \(rt\)不在\(x\)的子树内,那么\(LCA(x,y)=x\)

第二类:\(lca(x,y)\ne x\)。即\(x,y\)在原树上在不同的子树中。

  1. \(rt\)\(x\)的子树内,那么\(LCA(x,y)=x\)\(rt\)\(y\)的子树内,那么\(LCA(x,y)=y\)

  2. \(rt\)\(x\)\(y\)的简单路径上,那么\(LCA(x,y)=rt\)。判断条件为\((lca(x,rt)=rt \&\&lca(y,rt)=lca(x,y))||(lca(y,rt)=rt\&\&lca(x,rt)=lca(x,y))\),另一种用\(dep\)判断的写法是\((lca(x,rt)=rt\&\&dep_{rt}\ge dep_{lca(x,y)})||(lca(y,rt)=rt\&\&dep_{rt}\ge dep_{lca(x,y)})\)

  3. \(rt\)\(lca(x,y)\)上方,那么\(LCA(x,y)=lca(x,y)\)。判断为\(lca(x,rt)=lca(y,rt)\)

  4. \(rt\)\(x\)\(y\)的路径上的点\(u\)子树中,那么\(LCA(x,y)=u\)\(lca(x,rt)=lca(x,y)\)时,\(u=lca(y,rt)\)\(lca(y,rt)=lca(x,y)\)时,\(u=lca(x,rt)\)

分讨,爽!!!

重链上二分

长链剖分

长剖就是将子树高度最大的儿子作为重儿子来剖分。

性质有:

  1. 一条根链上轻边条数为\(O(\sqrt n)\)的。这是因为从一条链跳到另一条链上,所在链的长度必然更长。于是坏情况就是经过的链长度从\(1\)\(\sqrt n\),同时经过\(\sqrt n\)条轻边。

  2. 一个点的\(k\)级祖先所在长链的长度必然\(\ge k\)。容易反证。

优化 DP

可以使用指针/vector/一些手段 \(O(1)\) 继承重儿子的信息,然后暴力合并轻儿子信息。因为每个长链只会在链顶作为轻儿子合并一次,合并的信息与深度有关,是 \(O(\text{深度})\) 的,所以均摊每个点花费 \(O(1)\) 的代价,于是总复杂度是 \(O(n)\) 的。

虚实链剖分

就是LCT。

用于维护有断边加边操作时的树上询问。但可以离线+比较特殊的时候,可以考虑离线然后启发式合并树上信息。可以很灵活地处理树上路径信息,不过子树信息有点麻烦,也还能做。

虚实链剖分,因为我们想要很灵活地处理树上信息,自然我们想要处理的链是我们指定的链。

对于一个点连向儿子的边,我们自己选一条来剖,称之实边,其他边为虚边。很灵活,这里采用同样灵活的 Splay 来维护。

LCT 可以简单理解为用 Splay 来维护每条实链区间。

辅助树

一些 Splay 构成一个辅助树,每棵辅助树维护一棵树,一些辅助树构成了 LCT,LCT 是在维护森林。

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

  2. 原树每个点与辅助树上 Splay 的节点一一对应。

  3. 辅助树的各棵 Splay 之间并不相互独立。每棵 Splay 的根的父亲节点本应是空,但是这里每棵 Splay 的根的父亲指向原树中 这条链(的链顶)的父亲。这类父亲链接与通常 Splay 的父亲链接区别在于儿子认父亲,而父亲不认儿子,对应 虚边。每个连通块恰有一个点的父亲节点为空。

  4. 由于辅助树的上述性质,一棵辅助树对应唯一的一棵原树,因此任何操作都不需要维护原树,维护辅助树即可。

原树与辅助树的结构关系

  1. 实链:辅助树中节点都在一棵 Splay 中。

  2. 虚边:辅助树中,节点所在 Splay 的根的父亲指向实链链顶的父亲,描述的就是虚边。

  3. 原树的根不等于辅助树的根。

  4. 原树的父亲不等于辅助树上的父亲。

  5. 辅助树可以在满足性质的前提下随意换根。

  6. 虚实链变换可以在辅助树上轻松维护,实现动态维护树链剖分。

开始操作

LCT 中的 Splay 略作了修改,splay 是讲当前点旋转到当前所在 Splay 的根。

int get(int x)

获取 \(x\) 是 Splay 上父亲的左/右儿子。

inline int get(int x){
	return ch[fa[x]][1]==x;
}

int isroot(int x)

判断 \(x\) 是否是它所在 Splay 的根。由上文,Splay 的根的父亲记录这条实链链顶的父亲,这是一条轻边,所以根必然不会是其父亲的左/右儿子。反之亦然,这是充要的。

inline int isroot(int x){
	return ch[fa[x]][0]!=x&&ch[fa[x]][1]!=x;
}

void rotate(int x)

和 Splay 的略有不同。

inline void rotate(int x){
	int y=fa[x],z=fa[y],k=get(x);
	if(!isroot(y)) ch[z][get(y)]=x;
	ch[y][k]=ch[x][k^1],fa[ch[x][k^1]]=y;
	ch[x][k^1]=y,fa[y]=x;
	fa[x]=z;
	pushup(y),pushup(x);
}

这里要判是否为 Splay 上的根,如果是,那么它的父亲不能认这个儿子,因为这是虚边。

void splay(int x)

也略有不同。

void splay(int x){
	update(x);
	for(int f=fa[x];!isroot(x);rotate(x),f=fa[x]){
		if(!isroot(f)) rotate((get(x)==get(f))?f:x);
	}
    pushup(x);
}

其实还好。

int access(int x)

所有直接作用在辅助树上的函数中,只有这个必须要实现,否则还写什么 LCT。其他函数看情况写写。

作用是将原树中的根到 \(x\) 的路径变成一条实链放到同一棵 Splay 中。保证只有这条路径上的点

流程是从 \(x\) 开始向上改 Splay。由于 Splay 中的点中序遍历得到从上到下的一条链,每次当前点舍弃右儿子就是断掉它的实儿子的边,然后将它的右儿子连向指定点,即连了一条我们选的实边。

int access(int x){
	int p;
	for(p=0;x;p=x,x=fa[x]){
		splay(x),ch[x][1]=p,pushup(x);
	}
    return p;
}

其实只有四步操作:

  1. 将当前结点转到所在 Splay 的根。

  2. 把儿子换成之前的结点。

  3. 更新当前点的信息。

  4. 把当前点换成当前点的父亲,继续。

这里的返回值相当于最后一次虚实链变换时虚边父亲节点的编号,有两个含义:

  1. 连续两次 access 时,第二次的返回值等于这两个点的 LCA。

  2. 表示 \(x\) 到根的链所在 Splay 的根。\(p\) 一定被转到了 Splay 的根,且父亲一定为空。

void update(int x)

更新 \(x\) 到其所在 Splay 的根的信息。

void update(int x){
	if(!isroot(x)) update(fa[x]);
	pushdown(x);
}

void makeroot(int x)

作用是将 \(x\) 置为所在原树的根。这是为方便 access

发现换根其实就是 \(x\) 到根的链翻转了

由于 Splay 上中序遍历得到原树从上到下的一条链,于是直接给根节点打上整个区间翻转的标记就好了。

void makeroot(int x){
	access(x);
	splay(x);
	tag[x]^=1;
}

也可以用 access 的返回值。

void Link(int x,int p)

作用是在原树上两点间连一条边。

要先判一下两点不能在同一棵树中。

就直接 makeroot(x),保证 \(x\) 是 Splay 上的根,然后将 \(x\) 的父亲指向 \(p\) 即可。

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

void split(int x,int y)

makeroot(x),再 access(y) 即可。

int Find(int x)

作用是找到 \(x\) 在原树上的根。

access(x),然后根就是这棵 Splay 中序遍历的第一个点,于是一直向左儿子走,沿途 pushdown,走到没有左儿子就得到根。

每次查询后要将答案对应结点 splay 上去保证复杂度。

void cut(int x,int y)

作用是断开两点在原树上的边。要先判一下合不合法。也就是 \(x\)\(y\) 之间有边。

先用 Find 判连通。再 makeroot(x)。然后 access(y) 并且 splay(y)

如果直接相连,那么此时 Splay 上只有 \(x\)\(y\) 两个点,并且 \(y\) 的左儿子是 \(x\)\(x\)\(y\) 之间没有别的点,则 \(x\) 的右儿子还要是空的。否则中序遍历出来不对。\(x\) 是根,所以左儿子肯定是空的。

判断合法后,双向断开即可。

void cut(int x,int y){
    if(findroot(x)!=findroot(y)) return;
	split(x,y);
    if(ch[y][0]==x&&ch[x][1]==0) ch[y][0]=fa[x]=0;
}

以上是 LCT 基础。给出代码:

代码
#include<bits/stdc++.h>

using namespace std;

constexpr int maxn=1e5+10;

struct Splay{
	int v,sum,fa,ch[2];
	bool tag;
}t[maxn];

#define ls(u) (t[u].ch[0])
#define rs(u) (t[u].ch[1])
#define fa(u) (t[u].fa)

inline int get(int x){
	return t[fa(x)].ch[1]==x;
}

inline int isr(int x){
	return t[fa(x)].ch[1]!=x&&t[fa(x)].ch[0]!=x;
}

inline void pushup(int x){
	t[x].sum=t[ls(x)].sum^t[rs(x)].sum^t[x].v;return;
}

inline void pushdown(int x){
	if(t[x].tag){
		if(ls(x)) t[ls(x)].tag^=1,swap(ls(ls(x)),rs(ls(x)));
		if(rs(x)) t[rs(x)].tag^=1,swap(ls(rs(x)),rs(rs(x)));
		t[x].tag=0;
	}
}

inline void update(int x){
	if(!isr(x)) update(fa(x));
	pushdown(x);
}

inline void rotate(int x){
	int y=fa(x);int z=fa(y);int k=get(x);
	if(!isr(y)) t[z].ch[get(y)]=x;
	t[y].ch[k]=t[x].ch[k^1];if(t[x].ch[k^1]) fa(t[x].ch[k^1])=y;
	t[x].ch[k^1]=y;fa(y)=x;fa(x)=z;
	pushup(y);pushup(x);
}

void splay(int x){
	update(x);
	for(int p=fa(x);!isr(x);rotate(x),p=fa(x)){
		if(!isr(p)) get(x)==get(p)?rotate(p):rotate(x);
	}
	pushup(x);
}

void access(int x){
	update(x);
	for(int p=0;x;p=x,x=fa(x)){
		splay(x);rs(x)=p;pushup(x);
	}
}

void makeroot(int x){
	access(x);splay(x);t[x].tag^=1;swap(ls(x),rs(x));
}

int Find(int x){
	access(x);splay(x);pushdown(x);
	while(ls(x)) x=ls(x),pushdown(x);
	splay(x);
	return x;
}

void Link(int x,int y){
	makeroot(x);if(Find(y)==x) return;
	fa(x)=y;
}

void Cut(int x,int y){
	if(Find(x)!=Find(y)) return;
	makeroot(x);access(y);splay(y);
	if(ls(y)==x&&rs(x)==0) ls(y)=fa(x)=0;
}

int n,m;

int main(){
	ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;++i){
		cin>>t[i].v;t[i].sum=t[i].v;
	}
	
	for(int i=1;i<=m;++i){
		int op,x,y;cin>>op>>x>>y;
		if(op==0){
			makeroot(x);access(y);splay(y);cout<<t[y].sum<<'\n';
		}
		else if(op==1) Link(x,y);
		else if(op==2) Cut(x,y);
		else if(op==3) splay(x),t[x].v=y;
	}
	return 0;
}

全局平衡二叉树

树剖多了一个 \(\log n\),LCT 常数太大怎么办?

全局平衡二叉树是单 \(\log\) 的类似 LCT 的结构,同时结合了树剖的思想。

但是是静态的,不能 Link / Cut。

实现起来其实很暴力,设计得很精巧。

先轻重链剖分,然后对于每一条重链,我们对它建一个尽量平衡的二叉搜索树来维护信息。

这里的尽量平衡是有说法的。要是随便建线段树 / 静态平衡 BST,那样就是树剖。

如何尽量平衡呢?思考一下为什么会多一个 $\log $。直接线段树就不说了,一般的静态平衡 BST(直接取链的中点作为根递归建树)为什么不对?

发现这样在一条链上一个点的高度是 \(O(\log n)\) 的,而可能出现 \(O(\log n)\) 条这样的链连在一块。由于查询链的信息时每条链都要 \(O(\log n)\) 跳到根,然后得到信息,于是是 \(O(\log^2 n)\) 的。

那么换一种平衡的方式。我们给链上的每个位置带上轻子树(含自身)的大小的权值,选取带权中心作为每次选取的根递归建树。

这样类似一条链的带权点分树。类似 Splay 在 LCT 上的结构,一个结点的子树是重链上的一段区间,一条重链的 BST 的根的父亲连向原树中轻边连向的父亲。

总结一下流程就是:

  • 轻重链剖分。

  • 对每条重链,按轻子树大小给点权,然后按点权建 BST。

  • 对每条轻边,类似 LCT,认父不认子。

  • 重链信息直接在 BST 上查,然后修改就一直暴力跳父亲改。

可以证明这是 \(O(\log n)\) 的。分讨一下,至多跳 \(O(\log n)\) 条轻边,每次跳轻边时轻子树大小不减。由于 BST 内部按轻子树大小递归建树,每跳一条 BST 内部的重边轻子树大小至少翻倍,所以至多跳 \(O(\log n)\) 条重边。所以树高是 \(O(\log n)\) 的。

可以平替没有 Link / Cut 之类特别灵活的操作的 LCT。有时可以换掉树剖以得到时间复杂度的优势。

可能需要标记永久化之类的。维护子树信息需要类似 LCT 的技巧,单独对轻子树维护信息。

posted @ 2024-12-23 16:05  RandomShuffle  阅读(38)  评论(0)    收藏  举报