降维打击树形结构:从倍增 LCA 到重链剖分 (HLD) 的进阶实战

降维打击树形结构:从倍增 LCA 到重链剖分 (HLD) 的进阶实战

在算法竞赛(如 ICPC 或各类天梯赛)中,处理树上路径问题往往是拉开分数的关键。面对“求树上两点距离”或“对树上某条路径的所有节点加上一个值”,纯暴力的 DFS 会被极端数据(如退化成链的树)瞬间卡成 \(O(N^2)\)。

要优雅且高效地解决树上路径问题,我们需要两大核心武器:倍增法求最近公共祖先(LCA)与重链剖分(HLD)。本文将刨析这两种算法的底层逻辑,并提供 C++17 竞赛级标准模板。


一、 倍增魔法:最近公共祖先 (LCA)

LCA 的核心适用场景非常明确:静态树上的最近公共祖先、两点距离、判断祖先关系,或是需要将一条树上路径拆分到 LCA 处。

1. 核心思想与复杂度

暴力找 LCA 的方式是让两个点一步步往上爬,直到相遇。倍增算法的精髓在于:预处理出每个节点向上跳 \(2^0, 2^1, 2^2 \dots 2^k\) 步到达的祖先。查询时,利用二进制的思想“大步流星”地往上跳。
在复杂度方面,预处理需要 \(O(n \log n)\) 的时间,而每次 lca 或跳祖先查询仅需 \(O(\log n)\),空间复杂度为 \(O(n \log n)\)。

2. 竞赛级 C++17 模板

以下是高度封装的 LCA 模板,内置了深度与带权距离的维护:

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

struct LCA {
    int n, LOG;
    vector<vector<pair<int,ll>>> g;
    vector<vector<int>> up;
    vector<int> dep;
    vector<ll> dis;
    
    LCA(int n = 0) { init(n); }
    void init(int n_) {
        n = n_; LOG = 1;
        while ((1 << LOG) <= n) ++LOG;
        g.assign(n + 1, {}); up.assign(LOG, vector<int>(n + 1));
        dep.assign(n + 1, 0); dis.assign(n + 1, 0);
    }
    
    void addEdge(int u, int v, ll w = 1) { 
        g[u].push_back({v,w}); g[v].push_back({u,w}); 
    }
    
    void build(int root = 1) {
        queue<int> q; q.push(root); up[0][root] = root; // 根的父亲设为根本身
        vector<int> vis(n + 1); vis[root] = 1;
        while (!q.empty()) {
            int u = q.front(); q.pop();
            for (auto [v, w] : g[u]) if (!vis[v]) {
                vis[v] = 1; dep[v] = dep[u] + 1; dis[v] = dis[u] + w;
                up[0][v] = u; q.push(v);
            }
        }
        for (int j = 1; j < LOG; ++j)
            for (int v = 1; v <= n; ++v)
                up[j][v] = up[j - 1][up[j - 1][v]];
    }
    
    int jump(int u, int k) const {
        for (int j = 0; j < LOG; ++j) if (k >> j & 1) u = up[j][u];
        return u;
    }
    
    int lca(int u, int v) const {
        if (dep[u] < dep[v]) swap(u, v);
        u = jump(u, dep[u] - dep[v]);
        if (u == v) return u;
        for (int j = LOG - 1; j >= 0; --j)
            if (up[j][u] != up[j][v]) u = up[j][u], v = up[j][v];
        return up[0][u];
    }
    
    ll distance(int u, int v) const {
        int p = lca(u, v); return dis[u] + dis[v] - 2 * dis[p];
    }
};

避坑指南:

  • 在 build 初始化时,务必将根节点的父亲设为它自己,这样可以避免跳祖先时越界访问到未初始化的 0 号状态。
  • 必须严格区分边数深度 dep 和带权距离 dis,绝对不能混用。
  • 倍增算法的先天限制是只适用于静态树。如果题目包含动态换根或加边操作,则需要另行转化,或者直接上动态树结构(如 LCT)。

二、 极致的拍扁艺术:重链剖分 (HLD)

如果说 LCA 只是查询,那 HLD 就是树上修改的终极杀器。它的适用信号非常强烈:当题目在静态树上反复询问或修改任意两点间的路径,且底层操作能够处理数组区间(如区间和、区间最值、区间加法等)时,就是 HLD 登场的时刻。

1. 核心思想与复杂度

重链剖分通过两次 DFS,将树上的节点根据“子树大小”重新编号,使得每一条“重链”上的节点在内存中是连续的一段区间。这样,HLD 就能将树上任意一条路径巧妙地转成 \(O(\log n)\) 段一维区间。
搭配上线段树等数据结构后,预处理时间为 \(O(n)\),单次路径操作的时间复杂度被死死压在 \(O(\log^2 n)\)。

2. C++17 实战模板

以下是 HLD 的基础骨架,通过传入 lambda 表达式,它可以完美适配各种底层的线段树或树状数组:

#include <bits/stdc++.h>
using namespace std;

struct HLD {
    int n, timer = 0;
    vector<vector<int>> g;
    vector<int> parent, depth, heavy, head, pos, sz;
    
    HLD(int n = 0) { init(n); }
    void init(int n_) {
        n = n_; g.assign(n + 1, {});
        parent.assign(n + 1, 0); depth.assign(n + 1, 0);
        heavy.assign(n + 1, -1); head.assign(n + 1, 0);
        pos.assign(n + 1, 0); sz.assign(n + 1, 0); timer = 0;
    }
    
    void addEdge(int u, int v) { g[u].push_back(v); g[v].push_back(u); }
    
    int dfs1(int u, int p) {
        parent[u] = p; sz[u] = 1; int best = 0;
        for (int v : g[u]) if (v != p) {
            depth[v] = depth[u] + 1; int sub = dfs1(v, u); sz[u] += sub;
            if (sub > best) best = sub, heavy[u] = v;
        }
        return sz[u];
    }
    
    void dfs2(int u, int h) {
        head[u] = h; pos[u] = ++timer;
        if (heavy[u] != -1) dfs2(heavy[u], h);
        for (int v : g[u]) if (v != parent[u] && v != heavy[u]) dfs2(v, v);
    }
    
    void build(int root = 1) { depth[root] = 0; dfs1(root, 0); dfs2(root, root); }

    // 对路径 u-v 的每一段连续位置调用操作 op
    template<class F>
    void forEachPath(int u, int v, F op) const {
        while (head[u] != head[v]) {
            if (depth[head[u]] < depth[head[v]]) swap(u, v);
            op(pos[head[u]], pos[u]);
            u = parent[head[u]];
        }
        if (depth[u] > depth[v]) swap(u, v);
        op(pos[u], pos[v]); // 点权路径默认包含 LCA
    }
};

避坑指南:

  • 上述模板适用于点权问题。如果题目给定的值在边上,通常的处理手法是将边权赋给较深的一端节点,且在最后操作同链区间时,必须改为 pos[u] + 1 .. pos[v],以此排除掉 LCA 自身。
  • 在极长链的数据下,深链的递归操作有爆栈的风险。如果在 Windows 本地或栈空间较小的 OJ 平台上运行,需要改为迭代 DFS 或者手动扩大系统栈。
  • forEachPath 切割出的各段区间,在逻辑上并未严格按照原路径的方向排列。如果维护的是字符串哈希等与方向强相关的信息,必须分别维护正反方向,并自行控制拼接的顺序。

结语

从 LCA 的倍增跳跃,到 HLD 的连续空间映射,树形结构问题的解决思路完美诠释了计算机科学中“空间换时间”与“降维打击”的美学。掌握这两套模板并清晰认识它们的边界,足以应对绝大多数树上路径优化的挑战。

posted @ 2026-09-29 15:45  阿尹想学会C++  阅读(12)  评论(0)    收藏  举报