树链剖分学习笔记

前言

对于树链剖分,有两种形式,一种是重链剖分,一种是长链剖分。(对于刚学树链剖分的小伙伴应该都不怎么会碰到长链剖分吧!)
但是不管是重链还是长链,都是以一个标准进行操作的:若按照树遍历的dfs序进行操作,那么投影到一维数轴上是连续的,这样使线性修改成为可能。
虽然重链和长链有很大相似的部分,但是还是有很多不一样的地方。

重链剖分

重链剖分一些名词解释

首先要对一些名词进行解释:

  • 重儿子:我们假设当前节点是\(u\),那么儿子\(v\)集合中,子树最大的\(v\)节点叫做重儿子。
  • 轻儿子:对于每一个节点,不是重儿子的儿子都是轻儿子。
  • 重链:在一棵树上,从头到尾都是重儿子组成的链叫做重链,一个节点也能成为一个重链。(换句话说:一个节点如果没有重儿子,而且也不是父亲节点的重儿子,那么可以单独成为一个重链)
  • 轻链:同重链的定义,由轻儿子组成的链。

对于重链剖分的原理

我们都知道,在一棵树上,如果按照\(dfs\)序来投影树上的节点,一个子树上的节点是连续的,那么一个链上的节点也是连续的。
首先先感受一下何为重链,以下的加粗的边都是重链,标红的节点都是父亲节点的轻儿子。

那么我们根据这个性质,首先要将所有的\(dfs\)序整理出来,投影到一个一维数轴中。
但是你会发现一个比较严重的问题,就是如何遍历你这个子节点的顺序?反观我们的题目,我们需要通过父亲和儿子之间的轻重关系来决定出\(dfs\)的顺序,也就是我们要取那一条边当做剖分的对象。
如果对于每一个节点在新的一维数轴上会有新的编号,也就是这个节点的\(dfs\)序,重编号保证一个链在一个区间内。
假设你先在已经剖好了一棵树,那么你先在已经有的是重儿子,重链,以每一个节点为根节点的子树的大小。
我们顺便再算出每一个节点的父亲\(fa\)数组,这个是为了只有我们跳链操作做准备。还有一个需要我们记录的是\(top\)数组,表示当前节点所在重链的顶节点,如何求解这个顶节点。

重链剖分的预处理操作

首先还是对一些数组进行解释:

sz[N]//表示节点的子树大小
fa[N]//表示节点的父亲节点
dep[N]//表示节点的深度
idx[N]//表示节点的新编号
top[N]//表示节点所在重链的顶节点
son[N]//表示节点的重儿子

重链剖分的预处理有\(2\)\(dfs\)的操作:

  • 第一个\(dfs\),我们需要求出\(fa\)\(dep\)\(sz\)\(son\)
  • 第二个\(dfs\),可以求出\(top\)\(idx\)

第一个dfs操作

求出\(fa\)\(dep\)\(sz\)数组都是树的\(dfs\)遍历中非常常见的操作,也不详细分析了,那么我们只需要对\(son\)进行简略讲解,我们需要对所有子节点的\(sz\)\(max\),最大的就是我们的重儿子。
给出代码:

void dfs1(int u, int ft, int dp) {
    fa[u] = ft; 
    dep[u] = dp; 
    sz[u] = 1;
    int maxson = -1;
    for (int e = H[u]; e; e = E[e].nt) {
        int v = E[e].to; 
        if(v == fa[u]) continue;
        dfs1(v, u, dp + 1); 
        sz[u] += sz[v];
        if(sz[v] > maxson) maxson = sz[v], son[u] = v;
    }
}

第二个dfs操作

目标:求出\(top\)以及给节点重新编号。
向下递归,我们优先递归每一个节点的重儿子,这是为了保证重链上的节点是连续的这一性质,然后在枚举所有的轻儿子,轻儿子很明显不能串到原来的重链上去,那么我们就重新开一个重链,以这个轻儿子为重链的顶节点,继续向下递归。而原来的重儿子的链顶就是父亲的链顶。
\(ps.\)注意:如果当前节点没有重儿子就不需要继续递归了。
给出代码:

void dfs2(int u, int tp) {
    top[u] = tp;//tp表示链顶
    idx[u] = ++dfn;//这是dfs序的重标号
    a[dfn] = w[u];
    if (!son[u]) return;//如果没有
    dfs2(son[u], tp);//优先重儿子
    for (int e = H[u]; e; e = E[e].nt) {    
        int v = E[e].to;
        if (v == son[u] || fa[u] == v) return;
        dfs2(v, v);//重新开一个重链
    }
}

重链剖分的对树操作

很多时候我们都是用线段树来维护这个区间的问题,有的时候我们也可以用树状数组或者是平衡树等等,以下的操作都是用线段树维护为例。
如果不怎么熟练线段树的话可以参考我的另外一篇博客:线段树学习笔记

单节点修改

因为我们已经对原来的树进行的重新编号,那么新节点的编号就是idx[u],那么我们只需要在新节点的线段树上直接操作就可以了。

单节点查询

同理节点修改,我们只需要直接单点新编号查询就好了。

树上路径修改

我们知道新编号中,重链上的节点是连续的,那么我们能够操作一条重链上的所有数字,也是用线段树维护。不能直接区间修改两个节点,因为只有重链是连续的,而在不连续的区间上,是无法进行区间修改的。
那么如何解决这个问题?比较容易想到每次修改一条重链,然后进行跳链操作,也就是跳到当前节点\(top\)节点的父亲节点,直到两个节点到了一个重链上。
注意一个小细节,我们每一次处理的节点是深度较深的节点,然后在进行操作。每一次我们只能是从当前节点的顶节点处理到这个节点,因为顶节点的\(dfs\)序较小,所以我们修改的区间就是\([idx[top[u]],idx[u]]\)
为什么要选择深度较深的节点,因为这样可以保证每次修改后,两个点越来越接近。

我们再次梳理一下路径修改操作的过程:

  • 1、如果不是在同一条链上,那么先修改顶节点深度较大的一段重链。
  • 2、如果跳到了一个重链上,那么就直接修改区间。
void update_chain(int u, int v) {
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) swap(u, v);//取顶节点深度较大的修改
        tr.update_sec(1, idx[top[u]], idx[u]);
        u = fa[top[u]];//跳链操作
    }
    if (dep[u] > dep[v]) swap(u, v);
    tr.update_sec(1, idx[u], idx[v]);//在同一个重链上就直接修改
}

树上路径查询

同理路径修改操作,就只需要将修改改成查询操作就可以了。

子树修改操作

给出一个结论,这个结论也非常的好证明:
一个子树的区间是\([idx[u],idx[u]+sz[u]-1]\)
证明如下:
子树节点在区间内是连续的,那么子树的总的个数就是\(sz[u]\),开头是\(idx[u]\),出去开头外,结尾就是\(idx[u]+sz[u]-1\)
得到了一下结论后,我们就可以直接区间修改,不需要任何的跳链操作。
给出代码:

void update_tree(int u) {
    tr.update_sec(1, idx[u], idx[u] + sz[u] - 1);
}

子树查询操作

同理子树修改操作,就只需要将修改改成查询操作就可以了。

重链剖分求lca

这个也非常的简单,也非常的适用,但是代码量较大,我们来分析一下:
如果两个节点不在一个重链上,那么说明当前两条重链上不存在两个节点的\(lca\)
那么我们还是像树上路径修改的操作,运用跳链操作,跳到相同的重链上时,深度较小的节点就是两个点的\(lca\)
代码:

int Lca(int u, int v) {
    while(top[u] != top[v]) {
        if(dep[top[u]] < dep[top[v]]) swap(u, v);
        u = fa[top[u]];
    }
    return dep[u]<dep[v]?u:v;
}

换根操作

这是树链剖分的一大优势,如果你要换了一个树的根节点,树链剖分依旧可以用非常优的复杂度来求解。
不少人都会用重新建树来做,但是这样建树就是要\(O(n)\)的复杂度,那么总的复杂度就退化到了\(O(mn)\)了,这样不是我们想要看到的。
给出一道例题:bzoj3083遥远的国度
我们将这个根节点的操作简单化。
如果我们要求\(x\)节点为根的最小节点,整棵树的根节点是\(root\)
如果\(x=root\),显而易见,我们访问节点的答案就是整棵树的最小值。
如果\(lca(x,root)!=x\),也就是x和root是在两个不同的链上,那么我们的答案也就是原来的\(x\)的子树的答案。
那么最后一种情况就是\(lca(x,root)=x\),那么也就是说\(x\)成为了当前我们根节点的祖先。
这个玩意比较麻烦,但是画一张图,自己仔细观察一下,可以发现:我们要求的答案就是在\(root\)所在\(u\)的子树这条链以外的的所有其他子树。
那么我们就需要通过在一维数组中展开的树上的性质,枚举\(u\)节点的所有子节点,如果有一个节点深度比\(root\),而且\(idx+sz-1\)≥root的编号,也就是说我们root就在\(v\)(当前访问\(u\)号节点)的子树内,那么我们就可以直接算答案了。
\(ps\).求这道题的\(lca\)可以是用倍增,其实差不多的。
在我的博客园小屋里也有这个题的题解:https://www.cnblogs.com/chhokmah/p/10391457.html
给出代码:(很早写的题目,码风有一点不大一样)

#include<bits/stdc++.h>
#define ms(a,b) memset(a,b,sizeof(a))
#define inf 0x3f3f3f3f
#define N 1000005
using namespace std;
struct edge{
    int to,nt;
}E[N<<1];
int cnt,n,m,tot,rt;
int H[N],sz[N],top[N],dep[N],fa[N],son[N],idx[N],val[N],a[N];
int read(){
    int w=0,x=0;char ch=0;
    while(!isdigit(ch))w|=ch=='-',ch=getchar();
    while(isdigit(ch))x=(x<<1)+(x<<3)+(ch^48),ch=getchar();
    return w?-x:x;
}
void addedge(int u,int v){
    E[++cnt]=(edge){v,H[u]}; H[u]=cnt;
    E[++cnt]=(edge){u,H[v]}; H[v]=cnt;
}
struct segment_tree{//线段树维护树链
    #define lson (nod<<1)
    #define rson (nod<<1|1)
    #define mid (l+r>>1)
    int tr[N<<2],lazy[N<<2];
    void pushup(int nod){tr[nod]=min(tr[lson],tr[rson]);}//pushup操作
    void pushdown(int nod){//下放懒标记
        if(lazy[nod]==-1) return;
        tr[lson]=tr[rson]=lazy[nod];
        lazy[lson]=lazy[rson]=lazy[nod];
        lazy[nod]=-1;
    }
    void build(int l,int r,int nod,int *a){
        lazy[nod]=-1;
        if(l==r){
            tr[nod]=a[l];
            return;
        }
        build(l,mid,lson,a);
        build(mid+1,r,rson,a);
        pushup(nod);
    }
    void update(int l,int r,int ql,int qr,int v,int nod){//区间修改
        if(ql<=l&&r<=qr){
            tr[nod]=v;
            lazy[nod]=v;
            return;
        }
        pushdown(nod);
        if(ql<=mid) update(l,mid,ql,qr,v,lson);
        if(qr>mid) update(mid+1,r,ql,qr,v,rson);
        pushup(nod);
    }
    int query(int l,int r,int ql,int qr,int nod){//区间查询
        if(ql>r||qr<l) return inf;
        if(ql<=l&&r<=qr) return tr[nod];
        pushdown(nod);
        int res=inf;
        res=min(res,query(l,mid,ql,qr,lson));
        res=min(res,query(mid+1,r,ql,qr,rson));
        return res;
    }
}T;
void dfs1(int u,int ft,int dp){//第一遍dfs,求出sz,fa,dep,son
    sz[u]=1;
    fa[u]=ft;
    dep[u]=dp;
    int maxson=-1;
    for(int e=H[u];e;e=E[e].nt){
        int v=E[e].to;
        if(v==fa[u]) continue;
        dfs1(v,u,dp+1);
        sz[u]+=sz[v];
        if(sz[v]>maxson) maxson=sz[v],son[u]=v;
    }
}
void dfs2(int u,int tp){//第二遍求出top,idx,val,并且将所有的节点通过轻重关系化成平面上的链
    top[u]=tp;
    idx[u]=++tot;
    val[tot]=a[u];
    if(!son[u]) return;
    dfs2(son[u],tp);
    for(int e=H[u];e;e=E[e].nt){
        int v=E[e].to;
        if(v==fa[u]||v==son[u]) continue;
        dfs2(v,v);
    }
}
void Update(int a,int b,int v){//修改树上路径
    while(top[a]!=top[b]){
        if(dep[top[a]]<dep[top[b]]) swap(a,b);
        T.update(1,n,idx[top[a]],idx[a],v,1);
        a=fa[top[a]]; 
    }
    if(dep[a]>dep[b]) swap(a,b);
    T.update(1,n,idx[a],idx[b],v,1);
}
int Lca(int x,int y){//求lca
    while(top[x]!=top[y]){
        if(dep[top[x]]<dep[top[y]]) swap(x,y);
        x=fa[top[x]];
    }
    return dep[x]<=dep[y]?x:y;
}
int Query(int u){//查询操作
    if(u==rt) return T.tr[1];
    int lca=Lca(u,rt);
    if(lca!=u) return T.query(1,n,idx[u],idx[u]+sz[u]-1,1);
    int y;
    for(int e=H[u];e;e=E[e].nt){
        int v=E[e].to;
        if(idx[v]<=idx[rt]&&idx[v]+sz[v]-1>=idx[rt]){y=v;break;}//找到root所在子树
    }
    int res=T.query(1,n,1,idx[y]-1,1); res=min(res,T.query(1,n,idx[y]+sz[y],n,1));
    return res;
}
int main(){
    tot=0,cnt=0;
    n=read(),m=read();
    for(int i=1;i<n;i++) addedge(read(),read());
    for(int i=1;i<=n;i++) a[i]=read();
    rt=read();
    dfs1(1,-1,1); dfs2(1,1);
    T.build(1,n,1,val);
    while(m--){
        int opt=read();
        if(opt==1) rt=read();
        if(opt==2){
            int a=read(),b=read(),c=read();
            Update(a,b,c);
        }
        if(opt==3){
            int x=read();
            printf("%d\n",Query(x));
        }
    }
    return 0;
}

以上是蒟蒻chh能想到的比较简单的操作,一些难一点的操作也是基于这些操作做出来的,仔细分析都可以做。

重链剖分的习题

1、bzoj2243 染色

https://www.cnblogs.com/chhokmah/p/10401187.html

2、bzoj2590 树的统计

https://www.cnblogs.com/chhokmah/p/10416273.html

3、bzoj4034 树上操作

https://www.cnblogs.com/chhokmah/p/10410762.html

4、luogu3258 松鼠的新家

5、NOI2015 软件管理器

https://www.cnblogs.com/chhokmah/p/10467376.html

以上都是简单的模板题
多的题目主人会持续更新。


长链剖分

长链剖分很多小伙伴们都不怎么接触,我先来讲一下定义是什么?

长链剖分的定义

我们将重链剖分中的重儿子改成长儿子,长儿子也就是子树中深度最大的子节点叫做长儿子(我自己yy乱搞出来的名字,不要喷),那么我们建树的时候不是维护\(sz\)子树大小数组了,而是维护\(maxdep\)数组,表示最大深度。

以上这幅图就是一条条的长链。

长链剖分的性质

  • 任意点的任意祖先所在长链长度一定大于等于这个点所在长链长度
  • 所有长链长度之和就是总节点数
  • 个点到根的路径上经过的短边最多有\(\sqrt{n}\)

长链剖分的应用

以下分析来自:https://www.cnblogs.com/Khada-Jhin/p/9576403.html

O(1)时间内算出第k级的祖先

这个应用不是很广,因为只有在n特别大时才能体现出优势,但对于某些题可以简便地找到k级祖先。
首先想最暴力的方法每次朴素爬到父亲节点,这样单次查询时间复杂度是O(n)。
再进行优化,用倍增往上爬,单次时间复杂度O(logn)
因为倍增是满log的,那么用另一种求lca的方法重链剖分,这样虽然还是O(logn),但常数小了一点
再想想能不能把倍增和重链剖分一起用?先找出比k小的最高的2的幂次,然后维护每个点的往上跳的倍增数组,先跳2的最高次幂再重链剖分,这样快了一点,但还是不能O(1)。
那么我们能不能把跳完2的最高次幂的那个点的祖先都记录下来呢?这样预处理时间复杂度就爆炸了。
如果只预处理每条重链链头的祖先和链上的节点呢?但往上要预处理多长的祖先?
这时联想上面讲到的长链剖分的第一个性质,将重链剖分换成长链剖分,暴力预处理每个链头往上链长个祖先及这条链上的所有点,因为只有链头预处理,而所有链长和是节点总数,所以预处理这一步时间复杂度是O(2n)。再预处理出所有数二进制的最高次幂,每次跳最大一步之后O(1)查询。
具体怎么查?为什么往上预处理链长个祖先?
我们分类讨论:
1、当k级祖先在当前链上时,直接查链头存的链信息
2、当k级祖先不在当前链上但在跳2的最高次幂到的点x所在的链上时,直接查点x所在那条链的链头存的链信息
3、当k及祖先既不在当前链上,也不在跳2的最高次幂到的点x所在的链上时,因为x距离查询点深度最少为k/2(跳的是2的最高次幂),那么x往下的长链长度至少为k/2,也就是说x所在长链长度至少为k/2,x所在链的链头往上预处理的祖先至少有k/2个,一定包含k级祖先。
这就是为什么要用长链剖分而不是重链剖分的原因,重链剖分没有长链剖分的第一个性质。

O(n)处理可合并的与深度有关的子树信息(例如某深度点数、某深度点权和)

首先还是先想暴力,dfs整棵树,回溯时将每一深度的信息合并,时间复杂度O(n*maxdep)
再优化一下,还是想到重链剖分,因为每个点合并时第一个子节点可以直接继承下来(继承一般是用指针O(1)优化,具体后面再讲),剩下子树暴力遍历,因为重链剖分后每个点不被继承而被暴力遍历最多logn次(每个点到根路径上最多log条轻边是需要被遍历的),因此时间复杂度是O(nlogn)。
再想想能发现根本不用遍历其他子树,只要合并子树已有信息就好了。
但我们发现重链剖分在合并深度信息时不怎么优秀,因为每个点的轻儿子可能深度更深,合并还是很慢。
重链剖分不具有重儿子最深的性质但长链剖分具有啊!因此只要把重链剖分换成长链剖分,每次还是继承重儿子,其他的暴力合并。那么这样的时间复杂度呢?我们考虑一棵子树信息被暴力合并当且仅当这棵子树的根节点与其父节点之间的边是短边,合并的代价是这棵子树中最长链的长度(也就是这棵子树的深度),而这棵子树的根节点就是这个最长链的链头,那么也就转化成了只有每条链的链头会被暴力合并且合并的时间复杂度是链长。因为所有链长和是n,所以这样做的时间复杂度就是O(n)。有了这个应用就可以优化许多与深度有关的树形DP了。

长链剖分的习题

BZOJ4381、BZOJ4543、BZOJ1758

posted @ 2019-03-14 11:17 chhokmah 阅读(...) 评论(...) 编辑 收藏