来点树链剖分

树链剖分学习笔记

引入

给你一棵树,先单点加,再路径求和,你觉得很简单,用树上差分解决了这个问题。

再给你一棵树,先路径加,再单点查询,你觉得很简单,用树上差分解决了这个问题。

又给你一棵树,上述操作都有,而且顺序不分先后,你发现树上差分不能解决这个问题。

那么,该如何解决这个问题呢?

从树到序列

考虑序列上的问题。

单点加区间求和、区间加单点查询这两个问题都能使用差分和前缀和解决,然而当出现形似第三个问题的问题时,我们引入了树状数组和线段树。于是我们推测树上的这类问题也能转化为序列上的问题,进而使用这两种数据结构解决。

于是问题变成了:找到一种方式,使我们的树变成一个序列,以便于把树上路径修改与查询变成序列上区间的修改与查询。

笔者决定再给你一棵树,单点加,子树求和,想想该如何解决这个问题。

考虑对树进行 DFS,记录每个点的 DFS 序。

比如我们有这样一棵树:
这是那棵树,要是看不到就脑补一下

不难发现,每个点的子树的 DFS 序都在一个连续的区间上。于是在这个问题中,我们利用 DFS 序完成了从树上子树问题到序列上区间问题的转换。

我们观察刚才得到的 DFS 序,可以发现,不仅子树的 DFS 序在连续的区间上,从每个节点的 DFS 序开始,会有一个连续区间,对应这个节点向下延伸到底的一条链。

于是我们有一个大胆的想法:利用这一点来把树上路径问题转化为序列上区间问题。

具体地,在搜索时记录每个节点的 DFS 序、深度、所在链的最顶端。每次在 DFS 序列上进行单点操作,当需要对路径操作时,对两个路径端点进行如下操作:

  • 找到两端点中链头深度更大的那个点;

  • 对从链头到它的这一段 DFS 序在序列上进行操作;

  • 跳到它的链头的父节点;

  • 重复上述操作直到两端点的链头相同。

当链头相同时,直接对这两个点之间的区间在 DFS 序列上进行操作即可。

有如下代码(以路径加为例):

void modify(int u,int v,int k)
{
//dfn[]:DFS序
while(top[u]!=top[v])
{
if(dep[top[u]]<dep[top[v]]) swap(u,v);
SegTree.add(SegRoot,dfn[top[u]],dfn[u],k,1,n);
u=fa[top[u]];
}
if(dep[u]>dep[v]) swap(u,v);
SegTree.add(SegRoot,dfn[u],dfn[v],k,1,n);
}

于是我们成功实现了从树上路径问题到序列上区间问题的转化。这种转化就叫作树链剖分

然而,这样真的对吗?

改进

按照上面的方式,如果很不幸,这些链的延伸方向是这样:

那么每次修改操作的时间复杂度最坏情况下将会来到 \(O(n)\),是无法接受的。

但是如果我们控制链延伸的方向,使得构造出的链能使路径操作中跳链头(u=fa[top[u]])的次数最少,时间复杂度就会变得可以接受。

我们可以让链向子树中节点最多(子树大小最大)的儿子(下文称为重儿子,其他的儿子称为轻儿子)延伸。这种转化叫作重链剖分

为什么这样做就能使跳链头的次数更少呢?这样做,每次跳链头,都是从链头父亲的轻儿子跳上来。而由于它是轻儿子,它子树的大小一定不超过父亲节点子树大小的一半。因此每次跳链头,都是从一个子树大小为 \(x\) 的点,跳到一个子树大小至少为 \(2x\) 的点。最坏情况下,当从叶子节点一步步跳到根节点,即子树大小从 \(1\) 一步步变为 \(n\) 时,最多变换 \(\log{n}\) 次,时间复杂度为 \(O(\log{n})\),是可以接受的。而实际使用中,往往是卡不满的,因此重链剖分求 LCA 往往比倍增更优,当然也更难写

通过研究求 DFS 序的过程,可以发现,每个点的链延伸到哪个儿子的方向,取决于哪个儿子最先被访问。因此我们 DFS 预处理出每个节点的重儿子,每访问到一个节点,先访问它的重儿子,再访问轻儿子。

有如下代码:

int dfn[N],dep[N],fa[N],siz[N],son[N],top[N],bot[N],idx;
void dfs1(int x) //如果节点编号从0开始,就不能这么写了
{
siz[x]=1;
for(int i=hed[x];i;i=nxt[i]) if(!siz[tal[i]]) //上一行把siz设为1了,如果siz为0,说明没访问过tal[i]
{
fa[tal[i]]=x,dep[tal[i]]=dep[x]+1;
dfs1(tal[i]);
siz[x]+=siz[tal[i]];
if(siz[tal[i]]>siz[son[x]]) son[x]=tal[i]; //son[x]初值为0,而siz[0]=0
}
}
void dfs2(int x,int tp) //主函数调用:dfs2(1,1);因为根节点的链头是它本身
{
if(!x) return; //访问了一个叶子节点的重儿子
bot[x]=dfn[x]=++idx;
dfs2(son[x],top[x]=tp);
if(bot[son[x]]>bot[x]) bot[x]=bot[son[x]]; //只有一个儿子
for(int i=hed[x];i;i=nxt[i]) if(!top[tal[i]]) //没给链头赋值,说明没访问过
dfs2(tal[i],tal[i]),bot[x]=bot[tal[i]]; //轻儿子的链头是它本身
}

把这个代码和上面的 modify 拼起来,再动用你的智慧补全其他函数,树链剖分板子题就做完了。

实现

笔者这里给出一份 P3384 的代码,毕竟树链剖分对于初学者来说还是挺难调的

#include <cstdio>
#define N 100005
int n,m,r,p,a[N],rt;
int hed[N],tal[N<<1],nxt[N<<1],cnte;
void adde(int u,int v) {tal[++cnte]=v,nxt[cnte]=hed[u],hed[u]=cnte;}
int dfn[N],li[N],dep[N],siz[N],fa[N],son[N],top[N],bot[N],idx;
struct sgt
{
int d[N<<2],tg[N<<2],ls[N<<2],rs[N<<2],id;
#define mid (lb+rb>>1)
void mt(int x,int lb,int rb,int k) {(d[x]+=(rb-lb+1)*k%p)%=p,(tg[x]+=k)%=p;}
void pushdown(int x,int lb,int rb)
{
if(!tg[x]) return;
if(!ls[x]) ls[x]=++id;
if(!rs[x]) rs[x]=++id;
mt(ls[x],lb,mid,tg[x]);
mt(rs[x],mid+1,rb,tg[x]);
tg[x]=0;
}
int build(int lb,int rb)
{
int x=++id;
if(lb==rb) {d[x]=a[li[lb]]%p;return x;}
ls[x]=build(lb,mid),rs[x]=build(mid+1,rb);
d[x]=(d[ls[x]]+d[rs[x]])%p;
return x;
}
void modify(int x,int l,int r,int k,int lb,int rb)
{
if(l<=lb&&rb<=r) {mt(x,lb,rb,k);return;}
pushdown(x,lb,rb);
if(l<=mid) modify(ls[x],l,r,k,lb,mid);
if(r>mid) modify(rs[x],l,r,k,mid+1,rb);
d[x]=(d[ls[x]]+d[rs[x]])%p;
}
int query(int x,int l,int r,int lb,int rb)
{
if(l<=lb&&rb<=r) return d[x];
pushdown(x,lb,rb);
int ret=0;
if(l<=mid) ret=query(ls[x],l,r,lb,mid);
if(r>mid) (ret+=query(rs[x],l,r,mid+1,rb))%=p;
return ret;
}
#undef mid
} tr;
void dfs1(int x)
{
siz[x]=1;
for(int i=hed[x];i;i=nxt[i]) if(!siz[tal[i]])
{
dep[tal[i]]=dep[x]+1,fa[tal[i]]=x;
dfs1(tal[i]);
siz[x]+=siz[tal[i]];
if(siz[tal[i]]>siz[son[x]]) son[x]=tal[i];
}
}
void dfs2(int x,int tp)
{
if(!x) return;
li[bot[x]=dfn[x]=++idx]=x;
dfs2(son[x],top[x]=tp);
if(bot[son[x]]>bot[x]) bot[x]=bot[son[x]];
for(int i=hed[x];i;i=nxt[i]) if(!top[tal[i]])
dfs2(tal[i],tal[i]),bot[x]=bot[tal[i]];
}
void pmodify(int x,int y,int z)
{
while(top[x]!=top[y])
{
if(dep[top[x]]<dep[top[y]]) {int tmp=x;x=y,y=tmp;}
tr.modify(rt,dfn[top[x]],dfn[x],z,1,n);
x=fa[top[x]];
}
if(dep[x]>dep[y]) {int tmp=x;x=y,y=tmp;}
tr.modify(rt,dfn[x],dfn[y],z,1,n);
}
int pquery(int x,int y)
{
int ret=0;
while(top[x]!=top[y])
{
if(dep[top[x]]<dep[top[y]]) {int tmp=x;x=y,y=tmp;}
(ret+=tr.query(rt,dfn[top[x]],dfn[x],1,n))%=p;
x=fa[top[x]];
}
if(dep[x]>dep[y]) {int tmp=x;x=y,y=tmp;}
(ret+=tr.query(rt,dfn[x],dfn[y],1,n))%=p;
return ret;
}
int main()
{
scanf("%d%d%d%d",&n,&m,&r,&p);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1,u,v;i<n;i++) scanf("%d%d",&u,&v),adde(u,v),adde(v,u);
dfs1(r),dfs2(r,r);
rt=tr.build(1,n);
while(m--)
{
int op,x,y,z;
scanf("%d%d",&op,&x);
if(op==1) scanf("%d%d",&y,&z),pmodify(x,y,z);
if(op==2) scanf("%d",&y),printf("%d\n",pquery(x,y));
if(op==3) scanf("%d",&z),tr.modify(rt,dfn[x],bot[x],z,1,n);
if(op==4) printf("%d\n",tr.query(rt,dfn[x],bot[x],1,n));
}
}

什么,你要注释?再见吧

边权转点权

顾名思义,有些题需要修改和查询边权,如 P3038。像这类问题就需要把一条边的边权转化为某个点的点权。

由于这是一棵树,一条边一定连接着父亲和儿子,一个父亲可能有多个儿子,而一个儿子只有一个父亲,所以把边权转化为这条边所连的儿子的点权,然后套模板就行了。

注意路径操作需要去掉 LCA 处的点权值,因为两个点的 LCA 连向父亲的边不在这两个点的路径上。

结语

至此,重链剖分便基本讲完了。树链剖分不止有重链剖分,还有长链剖分、LCT 需要的剖分等实现,这里不再展开叙述。

树链剖分由于细节很多,容易写挂,所以需要多加练习。

推荐一个题单:树链剖分练习题


\[\Huge End \]

posted @   整齐的艾萨克  阅读(3)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」
点击右上角即可分享
微信分享提示