树链剖分总结

姓名: 陶荣杰
赛道: 省选组
类型: 算法技能

树链剖分

关键词:重链剖分,LCA,DFS序,线段树

前置知识

线段树,DFS序,LCA

树链剖分简介

~~~~ ​ 树链剖分是将树剖分成若干条的形式,使它组成线性结构,以更好的维护树上路径的信息。

~~~~~ 树链剖分有多种形式,有长链剖分重链剖分实链剖分。而重链剖分的特征是吧“最大的”儿子称为重儿子,以此把树剖分成若干条重链。因为重链剖分应用较多,所以一般来说,「树链剖分」都指「重链剖分」。

~~~ ​ ​ 重链剖分是提高树上搜索效率的一个巧妙的方法。它按一定规则把树“剖分”成一条条从根节点到叶子节点的不相交的链,把对树的操作转换成对链的操作。而重链剖分可以让从根节点到任意一条链只需要经过 O(log_2n) 条链,从而使操作的复杂度为 O(log_2n)

~~~~ ​ 重链剖分的特别之处就是每条链上的DFS序都是有序的,所以往往结合线段树来高效处理树上查询和修改问题。

重链剖分与LCA

~~~~ ​ 我们来通过求最近公共祖(LCA)先来更好的介绍重链剖分的思想。

~~~~~LCA的各种算法其主要原理都是快速“”到祖先节点,回顾一下求 LCA的两种方法:

1 . 倍增法:用二进制递增直接往祖先“跳“。

2 . Tarjan算法:用并查集合并子树,子树内的节点指向子树的根,查询LCA时可以直接从节点跳到子树的根,从而实现快速”跳“。

~~~~~ 重链剖分LCA也是”跳“到祖先节点,但”跳“法比较巧妙。它把树”剖分“成从根节点到叶子节点的一条条链路,每个点都只属于一条链,链路不相交,而链上任意两个相邻的节点都为父子关系,如下图(随便剖的)。

QQ图片20240815224433

~~~~~ ​ 这样图中就有3条链,可以发现,相同链上两点的LCA便是深度较浅的那个点,比如LCA(4,10)=4,LCA(2,9)=2。那不在同一条链呢?那就跳到同一个链上,比如LCA(9,11),如果我们把9和11跳到同一条链上,那么就跟前面一样了。

~ ~~~ ​ 所以如果我们让跳的链更少,也就是让链尽量少且尽量长,也就能让“剖分“更高效。

~~~~~~ 那到底要怎样”剖分“呢?注意每个节点都只能属于一条链。很自然就能想到观察树上的节点分布情况,每次选择那些有更多子节点的分支建链。链会更长一些,从而使链的数量更少。

~~~~ ​ 如下图,从1开始,每次选择些有更多子节点的分支建链,最终得到了3条链。从叶子节点到根节点1也最多经过1条链。

QQ截图20240816005902

~~~~~ 例如下图,从1开始,随意建链,最终得到了5条链,而且从叶子节点到根节点1最多经过2条链,显然比上图更劣势。

QQ截图20240816005902 - 副本

​​ ~~~~~ ​以上就是重链剖分的核心思想了,但在具体实现之前你还要记住以下概念:

1 . 重儿子:对于一个非叶子节点,其所有儿子中子树节点数最多(包括这个儿子)的节点。例如对于1,它的三个儿子2,3,4。子树节点数分别是6,2,4,显然2是1的重儿子。如果多个儿子中子树节点数一样,随便取其中一个就行了。

2 . 轻儿子:除了重儿子外的儿子,例如3,4就是轻儿子。

3 . 重边:父亲节点连接重儿子的边。例如(1,2),(4,9)就是重边。

4 . 重链:连续的重边而形成的链(或者说是连续的重儿子组成的链)。重链上任意两个相邻的节点都是父子关系。1,2,5,10,13就形成了一条重链。每条重链都是以轻儿子或根节点作为起点。可以把单独的一个叶子节点也看成一条重链,如11。

5 . 轻边:除了重边以外的边,任意两条重链都是用一条轻边所连接的。如(1,4)。

6 . 链头:一条重链上深度最浅的点。链头必定是个轻儿子或根节点。如果把一条重链看成一个集合,链头就是这个集合的集。与并查集类比,链头就是并查集的集。树链剖分就是通过用链头的快速跳跃实现高效操作的。1,3和4都是链头。

另外你还需知道以下的基本定义

变量 含义
fa(x) 表示节点 x 在树上的父亲
dep(x) 表示节点 x 在树上的深度
size(x) 表示节点 x 的子树节点个数
son(x) 表示节点 x 的重儿子
top(x) 表示节点 x 所在重链的链头
id(x) 表示节点 x 的DFS序

具体实现

​​ ~~~~~ ​首先跑第一遍DFS求出 fa,dep,size,son 这些基本信息 。

void dfs(int u,int fa){
    p[u].fa=fa;
    p[u].size=1;
    p[u].dep=p[fa].dep+1;
    for(int i=head[u];i;i=e[i].next){
        int v=e[i].to;
        if(v==fa) continue;//不能是父亲
        dfs(v,u);
        p[u].size+=p[v].size;
        if(p[u].son==0|p[v].size>p[p[u].son].size){
            p[u].son=v;//重儿子
        }
    }
}

~~~~~ 知道这些信息还不够,所以得进行第二遍DFS来建链。

​​ ~~~~ 特别注意:需要先对重儿子进行遍历,使一条重链上的DFS序连续,这样就可以使用线段树进行链上的维护,进而可以维护一整棵树。

void dfs2(int u,int t){
    p[u].id=++dfn;
    p[u].top=t;
    if(!p[u].son) return;//叶子节点就直接返回
    dfs2(p[u].son,t);//先遍历重儿子
    for(int i=head[u];i;i=e[i].next){
        int v=e[i].to; 
        if(v==p[u].fa|v==p[u].son) continue;//不能遍历重儿子和父亲,因为已经遍历过了
        dfs2(v,v);
    }   
}

~~~~~~ 然后再让初值按DFS序排好,便于用线段树维护。

 for(int i=1;i<=n;i++){
        w[p[i].id]=a[i];//对号入座
}

~~~~~ 接下来就是经典常谈(线段树)了(难崩

int ls(int p){return p<<1;} 
int rs(int p){return (p<<1)+1;}
void pushup(int p){
    tree[p]=tree[ls(p)]+tree[rs(p)];
}
void build(int p,int pl,int pr){
    if(pl==pr){tree[p]=w[pl]%mod;return;}
    int mid=(pl+pr)>>1;
    build(ls(p),pl,mid);
    build(rs(p),mid+1,pr);
    pushup(p);
}
void add(int p,int pl,int pr,int k){
    lazy[p]+=k);
    tree[p]+=(pr-pl+1)*k);
}
void pushdown(int p,int pl,int pr){
    if(lazy[p]){
        int mid=(pl+pr)>>1;
        add(ls(p),pl,mid,lazy[p]);
        add(rs(p),mid+1,pr,lazy[p]);
        lazy[p]=0;
    }
}   
void update(int p,int pl,int pr,int l,int r,int k){
    if(l<=pl&&pr<=r){
        add(p,pl,pr,k);
        return;
    }
    pushdown(p,pl,pr);
    int mid=(pl+pr)>>1;
    if(l<=mid) update(ls(p),pl,mid,l,r,k);
    if(mid<r) update(rs(p),mid+1,pr,l,r,k);
    pushup(p);
}
int query(int p,int pl,int pr,int l,int r){
        if(l<=pl&&pr<=r){return tree[p];}
        int res=0,mid=(pl+pr)>>1;
        pushdown(p,pl,pr);
        if(l<=mid) res=res+query(ls(p),pl,mid,l,r);
        if(mid<r) res=res+query(rs(p),mid+1,pr,l,r);
        return res;
}

~~~~~ ​ 树链剖分最经典的应用莫过于树上的最短路径修改,我们知道树上两点 的的最短路径一定经过他们的LCA,所以也是一个查找LCA的过程。我们来说下具体实现。

​ 如果我们要求 x y 的LCA。

  1. x y 在同一条重链上。不用说,重链上的节点都是祖先和后代的关系,设 y 的深度比 x 浅 ,那么 LCA(x, y)=y

  2. x y 不在同一条重链上,设 y 所在重链链头深度比 x 所在重链链头深度浅,让 x 先跳到先跳到重链链头,然后穿过轻边到另一条重链上,重复此流程直到 x y 在同一条重链上。重链的定义可以保证最后能跳到同一条重链上。例如我们要求 LCA(11,7)

QQ截图20240816005902 - 副本 (2)

~~~~ ​ 11所在重链链头比较深,所以让11穿过轻边到达5。此时7所在重链链头更深,让7跳到链头再穿过轻边到达1。此时5和1在同一条重连上,LCA就为深度较浅的1了。

int lca(int x,int y){
    while(p[x].top!=p[y].top){//跳到同一条链上
        if(p[p[x].top].dep<p[p[y].top].dep){swap(x,y);}
        x=p[p[x].top].fa;
    }
    if(p[x].dep<p[y].dep){swap(x,y);}
    return y;
}

~~~~~ ​ 欸,这时候聪明的人就发现了,这跳的过程不就是在走树上两点的最短路吗,那么要做树上最短路修改时可不可以边跳边修改呢,而且一条重链上的DFS序是连续的也很好修改啊。(实锤这话是作者说的

~~~~ ​ 嘿,这树上最短路径修改还真是这样的。所以路径修改就跟求LCA差不多。这也是重链剖分一个巧妙之处

void upd_c(int x,int y,int k){
    while(p[x].top!=p[y].top){//不在同一条重链上
        if(p[p[x].top].dep<p[p[y].top].dep){swap(x,y);}
        update(1,1,n,p[p[x].top].id,p[x].id,k);//跳的过程中进行区间修改
        x=p[p[x].top].fa;
    }
    if(p[x].dep<p[y].dep){swap(x,y);}
    update(1,1,n,p[y].id,p[x].id,k);
}

~~~~~~ ​amazing!很好的修改,是我的代码旋转。

~~~~~ ​ 查询也是大同小异,把区间修改改成区间查询就行了。

~~~~~~ ​ 这时有些偷看了模板题的人问了,不是还有子树修改吗,你这要怎么办?(还是作者说的

~~~~~ ​ 欸,这时候又有一个聪明的人发现,这子树中的DFS序也是连续的欸,直接做 [id[x],id[x]+size[x]-1] 的区间修改不就行了。

​​ ~~~~~ ​ 这与其说是树链剖分的妙处,不如说是DFS序的妙处。不清楚DFS序的妙用就好好补课去吧

~~~~~~ ​摘自:OI-wiki(DFS(图论) - OI Wiki (oi-wiki.org))

void upd_r(int x,int k){
    update(1,1,n,p[x].id,p[x].id+p[x].size-1,k);
}

~~~~~ 子树查询就是把update换成query了,不用多说了。

锵锵!最终代码

~~~~~~ 模板题目链接:题目

#include<bits/stdc++.h>
#define int long long
const int N=114514;
using namespace std;
int head[N],cnt;
int dfn;
int n,m,rt,mod,a[N],w[N],tree[N*4],lazy[N*4];
int ls(int p){return p<<1;} 
int rs(int p){return (p<<1)+1;}
struct edge{
    int next,to;
}e[N*2];
void add_edge(int u,int v){
    e[++cnt].to=v;
    e[cnt].next=head[u];
    head[u]=cnt;
}
struct node{
    int size,top,son,dep,fa,id,rk;
    node(){
        memset(this,0,sizeof(node));
    }
}p[N];
void dfs(int u,int fa){
    p[u].fa=fa;
    p[u].size=1;
    p[u].dep=p[fa].dep+1;
    for(int i=head[u];i;i=e[i].next){
        int v=e[i].to;
        if(v==fa) continue;//不能是父亲
        dfs(v,u);
        p[u].size+=p[v].size;
        if(p[u].son==0|p[v].size>p[p[u].son].size){
            p[u].son=v;//重儿子
        }
    }
}
void dfs2(int u,int t){
    p[u].id=++dfn;
    p[u].top=t;
    if(!p[u].son) return;
    dfs2(p[u].son,t);//先遍历重儿子
    for(int i=head[u];i;i=e[i].next){
        int v=e[i].to; 
        if(v==p[u].fa|v==p[u].son) continue;//重儿子和父亲已经遍历过了
        dfs2(v,v);
    }   
}
//线段树,无需多盐
void pushup(int p){
    tree[p]=(tree[ls(p)]+tree[rs(p)])%mod;
}
void build(int p,int pl,int pr){
    if(pl==pr){tree[p]=w[pl]%mod;return;}
    int mid=(pl+pr)>>1;
    build(ls(p),pl,mid);
    build(rs(p),mid+1,pr);
    pushup(p);
}
void add(int p,int pl,int pr,int k){
    lazy[p]=(lazy[p]+k);
    tree[p]=(tree[p]+(pr-pl+1)*k)%mod;
}
void pushdown(int p,int pl,int pr){
    if(lazy[p]){
        int mid=(pl+pr)>>1;
        add(ls(p),pl,mid,lazy[p]);
        add(rs(p),mid+1,pr,lazy[p]);
        lazy[p]=0;
    }
}   
void update(int p,int pl,int pr,int l,int r,int k){
    if(l<=pl&&pr<=r){
        add(p,pl,pr,k);
        return;
    }
    pushdown(p,pl,pr);
    int mid=(pl+pr)>>1;
    if(l<=mid) update(ls(p),pl,mid,l,r,k);
    if(mid<r) update(rs(p),mid+1,pr,l,r,k);
    pushup(p);
}
int query(int p,int pl,int pr,int l,int r){
        if(l<=pl&&pr<=r){return tree[p]%mod;}
        int res=0,mid=(pl+pr)>>1;
        pushdown(p,pl,pr);
        if(l<=mid) res=res+query(ls(p),pl,mid,l,r);
        if(mid<r) res=res+query(rs(p),mid+1,pr,l,r);
        return res%mod;
}
void upd_c(int x,int y,int k){//路径修改
    while(p[x].top!=p[y].top){
        if(p[p[x].top].dep<p[p[y].top].dep){swap(x,y);}
        update(1,1,n,p[p[x].top].id,p[x].id,k);
        x=p[p[x].top].fa;
    }
    if(p[x].dep<p[y].dep){swap(x,y);}
    update(1,1,n,p[y].id,p[x].id,k);
}
int que_c(int x,int y){//路径查询
    int res=0;
    while(p[x].top!=p[y].top){
        if(p[p[x].top].dep<p[p[y].top].dep){swap(x,y);}
        res=(res+query(1,1,n,p[p[x].top].id,p[x].id))%mod;
        x=p[p[x].top].fa;
    }
    if(p[x].dep<p[y].dep) {swap(x,y);}
    res=(res+query(1,1,n,p[y].id,p[x].id))%mod;
    return res%mod;
}
void upd_r(int x,int k){//子树修改
    update(1,1,n,p[x].id,p[x].id+p[x].size-1,k);
}
int que_r(int x){//子树查询
    return query(1,1,n,p[x].id,p[x].id+p[x].size-1)%mod;
}
signed main(){
    cin>>n>>m>>rt>>mod;
    for(int i=1;i<=n;i++){
        cin>>a[i];
    }
    for(int i=1;i<=n-1;i++){
        int x,y;
        cin>>x>>y;
        add_edge(x,y);
        add_edge(y,x);
    }
    dfs(rt,rt);
    dfs2(rt,rt);
    for(int i=1;i<=n;i++){
        w[p[i].id]=a[i];
    }
    build(1,1,n);
    while(m--){
        int op,x,y,z;
        cin>>op;
        if(op==1){
            cin>>x>>y>>z;
            upd_c(x,y,z);
        }
        else if(op==2){
            cin>>x>>y;
            cout<<que_c(x,y)<<endl;
        }
        else if(op==3){
            cin>>x>>z;
            upd_r(x,z);
        }
        else{
            cin>>x;
            cout<<que_r(x)<<endl;
        }
    }
    return 0;
}

~~~~~ 150行啊,初学者要变成出血者了。

边权转点权

~~~~~~ ​边权转点权说起来非常简单。因为树只有 n-1 条边,所以我们只直接将边权传给儿子(也就是深度较深的节点)即可,可以发现除了根节点每个节点都有权值,在实现起来也只要简单在dfs1中加一行就行了,当然你也可以研究些其他写法.

a[v]=e[i].w;//注意v不是父亲节点

~~~~~ 但因为传给了儿子,所以在做树上修改时候也要改些细节

void update_tree(int x,int y,int k){
    while(p[x].top!=p[y].top){
		if(p[p[x].top].dep<p[p[y].top].dep) swap(x,y);
		update_add(1,1,n,p[p[x].top].id,p[x].id,k);
		x=p[p[x].top].fa;
	}
	if(p[x].dep<p[y].dep) swap(x,y);
	if(p[y].id<p[x].id) update_add(1,1,n,p[y].id++,p[x].id,k);//边是记录在儿子上的,所以只能从儿子开始改
}

~~~~~ 因为 x 是要穿过轻边的,所以循环中的修改不需要动。但外面就不能从 LCA 开始改了,因为边权记录在儿子的缘故,所以要从 LCA 的 重儿子 开始改(因为一条重链的dfs序是有序的,所以可以直接写成 LCA 的 dfs序++ )。

例题

染色

描述

给定一棵 n 个节点的无根树,共有 m 个操作,操作分为两种:

  1. 将节点 a 到节点 b 的路径上的所有点(包括 a b )都染成颜色 c
  2. 询问节点 a 到节点 b 的路径上的颜色段数量。

分析

~~~~~~ 因为要进行树上路径修改和查询所以第一步进行树链剖分,方便我们进行树上操作。然后就是珂朵莉树的板子了
~~~~~~ 当然写线段树也是完全没问题的,分析下题目,难点就是快速统计区间颜色段数量,可以想到分治,将每个大区间拆分成每个小区间,计算出小区间后再统计出大区间的答案,也符合线段树的思想。
具体细节

练习

月下“毛景树”
[国家集训队] 旅游
ZJOI2008 树的统计

后记

~~~~~~ 这是我第一次写这样的总结,本来还准备做一个视频或者一个动画来讲解的,但因为邻近开学,再加上一个人搞对这没什么了解,所以到最后视频没剪好,动画制作也是停滞不前(自己还是太菜了),也就是说这个还是只算个“半成品”,也不知道能不能弄好,但还是会尽力去做的。
~~~~~~ 尽管观看量没那么出众,但感悟还是挺深的。
~~~~~~ 写总结的初衷也是出于想向大家分享下自己的心得,同时对自己也算是种复习了。这篇总结就是在我自学树剖后,一时起兴写出来的。过程还是挺枯燥的,花了不少时间查资料打代码,有些例题还是请别人调出来的,但最终写出来还是满高兴的,算是值得了。后来还想加LCT的,但自己实力实在一般,写出来起码还要几个月。
~~~~~~ OIer所要行的路漫长而又艰难,自己也不见得有多少起色。但回过头来,从第一次打印出“Hello world!”到遇到种种新奇的算法和数据结构。从一个小白走到现在,经历过一次次春秋交替,一次次日月更迭,一次次跌倒,又一次次站起。虽含着泪,但这又何尝不是一场精彩的旅途,一次难忘的成长呢?
~~~~~ “路漫漫其修远兮,吾将上下而求索。”
~~~~~ 也祝大家在将要到来比赛中取得好成绩了。

3 个赞

我突然意识到一件事


:upside_down_face:
是不是应该投省选啊

我觉得应该是的

1 个赞

是的

2 个赞

ok改了
也不知道省选班的能整出什么活,但现在好像没人投省选 :thinking:

1 个赞

多投稿投稿省选的 :smiling_face_with_tear:
需要你们分享经验!

2 个赞

O(∩_∩)O哈哈~,点个赞

2 个赞

可我是提高班的欸

1 个赞

录出来是视频稍微有点糊,但质量开高空间占的也很大,稍微研究下吧

1 个赞

什么逆天 typora

2 个赞

左下是live2d

1 个赞

我测,芙

2 个赞

真是芙了

3 个赞

live2D里面的模型

1 个赞

嗷嗷,大佬膜拜%%%

3 个赞

@陶荣杰1 能快讲边权转点权吗,我不会 :crazy_face:

2 个赞

补好咧 @数据结构

1 个赞

确实边权转点权好像还要进行其他处理。

1 个赞

9月15号:优化了xyd的排版,修改了总结中的一些错误,增加了一些描述和后记。

自己的一些感悟也写着总结里面了,也希望大家能支持一下了。