树链剖分

树链剖分就是将树分割成多条链,然后利用数据结构(树状数组、BST、SPLAY、线段树等)来维护这些链,其实本质是一些数据结构或算法在树上的推广。

树链剖分的实质是一种暴力优化,它可以减少在链上跳跃的次数,也可以快速的访问数上两点间的路径,总之,常见作用就是可以维护树上路径的信息

树链剖分有很多种,如重链剖分、长链剖分和实链剖分,本文主要讲的是重链剖分的思想与实现过程。

基本概念与信息的维护

首先就是一些必须知道的基本概念

重结点:同父节点的点中以之为根子树结点数目最多的结点(如下图中的 $3$ 号和 $11$ 号节点等标黄色阴影的点);
轻节点:同父亲节点的点中除了重结点以外的结点(如下图中的 $1$ 号和 $2$ 号节点等未标黄色阴影的点);
重边:父亲结点和其重结点连成的边(如下图中 $1$、$3$ 之间和 $7$、$13$ 之间的边等黄色边);
轻边:父亲节点和其轻节点连成的边(如下图中 $1$、$2$ 之间和 $8$、$15$ 之间的边等白色边);
重链:由多条重边连接而成的路径(如从 $1$ 到 $18$ 号节点之间的路径,从 $2$ 到 $16$ 号节点之间的路径等);
轻链:由多条轻边连接而成的路径(如从 $1$ 到 $2$ 号节点之间的路径,从 $5$ 到 $9$ 号节点之间的路径等);

比如对于上图,所有带黄色阴影的点为该店父节点的重儿子,也就是重结点,所有黄色的边都是重边,所有白色的边都是轻边,节点内的白色数字代表节点的编号,节点旁的红色数字表示以该节点为根的子树大小,粉色数字为 dfs 序,究竟 dfs 序为什么是这样,我们过会儿再讲。

首先,我们可以进行一次简单的 dfs,求出每个结点的子树大小、重儿子、轻儿子、重边、轻边等信息。

struct node {
  int to, nx;
} eg[N << 1];
int hd[N], tot, cnt;
void adeg(int st, int ed) {
  eg[++tot] = {ed, hd[st]};
  hd[st] = tot;
} //链式前向星,不必多说

int dfn[N], top[N], f[N];
int dep[N], sz[N], son[N];
bool hv[N]; /*
dfn[i] - 第 i 个结点的 dfs 序
top[i] - 第 i 个结点所在重链的重链头的编号,若该节点在轻链上或者该点为重链头,那么 top[i]=i
f[i] - 第 i 个结点的父结点编号
dep[i] - 第 i 个结点在树中的深度,每个节点的深度等于其父节点深度 + 1,规定根节点深度为 1
sz[i] - 以第 i 个节点为根的子树大小
son[i] - 第 i 个结点的重儿子的编号
hv[i] - 若 hv[1] = 1,表示第 i 个结点为其父节点的重儿子,否则不是 */
// 第一次深搜记录 dep、sz、f、son 和 hv 这些参数
void dfs1(int u, int fa) {
  dep[u] = dep[fa] + 1; //计算深度
  f[u] = fa, sz[u] = 1; //记录父节点,初始化子树大小
  int mxsz = 0;
  for (int i = hd[u]; i; i = eg[i].nx) {
    int to = eg[i].to;
    if (to == fa) continue;
    dfs1(to, u);
    sz[u] += sz[to]; //将字数大小加上各个子结点字数大小
    if (sz[to] > mxsz) son[u] = to, mxsz = sz[to];
    //计算并求出重儿子
  } hv[son[u]] = 1; //记录重儿子
}

然后,我们再 dfs 一次,这次求出 top 和 dfn,注意在求 dfn 时,要保证先搜重儿子,以保证同一重链上的点 dfs 序连续。

void dfs2(int u, int fa) {
  dfn[u] = ++cnt;
  if (hv[u]) top[u] = top[fa];
  else top[u] = u; //按要求计算 top
  if (!son[u]) return;
  dfs2(son[u], u); //直接先搜重儿子
  for (int i = hd[u]; i; i = eg[i].nx) {
    int to = eg[i].to;
    if (to == fa) continue;
    if (son[u] == to) continue;
    dfs2(to, u); //再搜索其他轻儿子
  }
}

树链剖分的基本性质

  1. 如果 $(u,v)$ 是一条轻边,那么 $size(v)\leq size(u)/2$;
  2. 从根结点到任意结点的路所经过的轻重链的个数必定都小于 $\log n$;
  3. 所有重链的 top 都是轻结点。

树上跳跃与树剖求 LCA

★ 什么是 LCA?

LCA 即 Lowest Common Ancestor,中文译为最近共同祖先,即两节点所共同存在的子树中,根节点深度最大的根节点编号,除暴搜外,我们主要求 LCA的方法有四种,分别是倍增、RMQ+ST、离线 Tarjan 和树链剖分,其中,在实际应用中,树链剖分是效率最高且较好写的一种。

在了解了 LCA 之后,我们详细讲解树链剖分求 LCA 的过程,并通过此过程,体现如何在树上如何进行链跳跃,起到举一反三的效果。

我们定义 $\mathrm{lca}(a,b)$ 为结点 $a$ 和结点 $b$ 的最近公共祖先,还是对于此图,加入我们要求 $\mathrm{lca}(19,13)$,那么应该怎么办呢?

那么,求结点 $\mathrm{lca}(i,j)$,我们试考虑如下做法:

  1. 若结点 $i$ 和 $j$ 已在同一重链上,即其所在重链的 $top$ 相同,返回深度较小的点,停止循环。
  2. 若 $i$ 结点所在的重链头 $x$ 的深度小于 $j$ 结点所在的重链头 $y$,那么交换 $x$ 和 $y$。
  3. 将 $x$ 设为 $f[top[x]]$,问题转化为求 $\mathrm{lca}(f[top[x]],y)$,重复 $1$ 步骤。

首先,通过观察,我们可以发现,$\mathrm{lca}(19,13)=3$,我们就此样例进行模拟上述过程:

首先 $top[19]=19$,$top[13]=7$,不相同,进入第二个步骤,显然 $dep[top[19]]\geq dep[top[13]]$,进入第三个步骤,问题此时转化为求 $\mathrm{lca}(f[top[19]],13) = \mathrm{lca}(17,13)$。

返回 $1$ 操作,$top[17]=1$,$top[13]=7$,不相同,进入第二个步骤,$dep[top[19]] < dep[top[13]]$,应当交换 $i$ 和 $j$,假如不交换,那么第三个步骤会把问题转化为 $\mathrm{lca}(f[1],13)$,根节点没有父亲,循环不成立,即使我们将根节点的父亲强行规定为其本身,那么我们要求的问题将是 $\mathrm{lca}(1,13)$,那么显然 $\mathrm{lca}(1,13)\neq\mathrm{lca}(19,13)$,答案错误,而且程序还会进入死循环,所以必须交换 $i$ 和 $j$,把问题转化为 $\mathrm{lca}(13,17)$,然后再进入第三个步骤,问题此时转化为求 $\mathrm{lca}(f[top[13]],17) = \mathrm{lca}(3,17)$。

再次返回 $1$ 操作,$top[3]=rop[17]=1$,此时在同一条链,由于 $dep[3]<dep[17]$,返回 $3$ 为 $\mathrm{lca}(19,13)$ 的结果,答案正确。

回顾上述过程,我们将 $\mathrm{lca}(19,13)$ 逐步转化,求出答案,转化的总过成为:$\mathrm{lca}(19,13) = \mathrm{lca}(f[top[19]],13) = \mathrm{lca}(17,13) = \mathrm{lca}(13,17) = \mathrm{lca}(f[top[13]],17) = \mathrm{lca}(3,17)=3$。

可写出如下代码:

int lca(int x, int y) {
  while (top[x] != top[y]) {
    if (dep[top[x]] < dep[top[y]]) swap(x, y);
    x = f[top[x]];
  }
  if (dep[x] < dep[y]) return x;
  return y;
}

由于上面写的树链剖分的基本性质,可以证明,树链剖分求 $q$ 次 lca 的最坏时间复杂度为 $O(q\log n)$,但是在实际运行中,树链剖分的时间是接近大常数 $O(n)$ 的,效率非常可观。

那么,通过此过程,我们来探究树上跳跃的规律, 对于任意一点 $u$,不断将其跳到 $f[top[u]]$,直到跳到所求位置为止。

与数据结构结合

树链剖分 + 线段树

来看在洛谷中的树链剖分模板题:P3384 【模板】重链剖分/树链剖分

首先,在第二次 dfs 时,求出了 dfn,在求 dfn 时,要保证先搜重儿子,以保证同一重链上的点 dfs 序连续,那么,我们可以对于重链开线段树,由于 dfs 序的连续性,我们可以直接使用线段树区修区查。

在进行两点之间路径查询时,可以做类似求 lca 的跳链操作,对于每次跳到的重链进行查询,修改操作也是如此。

思路

// author: syl
// language: c++
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5+7;
int mod,v[N],w[N];
int son[N],sz[N],top[N],fat[N],dfn[N],dep[N],dfszc;
//树链剖分常规操作
int hd[N],tot,n,m,r;
struct node {
    int to,nx;
} eg[N<<1];
void adeg(int st,int ed) {
    eg[++tot]={ed,hd[st]};
    hd[st]=tot;
} //链式前向行
void dfs(int u,int fa) {
    sz[u]=1,fat[u]=fa,dep[u]=dep[fa]+1;
    for(int i=hd[u];i;i=eg[i].nx) {
        int to=eg[i].to;
        if(to==fa) continue;
        dfs(to,u);
        sz[u]+=sz[to];
        if(sz[to]>sz[son[u]]) son[u]=to;
    }
}
void rdfs(int u,int head) {
    top[u]=head,dfn[u]=++dfszc,w[dfn[u]]=v[u];
    if(son[u]) rdfs(son[u],head);
    for(int i=hd[u];i;i=eg[i].nx) {
        int to=eg[i].to;
        if(top[to]) continue;
        rdfs(to,to);
    }
} //两次 dfs 进行树链剖分操作,生成 dfs 序
struct segment_tree {
    int sum[N<<2],tag[N<<2];
    void push_up(int u) {
        sum[u]=sum[u<<1]+sum[u<<1|1];
        sum[u]%=mod;
    }
    void push_down(int u,int l,int r) {
        if(!tag[u]) return;
        tag[u<<1]+=tag[u];
        tag[u<<1]%=mod;
        tag[u<<1|1]+=tag[u];
        tag[u<<1]%=mod;
        int mid=l+r>>1;
        sum[u<<1]+=(mid-l+1)*tag[u];
        sum[u<<1]%=mod;
        sum[u<<1|1]+=(r-mid)*tag[u];
        sum[u<<1]%=mod;
        tag[u]=0;
    }
    void build(int u,int l,int r) {
        if(l==r) {
            sum[u]=w[l];
            sum[u]%=mod;
            return;
        } int mid=l+r>>1;
        build(u<<1,l,mid);
        build(u<<1|1,mid+1,r);
        push_up(u);
    }
    int query(int u,int l,int r,int ql,int qr) {
        if(ql<=l&&r<=qr) return sum[u];
        int mid=l+r>>1,res=0;
        push_down(u,l,r);
        if(ql<=mid) res+=query(u<<1,l,mid,ql,qr);
        if(qr>mid) res+=query(u<<1|1,mid+1,r,ql,qr);
        return res%mod;
    }
    void update(int u,int l,int r,int ql,int qr,int x) {
        if(ql<=l&&r<=qr) {
            sum[u]+=(r-l+1)*x;
            sum[u]%=mod;
            tag[u]+=x;
            tag[u]%=mod;
            return;
        } int mid=l+r>>1;
        push_down(u,l,r);
        if(ql<=mid) update(u<<1,l,mid,ql,qr,x);
        if(qr>mid) update(u<<1|1,mid+1,r,ql,qr,x);
        push_up(u);
    }
} seg;
//对 dfs 序建立线段树
void modify(int x,int y,int tt) {
    while(top[x]!=top[y]) {
        if(dep[top[x]]<dep[top[y]]) swap(x,y);
        seg.update(1,1,n,dfn[top[x]],dfn[x],tt);
        //一条重链的 dfs 序由于 rdfs 时的搜索顺序一定连续
        x=fat[top[x]];
    } //跳链知道两个点到达同一个链上
    if(dep[x]<dep[y]) swap(x,y);
    seg.update(1,1,n,dfn[y],dfn[x],tt);
    //跳链并进行修改
}
int ask(int x,int y) {
    int res=0;
    while(top[x]!=top[y]) {
        if(dep[top[x]]<dep[top[y]]) swap(x,y);
        res+=seg.query(1,1,n,dfn[top[x]],dfn[x]);
        res%=mod;
        x=fat[top[x]];
    } //跳链方式和原理与 modify 函数一样
    if(dep[x]<dep[y]) swap(x,y);
    res+=seg.query(1,1,n,dfn[y],dfn[x]);
    return res%mod;
    //跳链并进行查询
}
signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr),
    cout.tie(nullptr);
    cin>>n>>m>>r>>mod;
    for(int i=1;i<=n;++i) cin>>v[i];
    for(int i=1;i<n;++i) {
        int st,ed;
        cin>>st>>ed;
        adeg(st,ed);
        adeg(ed,st);
    } //输入一棵树
    dfs(r,0),rdfs(r,r);
    seg.build(1,1,n);
    for(int TT=1;TT<=m;++TT) {
        int op;
        cin>>op;
        if(op==1) {
            int x,y,z;
            cin>>x>>y>>z;
            modify(x,y,z);
        } else if(op==2) {
            int x,y;
            cin>>x>>y;
            cout<<ask(x,y)<<'\n';
        } else if(op==3) {
            int x,z;
            cin>>x>>z;
            seg.update(1,1,n,dfn[x],dfn[x]+sz[x]-1,z);
        } else if(op==4) {
            int x;
            cin>>x;
            cout<<seg.query(1,1,n,dfn[x],dfn[x]+sz[x]-1)<<'\n';
        } //题中描述的四种操作
    }
    return 0;
}
posted @ 2023-07-09 20:40  abensyl  阅读(176)  评论(0)    收藏  举报  来源
//雪花飘落效果