NeFut Logo NeFut
EN 管理员登录

树的重链剖分:优化树形结构的高效算法与实现

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #Tree #Data Structure

核心逻辑与数学原理

树的重链剖分(Heavy Path Decomposition)是一种将树形拓扑结构无损转换为线性结构的先进技术。其核心动机在于打破树形分支的离散性,使树上任意两条节点路径或子树区间能够同构映射为最高 $O(\log N)$ 段连续的内存区间,从而利用线段树或树状数组实现 $O(\log N)$ 级的动态维护。

1. 核心定义

对于树中任意非叶子节点 $u$:

2. 数学定理与复杂度证明

定理:从任意节点 $u$ 向上走向根节点的路径中,经过的轻边数量和重链断开跳转的次数,严格不大于 $\log_2 N$。 证明:根据重儿子定义,当从节点 $u$ 沿着一条轻边走向其父节点 $f$ 时,必然满足 $\text{siz}[u] \le \frac{1}{2} \text{siz}[f]$。也就是说,每经过一条轻边,当前所在的子树规模至少翻倍。由于树的总节点数为 $N$,这种翻倍操作最多发生 $\log_2 N$ 次。由此可证,任何树上路径都可以被拆解为最高 $O(\log N)$ 段重链区间。


状态设计与算法推导

实现重链剖分需要两轮独立的 DFS 过程。

1. 第一轮 DFS (预处理拓扑属性)

定义状态数组:

状态转移方程

$$\text{siz}[u] = 1 + \sum_{v \in \text{adj}[u], v \neq \text{fa}[u]} \text{siz}[v]$$

$$\text{son}[u] = \arg\max_{v} \{\text{siz}[v] \mid v \in \text{adj}[u], v \neq \text{fa}[u]\}$$

2. 第二轮 DFS (剖分链并确定线性序列)

定义状态数组:

剖分策略

  1. 重儿子优先原则:在向下递归时,必须首先对重儿子 $\text{son}[u]$ 进行深度遍历。这保证了同一条重链上的所有节点在 $\text{dfn}$ 序列中占有一段连续的内存地址。
  2. 状态传递:
    • 对于重儿子 $\text{son}[u]$,其链顶直接继承:$\text{top}[\text{son}[u]] = \text{top}[u]$。
    • 对于任意轻儿子 $v$,其自成新链起点,链顶为其自身:$\text{top}[v] = v$。

C++ 标准源码

#include <iostream>
#include <vector>
#include <algorithm>

const int MAXN = 200005;

std::vector<int> adj[MAXN];
int fa[MAXN], dep[MAXN], siz[MAXN], son[MAXN];
int top[MAXN], dfn[MAXN], rnki[MAXN], tim;

// 第一遍 DFS:计算 fa, dep, siz, son
void dfs1(int u, int p) {
    fa[u] = p;
    dep[u] = dep[p] + 1;
    siz[u] = 1;
    son[u] = 0; // 初始化无重儿子

    int max_siz = 0;
    for (size_t i = 0; i < adj[u].size(); ++i) {
        int v = adj[u][i];
        if (v == p) continue;

        dfs1(v, u);
        siz[u] += siz[v];
        if (siz[v] > max_siz) {
            max_siz = siz[v];
            son[u] = v; // 更新重儿子
        }
    }
}

// 第二遍 DFS:计算 top, dfn,注意重儿子优先
void dfs2(int u, int t) {
    top[u] = t;
    tim++;
    dfn[u] = tim;
    rnki[tim] = u; // 映射反查:线性索引对应的原树节点

    if (!son[u]) return; // 叶子节点断点

    // 致命踩坑点:必须且只能首先对重儿子进行递归,确保重链序列连续
    dfs2(son[u], t);

    for (size_t i = 0; i < adj[u].size(); ++i) {
        int v = adj[u][i];
        if (v == fa[u] || v == son[u]) continue;
        dfs2(v, v); // 轻儿子自成新链,链顶为其自身
    }
}

// 跳重链求 LCA(工业标准,常数极小,吊打倍增)
int lca_by_heavy_edge(int u, int v) {
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) {
            std::swap(u, v);
        }
        u = fa[top[u]]; // 将链顶更深的节点向上跳到其父节点处
    }
    return dep[u] < dep[v] ? u : v;
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, root;
    if (!(std::cin >> n >> root)) return 0;

    for (int i = 1; i < n; ++i) {
        int u, v;
        std::cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dep[0] = 0;
    tim = 0;

    dfs1(root, 0);
    dfs2(root, root);

    // 输出每个点拍平后的线性序列位置
    for (int i = 1; i <= n; ++i) {
        std::cout << "Node " << i << " Map to Segment-Tree Index: " << dfn[i] << "\n";
    }

    return 0;
}

NOIP 实战避坑指南

  1. 第二遍 DFS 中混淆重儿子判定条件:dfs2 遍历邻接表时,极易漏掉 if (v == son[u]) continue; 导致对重儿子进行二次深度遍历。这不仅会完全破坏重链的线性物理地址连续性,让后续的线段树区间查询彻底失效,还会因为死循环产生 SIGSEGV
  2. 多组数据未初始化 timson 数组: son 数组存储的是重儿子指针。如果有多组测试数据,未将 son 数组全部清零,在遇到叶子节点时,其原本没有重儿子,却可能读取到上一组数据的残留脏数据,导致 dfs2 继续向下调用不存在的节点,引发内存非法越界。

经典 NOIP/洛谷 真题

1. 洛谷 P2590 [ZJOI2008] 树的统计

2. 洛谷 P2146 [NOI2015] 软件包管理器

原文链接: local://9.4

[h] 返回首页