链剖分,指对树的边进行划分的一类操作,目的是减少在链上修改、查询等操作的复杂度。链剖分有三类:轻重链剖分、虚实链剖分和长链剖分。
树链剖分的思想是通过轻重链剖分将树分为多条链,保证每个节点都属于且只属于一条链。树链剖分是轻重链剖分,节点到重儿子(子树节点数最多的儿子)之间的路径为重链。每条重链都相当于一段区间,把所有重链首尾相接组成一个线性节点序列,再通过数据结构(如树状数组、SBT、伸展树、线段树等)来维护即可。
若size[u]表示以u为根的子树的节点个数,则在u的所有儿子中,size最大的儿子就是重儿子,而u的其他儿子都是轻儿子,当前节点与其重儿子之间的边就是重边,多条重边相连为一条重链。一棵树如下图所示。长度大于1的重链有两条:1-3-6-8、2-5,单个轻儿子可被视作一个长度为1的重链:4、7,因此本题中有4条重链。图中深色的节点是重儿子,加粗的边是重边。
重要性质:
- 若v是轻儿子,u是v的父节点,则size[v]≤size[u]/2;
- 从根到某一点路径上,不超过log2n条重链,不超过log2n条轻边。
树链剖分支持以下操作。
(1)单点修改:修改一个点的权值。
(2)区间修改:修改节点u到v路径上节点的权值。
(3)区间最值查询:查询节点u到v路径上节点的最值。
(4)区间和查询:查询节点u到v路径上节点的和值。
树链剖分的应用比倍增更广泛,倍增可以做的,树链剖分一定可以做,反过来则不行。树链剖分的代码复杂度不算特别高,调试也不难,树链剖分在算法竞赛中是必备知识。
1.预处理
树链剖分可以采用两次深度优先搜索实现。
第1次深度优先搜索维护4个信息:dep[]、fa[]、size[]、son[]。
- dep[u]:u的深度。
- fa[u]:u的父节点。
- size[u]:以u为根的子树的节点数。
- son[u]:u的重儿子,u-son[u]为重边。
第2次深度优先搜索以优先走重边的原则,维护3个信息:top[]、id[]、rev[]。
- top[u]:u所在的重链上的顶端节点编号(重链上深度最小的节点)。
- id[u]:u在节点序列中的位置下标。
- rev[x]:树链剖分后节点序列中第x个位置的节点。
id[]与rev[]是互逆的。例如,节点u在节点序列中的位置下标是x,则节点序列中第x个位置的节点是u,id[u]=x,rev[x]=u。对上面的树进行树链剖分后,将所有重链都放在一起组成一个节点序列:[1,3,6,8],[7],[2,5],[4]。序列中第4个位置是8号节点,8号节点的存储下标是4,即rev[4]=8,id[8]=4。预处理的时间复杂度为O(n)。
2.求解LCA问题
对于LCA(最近公共祖先)问题,点和边均没有权值,因此无须维护线段树来实现。输入树后,先进行树链剖分预处理。
算法代码:
void dfs1(int u,int f) {//求dep、fa、size和son
size[u]=1;
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v==f)//父节点
continue;
dep[v]=dep[u]+1;//深度
fa[v]=u;
dfs1(v,u);
size[u]+=size[v];
if(size[v]>size[son[u]])
son[u]=v;
}
}
void dfs2(int u) {//求top
if(u==son[fa[u]])
top[u]=top[fa[u]];
else
top[u]=u;
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v!=fa[u])
dfs2(v);
}
}
显然,树中的任意一对节点(u,v)只存在两种情况:①在同一条重链上(top[u]=top[v]);②不在同一条重链上。
对第1种情况,LCA(u,v)就是u、v中深度较小的节点。例如下图中求节点3和8的最近公共祖先时,因为3和8在同一条重链上且3的深度较小,因此LCA(3,8)=3。
对第2种情况,只要想办法将u、v两点转移到同一条重链上即可。首先求出u、v所在重链的顶端节点top[u]和top[v],将其顶端节点深度大的节点上移,直到u、v在同一条重链上,再用第1种情况中的方法求解即可。
例如下图中求节点7和8的最近公共祖先,7和8不在同一条重链上,先求两个节点所在重链的顶端节点:top[7]=7,top[8]=1,dep[1]<dep[7],7的顶端节点深度大,因此将v从7上移到其父节点3,此时3和8在同一条重链上,且3的深度较小,因此LCA(7,8)=3。
求5和7的最近公共祖先,5和7不在同一条重链上,先求两节点所在重链的顶端节点:top[5]=2,top[7]=7,dep[2]<dep[7],7的顶端节点深度大,因此将v从7上移到其顶端节点的父节点3。
3所在重链的顶端节点:top[3]=1,dep[1]<dep[2],5的顶端节点深度大,因此将u从5上移到其顶端节点的父节点1,此时1和3在同一条重链上,且1的深度较小,因此LCA(5,7)=1。
算法代码:
int LCA(int u,int v) {//求区间u、v的最近公共祖先
while(top[u]!=top[v]) {//不在同一条重链上
if(dep[top[u]]>dep[top[v]])//将顶端节点深度大的上移
u=fa[top[u]];
else
v=fa[top[v]];
}
return dep[u]>dep[v]?v:u;//返回深度小的节点
}
3.树链剖分与线段树
若在树中进行点更新、区间更新、区间查询等操作,则可以使用线段树来维护和处理。
一棵树如下图所示。
树链剖分之后的节点序列和下标序列如下图所示。
节点序列对应的权值如下图所示。
根据w[]序列创建线段树,如下图所示。
查询节点u到v路径上节点权值的最值与和值的方法如下。
- 若u和v在同一条重链上,则在线段树上查询其对应的下标区间[id[u],id[v]]即可。
- 若u和v不在同一条重链上,则一边查询,一边将u和v向同一条重链上移,然后采用上面的方法处理。对于顶端节点深度大的节点,先查询其到顶端节点的区间,然后一边上移一边查询,直到上移到同一条重链上,再查询在同一条重链上的区间。
查询节点6~9权值的最值与和值(包括6和9节点),过程如下。
(1)读取top[6]=1,top[9]=2,两者不相等则说明其不在一条重链上,且top[9]的深度大,先查询top[9]~9之间的最值与和值。
首先得到节点2和9对应的节点序列下标7和9。
然后在线段树中查询[7,9]区间的最值与和值。[7,9]区间的最值与和值:Max=15,Sum=22。
(2)将u上移到top[9](2号节点)的父节点,即1号节点,此时1和6在同一条链上。
节点1和6对应的线段树下标为1和3。
在线段树中查询到[1,3]区间的最值与和值分别为20、31,如下图所示。再与前面的结果求最大值与和值,则Max=max(Max,20)=max(15,20)=20,Sum=Sum+31=22+31=53。
区间更新的方法与此类似,若不在一条链上,则一边更新,一边向同一条链上靠,最后在同一条链上更新即可。
注意:更新和查询时均需要先得到节点对应的线段树下标,再在线段树上更新和查询。
算法代码:
void dfs1(int u,int f) {//求dep、fa、size、son
size[u]=1;
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v==f)//父节点
continue;
dep[v]=dep[u]+1;//深度
fa[v]=u;
dfs1(v,u);
size[u]+=size[v];
if(size[v]>size[son[u]])
son[u]=v;
}
}
void dfs2(int u,int t){//求top、id、rev
top[u]=t;
id[u]=++total; //u对应的节点序列中的下标
rev[total]=u; //节点序列下标对应的节点u
if(!son[u])
return;
dfs2(son[u],t);//沿着重儿子深度优先搜索
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v!=fa[u]&&v!=son[u])
dfs2(v,v);
}
}
void build(int k,int l,int r){//创建线段树,k表示存储下标,区间为[l,r]
tree[k].l=l;
tree[k].r=r;
if(l==r){
tree[k].mx=tree[k].sum=w[rev[l]];
return;
}
int mid,lc,rc;
mid=(l+r)/2;//划分点
lc=k*2; //k节点的左子节点存储下标
rc=k*2+1;//k节点的右子节点存储下标
build(lc,l,mid);
build(rc,mid+1,r);
tree[k].mx=max(tree[lc].mx,tree[rc].mx);//节点的最大值等于左右子节点最值的最大值 tree[k].sum=tree[lc].sum+tree[rc].sum;//节点的和值等于左右子树的和值
}
void query(int k,int l,int r){//求[l,r]区间的最值、和值
if(tree[k].l>=l&&tree[k].r<=r) {//找到该区间
Max=max(Max,tree[k].mx);
Sum+=tree[k].sum;
return;
}
int mid,lc,rc;
mid=(tree[k].l+tree[k].r)/2;//划分点
lc=k*2; //左子节点存储下标
rc=k*2+1; //右子节点存储下标
if(l<=mid)
query(lc,l,r);//到左子树中查询
if(r>mid)
query(rc,l,r);//到右子树中查询
}
void ask(int u,int v){//求u、v之间的最值或和值
while(top[u]!=top[v]) {//不在同一条重链上
if(dep[top[u]]<dep[top[v]])
swap(u,v);
query(1,id[top[u]],id[u]);//u顶端节点和u之间
u=fa[top[u]];
}
if(dep[u]>dep[v]) //在同一条重链上
swap(u,v); //深度小的节点为u
query(1,id[u],id[v]);
}
void update(int k,int i,int val){//u对应的下标i,将其值更新为val
if(tree[k].l==tree[k].r&&tree[k].l==i){//找到i
tree[k].mx=tree[k].sum=val;
return;
}
int mid,lc,rc;
mid=(tree[k].l+tree[k].r)/2;//划分点
lc=k*2; //左子节点存储下标
rc=k*2+1; //右子节点存储下标
if(i<=mid)
update(lc,i,val);//到左子树中更新
else
update(rc,i,val);//到右子树中更新
tree[k].mx=max(tree[lc].mx,tree[rc].mx);//返回时更新最值
tree[k].sum=tree[lc].sum+tree[rc].sum;//返回时更新和值
}
算法分析:树链剖分预处理需要O(n)时间,每次更新和查询都需要O(logn)时间。
原创声明:本文来自本人著作《算法训练营:海量图解+竞赛刷题》进阶篇,未经许可,谢绝转载。