CC++ & Algorithm

树链剖分:把大树切成链,轻松找路径

较难23
语言版本:C++
概述:树链剖分是一种将树形结构分解成若干条链,从而用线段树等数据结构高效处理路径查询和修改问题的方法。

树链剖分详解:把大树切成链,轻松处理路径问题

树链剖分(重链剖分)是一种把树形结构拆成若干条链的算法,配合线段树等数据结构,可以高效地处理树上路径的查询和修改问题。比如:有一棵苹果树,每个节点上有若干个苹果,你想知道从根节点到某个子节点一共经过了多少苹果,或者把整条路径上的苹果数都加一个值。如果直接沿着树枝一个一个节点走,大树可能会很慢(最坏 O(n)),而树链剖分可以把任意路径变成 O(log n) 段连续的区间,然后用线段树一次处理一整段,速度飞快。

核心思想:重儿子与重链

想象你有一棵大树,树有很多分叉。我们要找出每个节点下子树大小最大的那个儿子(称为重儿子),然后把每个节点和它的重儿子连成一条重链。其他儿子(轻儿子)则各自开始一条新的重链。这样做的好处是:任何一条从根到叶子的路径,最多会被不超过 log N 条重链覆盖。这样,处理路径时只需处理每一条重链上的连续区间,而每条重链上的节点在 DFS 序上是连续的,所以可以用线段树快速操作。

生活类比:班级里要选组长。每个同学(节点)手下有若干小弟(子节点)。谁手下的小弟最多,谁就是重儿子(副班长)。然后副班长再选它手下小弟最多的人……这样形成一条骨干链。其他小团体各自独立成组。从班长到任何一个普通同学,最多经过 O(log 班级人数) 个小组长就能找到。

两次 DFS 详解

树链剖分需要两次深度优先搜索(DFS)来收集信息。

第一次 DFS:求父亲、深度、子树大小、重儿子

我们从根节点开始遍历整棵树,记录每个节点的:

  • fa[u]:父节点
  • dep[u]:深度(根深度为 0 或 1)
  • siz[u]:子树大小(包括自身)
  • son[u]:重儿子(子树大小最大的子节点,如果没有儿子则为 0)

代码示例(假设节点编号从 1 开始,图用邻接表存储):

void dfs1(int u, int f) {
    fa[u] = f;                // 父节点
    dep[u] = dep[f] + 1;      // 深度
    siz[u] = 1;               // 子树大小初始化为1(自己)
    for (int v : G[u]) {      // 遍历所有邻接点
        if (v == f) continue; // 跳过父节点
        dfs1(v, u);           // 递归子节点
        siz[u] += siz[v];     // 累加子树大小
        if (siz[v] > siz[son[u]]) son[u] = v;  // 更新重儿子
    }
}

第二次 DFS:求链顶、DFS 序(dfn)

第二次 DFS 按照“先重儿子,后轻儿子”的顺序给每个节点分配一个编号(dfn),使得每条重链上的节点编号连续。同时记录每个节点所在重链的顶端 top[u],以及 rnk[cnt] 表示编号 cnt 对应的原来节点编号。

为什么先走重儿子? 因为这样可以保证同一条重链上的节点被连续地访问,从而在 dfn 序上形成一段连续的区间。

int cnt = 0;  // dfs序计数器
void dfs2(int u, int t) {
    top[u] = t;              // 当前节点所在重链的顶端
    dfn[u] = ++cnt;          // 给节点分配dfs序编号
    rnk[cnt] = u;            // 反向映射
    if (!son[u]) return;     // 没有重儿子,即叶子节点
    dfs2(son[u], t);         // 先走重儿子,保持重链连续
    for (int v : G[u]) {
        if (v != fa[u] && v != son[u]) // 轻儿子
            dfs2(v, v);      // 轻儿子开始新的一条重链,顶端就是自己
    }
}

举例:假设有一棵二叉树:

         1
       /   \
      2     3
     / \   / \
    4   5 6   7

假设子树大小:siz[1]=7, siz[2]=3, siz[3]=3, siz[4]=1, siz[5]=1, siz[6]=1, siz[7]=1。对于节点1,重儿子为2(或3,任选最大,这里选2)。对于节点2,重儿子为4(或5,选4)。对于节点3,重儿子为6(或7)。所以重链为:1-2-4,3-6,5,7。第二次DFS会先走1-2-4这条链,分配dfn=1,2,3给1,2,4;然后回溯回来,走轻儿子5,分配dfn=4;再走节点3的重链3-6,分配dfn=5,6;最后走轻儿子7,分配dfn=7。最终dfn顺序为:1,2,4,5,3,6,7。注意重链上的节点dfn连续。

为什么路径被切成 O(log N) 条链?

这是因为每经过一条轻边(从轻儿子出发的边),子树大小至少减半。具体地,如果一个节点不是其父节点的重儿子,那么它的子树大小 ≤ 父节点子树大小的一半。所以从根到任意叶子,最多经过 O(log N) 条轻边,而每条重链之间由轻边连接,因此路径上最多有 O(log N) 条重链。

线段树维护:把树变成连续区间

经过第二次DFS后,树上的每个节点都有了唯一的dfn编号。而且任意一条重链上的节点dfn连续。我们可以用线段树(或树状数组、分块)来维护整个dfn序列上的信息(比如点权、路径和、最大值等)。线段树支持区间查询和区间修改,复杂度 O(log N) 每次操作。

线段树模板(用于路径和查询和区间加修改):

int seg[MAXN*4];    // 线段树数组,存储区间和
int lazy[MAXN*4];   // 懒标记,用于区间加

void build(int o, int l, int r) {
    if (l == r) {
        seg[o] = a[rnk[l]];  // a是原始节点权值,注意rnk[l]得到原节点编号
        return;
    }
    int mid = (l+r)/2;
    build(o*2, l, mid);
    build(o*2+1, mid+1, r);
    seg[o] = seg[o*2] + seg[o*2+1];
}

void pushdown(int o, int l, int r) {
    if (lazy[o]) {
        int mid = (l+r)/2;
        seg[o*2] += lazy[o] * (mid-l+1);
        seg[o*2+1] += lazy[o] * (r-mid);
        lazy[o*2] += lazy[o];
        lazy[o*2+1] += lazy[o];
        lazy[o] = 0;
    }
}

void update(int o, int l, int r, int ql, int qr, int v) {
    if (ql <= l && r <= qr) {
        seg[o] += v * (r-l+1);
        lazy[o] += v;
        return;
    }
    pushdown(o, l, r);
    int mid = (l+r)/2;
    if (ql <= mid) update(o*2, l, mid, ql, qr, v);
    if (qr > mid) update(o*2+1, mid+1, r, ql, qr, v);
    seg[o] = seg[o*2] + seg[o*2+1];
}

int query(int o, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return seg[o];
    pushdown(o, l, r);
    int mid = (l+r)/2;
    int res = 0;
    if (ql <= mid) res += query(o*2, l, mid, ql, qr);
    if (qr > mid) res += query(o*2+1, mid+1, r, ql, qr);
    return res;
}

路径查询与修改:跳链操作

现在我们有了线段树,可以快速处理一段连续区间的信息。对于任意路径 (u, v),我们需要把它拆成若干条完整的重链区间。基本思路是:让 uv 沿着各自的重链向上跳,直到它们位于同一条重链上。每次跳的时候,取当前链顶深度较大的那个节点,处理它所在的整条链(从链顶到该节点),然后跳到链顶的父节点,继续比较。

路径查询(求和):

int pathQuery(int u, int v) {
    int res = 0;
    while (top[u] != top[v]) {          // 不在同一条重链
        if (dep[top[u]] < dep[top[v]])  // 确保u所在链顶更深
            swap(u, v);
        res += query(1, 1, n, dfn[top[u]], dfn[u]); // 处理整条链的一段
        u = fa[top[u]];                 // u跳到链顶的父亲
    }
    // 现在 u 和 v 在同一条重链上
    if (dep[u] > dep[v]) swap(u, v);
    res += query(1, 1, n, dfn[u], dfn[v]); // 处理最后一段
    return res;
}

路径修改(区间加):

void pathUpdate(int u, int v, int val) {
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) swap(u, v);
        update(1, 1, n, dfn[top[u]], dfn[u], val);
        u = fa[top[u]];
    }
    if (dep[u] > dep[v]) swap(u, v);
    update(1, 1, n, dfn[u], dfn[v], val);
}

注意:在最后一段中,dfn[u]dfn[v] 对应的是 uv 的路径(u是深度较小的那个,即较浅的点),之所以包含 u,是因为路径可能包括 uv 的 LCA(最近公共祖先)。如果需要排除 LCA,需要特殊处理。

完整可运行的示例

下面是一个完整的 C++ 程序,实现了树链剖分 + 线段树,支持路径求和与路径区间加。假设有 n 个节点,m 次操作,操作为:

  • C u v w:路径 u-v 上所有点权加 w
  • Q u v:查询路径 u-v 上所有点权之和
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;

int n, m, root;
vector<int> G[MAXN];        // 邻接表
int a[MAXN];                // 原始点权

// 树链剖分所需数组
int fa[MAXN];               // 父节点
int dep[MAXN];              // 深度
int siz[MAXN];              // 子树大小
int son[MAXN];              // 重儿子(0表示没有)
int top[MAXN];              // 链顶
int dfn[MAXN];              // dfs序编号
int rnk[MAXN];              // 编号到原节点映射
int cnt;                    // 编号计数器

// 线段树
int seg[MAXN*4];            // 区间和
int lazy[MAXN*4];           // 懒标记

// 第一次DFS
void dfs1(int u, int f) {
    fa[u] = f;
    dep[u] = dep[f] + 1;
    siz[u] = 1;
    son[u] = 0;  // 初始无重儿子
    for (int v : G[u]) {
        if (v == f) continue;
        dfs1(v, u);
        siz[u] += siz[v];
        if (siz[v] > siz[son[u]]) son[u] = v;
    }
}

// 第二次DFS
void dfs2(int u, int t) {
    top[u] = t;
    dfn[u] = ++cnt;
    rnk[cnt] = u;
    if (!son[u]) return;             // 叶子
    dfs2(son[u], t);                 // 先走重儿子
    for (int v : G[u]) {
        if (v != fa[u] && v != son[u])
            dfs2(v, v);              // 轻儿子另起链
    }
}

// 线段树构建
void build(int o, int l, int r) {
    if (l == r) {
        seg[o] = a[rnk[l]];
        return;
    }
    int mid = (l + r) / 2;
    build(o*2, l, mid);
    build(o*2+1, mid+1, r);
    seg[o] = seg[o*2] + seg[o*2+1];
}

// 下传懒标记
void pushdown(int o, int l, int r) {
    if (lazy[o]) {
        int mid = (l + r) / 2;
        seg[o*2] += lazy[o] * (mid - l + 1);
        seg[o*2+1] += lazy[o] * (r - mid);
        lazy[o*2] += lazy[o];
        lazy[o*2+1] += lazy[o];
        lazy[o] = 0;
    }
}

// 区间加
void update(int o, int l, int r, int ql, int qr, int v) {
    if (ql <= l && r <= qr) {
        seg[o] += v * (r - l + 1);
        lazy[o] += v;
        return;
    }
    pushdown(o, l, r);
    int mid = (l + r) / 2;
    if (ql <= mid) update(o*2, l, mid, ql, qr, v);
    if (qr > mid) update(o*2+1, mid+1, r, ql, qr, v);
    seg[o] = seg[o*2] + seg[o*2+1];
}

// 区间求和
int query(int o, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return seg[o];
    pushdown(o, l, r);
    int mid = (l + r) / 2;
    int res = 0;
    if (ql <= mid) res += query(o*2, l, mid, ql, qr);
    if (qr > mid) res += query(o*2+1, mid+1, r, ql, qr);
    return res;
}

// 路径加
void pathUpdate(int u, int v, int val) {
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) swap(u, v);
        update(1, 1, n, dfn[top[u]], dfn[u], val);
        u = fa[top[u]];
    }
    if (dep[u] > dep[v]) swap(u, v);
    update(1, 1, n, dfn[u], dfn[v], val);
}

// 路径求和
int pathQuery(int u, int v) {
    int res = 0;
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) swap(u, v);
        res += query(1, 1, n, dfn[top[u]], dfn[u]);
        u = fa[top[u]];
    }
    if (dep[u] > dep[v]) swap(u, v);
    res += query(1, 1, n, dfn[u], dfn[v]);
    return res;
}

int main() {
    // 示例输入:
    // 第一行:n m root
    // 第二行:n个点权
    // 接下来n-1行:边
    // 接下来m行:操作
    ios::sync_with_stdio(false);
    cin.tie(0);
    
    cin >> n >> m >> root;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        G[u].push_back(v);
        G[v].push_back(u);
    }
    
    // 树链剖分预处理
    dfs1(root, 0);      // 根的父亲设为0
    dfs2(root, root);   // 根链顶为自身
    build(1, 1, n);     // 建线段树
    
    while (m--) {
        char op;
        int u, v, w;
        cin >> op;
        if (op == 'C') {
            cin >> u >> v >> w;
            pathUpdate(u, v, w);
        } else if (op == 'Q') {
            cin >> u >> v;
            cout << pathQuery(u, v) << '\n';
        }
    }
    return 0;
}

新手常见错误

  1. 忘记更新子树大小 siz:DFS1 中需要先递归子节点再累加 siz
  2. 重儿子比较条件用错if (siz[v] > siz[son[u]]) 注意 son[u] 初始为 0,siz[0] 未定义,最好在循环外初始化为 0,或者用 if (siz[v] > siz[son[u]] || son[u]==0)
  3. 第二次 DFS 中忘记递归轻儿子:必须遍历所有非父非重儿子的子节点并对其调用 dfs2(v, v)
  4. 跳链时比较深度用错:应该比较 dep[top[u]]dep[top[v]],而不是 dep[u]dep[v],因为我们要确保每次处理的是较深的那条链。
  5. 线段树区间边界dfn 从 1 到 n,注意数组大小开 4 倍。
  6. 路径修改时最后一段的边界:当 uv 在同一条重链上时,需要保证 dfn[u] <= dfn[v] 且区间覆盖了从较浅点到较深点的所有节点。如果只需要不包括 LCA,则需特殊处理(如 u 为 LCA 时,从 son[u] 开始)。
  7. 多组数据时清空数组:注意清空邻接表和数组,尤其是 sontop

相关指引

  • 子树操作:由于 dfn 序中,一棵子树的所有节点也对应一段连续区间(因为 DFS 进入和离开子树的时间区间),所以也可以直接用线段树处理子树查询和修改,无需跳链。
  • 求 LCA:树链剖分可以快速求 LCA(通过不断跳链,直到链顶相同,然后深度小的就是 LCA)。实际上,pathQuery 中的跳链操作就是在寻找 LCA 的过程。
  • 树链剖分的其他应用:可以用来维护路径上的最大值、最小值、区间异或、区间取反等,只需修改线段树的操作。
  • 与树分块的区别:树分块(如树上莫队)适合离线的路径查询,而树链剖分适合在线的动态修改和查询,且支持区间修改(如给路径加值)。
  • 相关算法:树链剖分常与 LCT(Link-Cut Tree)对比,LCT 更灵活但常数大,树链剖分较简单且适合静态树,配合线段树效率高。

掌握了树链剖分,复杂的树上路径问题就变成了熟悉的区间操作,是竞赛中解决树上问题的利器。

例题精讲

1单选题

在树链剖分中,对于一个节点,其所有子节点中子树节点数最多的子节点称为?

A重儿子
B轻儿子
C父节点
D叶子节点
2判断题

树链剖分中,每个节点到根节点的路径上最多经过O(log n)条重链。

3填空题
以下为树链剖分第一次DFS的部分代码,请补全找出重儿子的部分:
void dfs1(int u, int f) {
    size[u]=1; fa[u]=f; dep[u]=dep[f]+1;
    int maxson=0;
    for(int v:g[u]){
        if(v==f) continue;
        dfs1(v,u);
        size[u]+=size[v];
        if(size[v] > maxson) {
            maxson=size[v];
            ___ = v;
        }
    }
}
4单选题

树链剖分后,通常使用什么数据结构来维护每条重链上的信息?

A平衡树
B线段树
CST表
D分块
5判断题

树链剖分中,每个节点在链中的位置(dfs序)是连续的,并且同一重链上节点的dfs序也连续。