链剖分总结
来解决树上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)\)的。
换根
换根后的路径与子树操作
支持换根,路径赋值,查子树最小值。
\(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
主要就是求换根后的LCA。
以下记\(lca(u,v)\)表示原树上的LCA,\(LCA(u,v)\)表示换根后的LCA,\(rt\)表示新根。
换根后求\(x,y\)的LCA需要大力分讨(以下默认\(dep_x\le dep_y\)):
第一类:\(lca(x,y)=x\)时。即\(x,y\)在原树上时祖先后代关系。
-
\(rt\)在\(x\)的子树内也在\(y\)的子树内,那么\(LCA(x,y)=y\)。
-
\(rt\)在\(x\)的子树内却不在\(y\)的子树内,那么\(LCA(x,y)=lca(y,rt)\)。
-
\(rt\)不在\(x\)的子树内,那么\(LCA(x,y)=x\)。
第二类:\(lca(x,y)\ne x\)。即\(x,y\)在原树上在不同的子树中。
-
\(rt\)在\(x\)的子树内,那么\(LCA(x,y)=x\);\(rt\)在\(y\)的子树内,那么\(LCA(x,y)=y\)。
-
\(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)})\)。
-
\(rt\)在\(lca(x,y)\)上方,那么\(LCA(x,y)=lca(x,y)\)。判断为\(lca(x,rt)=lca(y,rt)\)。
-
\(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)\)。
分讨,爽!!!
重链上二分
长链剖分
长剖就是将子树高度最大的儿子作为重儿子来剖分。
性质有:
-
一条根链上轻边条数为\(O(\sqrt n)\)的。这是因为从一条链跳到另一条链上,所在链的长度必然更长。于是坏情况就是经过的链长度从\(1\)到\(\sqrt n\),同时经过\(\sqrt n\)条轻边。
-
一个点的\(k\)级祖先所在长链的长度必然\(\ge k\)。容易反证。
优化 DP
可以使用指针/vector
/一些手段 \(O(1)\) 继承重儿子的信息,然后暴力合并轻儿子信息。因为每个长链只会在链顶作为轻儿子合并一次,合并的信息与深度有关,是 \(O(\text{深度})\) 的,所以均摊每个点花费 \(O(1)\) 的代价,于是总复杂度是 \(O(n)\) 的。
虚实链剖分
就是LCT。
用于维护有断边加边操作时的树上询问。但可以离线+比较特殊的时候,可以考虑离线然后启发式合并树上信息。可以很灵活地处理树上路径信息,不过子树信息有点麻烦,也还能做。
虚实链剖分,因为我们想要很灵活地处理树上信息,自然我们想要处理的链是我们指定的链。
对于一个点连向儿子的边,我们自己选一条来剖,称之实边,其他边为虚边。很灵活,这里采用同样灵活的 Splay 来维护。
LCT 可以简单理解为用 Splay 来维护每条实链区间。
辅助树
一些 Splay 构成一个辅助树,每棵辅助树维护一棵树,一些辅助树构成了 LCT,LCT 是在维护森林。
-
辅助树由多棵 Splay 组成,每棵 Splay 维护原树上的一条路径,且中序遍历这棵 Splay 得到的点序列从前到后对应原树 从上到下 的一条路径。
-
原树每个点与辅助树上 Splay 的节点一一对应。
-
辅助树的各棵 Splay 之间并不相互独立。每棵 Splay 的根的父亲节点本应是空,但是这里每棵 Splay 的根的父亲指向原树中 这条链(的链顶)的父亲。这类父亲链接与通常 Splay 的父亲链接区别在于儿子认父亲,而父亲不认儿子,对应 虚边。每个连通块恰有一个点的父亲节点为空。
-
由于辅助树的上述性质,一棵辅助树对应唯一的一棵原树,因此任何操作都不需要维护原树,维护辅助树即可。
原树与辅助树的结构关系
-
实链:辅助树中节点都在一棵 Splay 中。
-
虚边:辅助树中,节点所在 Splay 的根的父亲指向实链链顶的父亲,描述的就是虚边。
-
原树的根不等于辅助树的根。
-
原树的父亲不等于辅助树上的父亲。
-
辅助树可以在满足性质的前提下随意换根。
-
虚实链变换可以在辅助树上轻松维护,实现动态维护树链剖分。
开始操作
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;
}
其实只有四步操作:
-
将当前结点转到所在 Splay 的根。
-
把儿子换成之前的结点。
-
更新当前点的信息。
-
把当前点换成当前点的父亲,继续。
这里的返回值相当于最后一次虚实链变换时虚边父亲节点的编号,有两个含义:
-
连续两次
access
时,第二次的返回值等于这两个点的 LCA。 -
表示 \(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 的技巧,单独对轻子树维护信息。