【学习笔记】重链剖分

1

导论

在计算机科学中,我们处理线性结构(如数组、字符串、链表)是非常高效的。因为线性结构拥有一个极其强大的特性:连续性。有了连续性,我们就可以使用前缀和、双指针、线段树、树状数组等工具,在 \(O(\log N)\) 甚至 \(O(1)\) 的时间内完成区间操作。

然而,树(Tree)是一种典型的非线性结构。它具有层级关系,节点之间通过边连接。如果你想查询“以节点 \(u\) 为根的子树内所有节点的权值之和”,在原始的树结构中,你必须通过递归(DFS)遍历整个子树,时间复杂度是 \(O(SubtreeSize)\)。在最坏情况下(树退化成一条链),单次查询需要 \(O(N)\)

那么,有没有一种方法,能把一棵“立体”的树,“拍扁”成一条“平面”的线,且在拍扁的过程中,依然能够保持子树的连续性?

这就是 DFS 序(DFS Order) 的核心目的。


DFS 序

1.1 DFS回顾

在进入 DFS 序之前,请你在脑海中重新运行一次 DFS 的过程。
当我们从根节点 \(r\) 开始 DFS 时,算法的行为是:尽可能深地探索,直到无法继续,然后回溯。

这个过程在内存中是由递归栈(Call Stack)维护的。一个节点 \(u\) 被访问时,它会被压入栈中;只有当 \(u\) 的所有子节点都被全部访问完毕后,\(u\) 才会从栈中弹出。

1.2 时间戳(Time Stamp)

为了记录 DFS 的轨迹,我们引入一个全局计数器 timer(初始值为 0)。
每当我们第一次进入一个节点 \(u\) 时,我们将当前的 timer 赋值给 \(u\),然后 timer 自增 1。这个值就叫做节点的 DFN 序(Depth First Number),记作 \(dfn[u]\)

案例模拟

假设有一棵简单的树:

  • 1 是根,连接 2 和 3
  • 2 连接 4 和 5
  • 3 连接 6

DFS 遍历顺序可能是: \(1 \rightarrow 2 \rightarrow 4 \rightarrow 5 \rightarrow 3 \rightarrow 6\)

对应的 DFN 序:

  • \(dfn[1] = 1\)
  • \(dfn[2] = 2\)
  • \(dfn[4] = 3\)
  • \(dfn[5] = 4\)
  • \(dfn[3] = 5\)
  • \(dfn[6] = 6\)

1.3 进出时间戳(Entry and Exit Time)

仅仅记录进入时间 \(dfn[u]\) 是不够的,因为我们不知道一个子树在什么时候“结束”。
因此,我们引入出栈时间戳 \(out[u]\):当我们完成对节点 \(u\) 及其所有子树的访问,准备从 \(u\) 回溯到其父节点之前,我们记录当前的 timer

修正后的模拟:

  1. 进入 1:\(dfn[1]=1\), timer \(\to\) 2
  2. 进入 2:\(dfn[2]=2\), timer \(\to\) 3
  3. 进入 4:\(dfn[4]=3\), timer \(\to\) 4
  4. 离开 4:\(out[4]=4\), timer \(\to\) 5 (注:有些实现中 timer 不在离开时增加,这里我们采用增加方案以便更清晰地界定区间)
    ... 以此类推。

关键结论:
对于任何节点 \(u\),它的所有后代节点 \(v\) 满足:

\[dfn[u] \le dfn[v] \le out[u] \]

这意味着:在 DFS 序的线性数组中,节点 \(u\) 的子树被完美地映射为了一个连续的区间 \([dfn[u], out[u]]\)


第 2 章:子树映射

2.1 为什么子树一定是连续的?

这是一个非常关键的逻辑点,初学者必须彻底理解。

证明思路:
DFS 的特性是“先深后广”。当你进入节点 \(u\) 时,在 DFS 算法离开 \(u\) 之前,它绝对不会访问任何不属于 \(u\) 子树的节点。

  • 所有的子节点 \(v_1, v_2, \dots\) 必须在 \(u\) 进入之后被访问。
  • 所有的子节点及其后代必须在 \(u\) 离开之前被访问完毕。
  • 因此,在 timer 的时间轴上,从 \(dfn[u]\)\(out[u]\) 之间出现的所有时间戳,全部且仅属于 \(u\) 的子树。

2.2 映射关系的两种形式

在实际竞赛中,你会看到两种主流的 DFS 序记录方式:

形式 A:入栈序 \(\rightarrow\) 子树区间

  • 只记录 \(dfn[u]\)
  • 记录每个子树的大小 \(sz[u]\)
  • 子树区间为 \([dfn[u], dfn[u] + sz[u] - 1]\)
  • 适用场景:大多数树链剖分、子树求和问题。

形式 B:入出栈序(欧拉序)

  • 同时记录 \(dfn[u]\)\(out[u]\)
  • 子树区间为 \([dfn[u], out[u]]\)
  • 适用场景:需要处理节点进入和离开不同操作的问题(如某些复杂的树形 DP)。

第 3 章:区间操作实现

既然子树变成了区间,那么原本复杂的“树上操作”就变成了简单的“区间操作”。

3.1 转换表

树上操作 线性映射后操作 推荐数据结构 时间复杂度
修改节点 \(u\) 的权值 修改数组中 \(dfn[u]\) 位置的值 树状数组 / 线段树 \(O(\log N)\)
查询子树 \(u\) 的权值和 查询区间 \([dfn[u], dfn[u]+sz[u]-1]\) 的和 树状数组 / 线段树 \(O(\log N)\)
修改子树 \(u\) 所有权值 \(+v\) 区间修改 \([dfn[u], dfn[u]+sz[u]-1]\) 线段树 (Lazy Tag) \(O(\log N)\)

3.2 细节

  1. 递归深度:对于 \(N=10^5\) 的数据,默认栈空间可能不足。在 C++ 中,可以使用 #pragma comment(linker, "/STACK:1024000000") 或在 Linux 下使用 ulimit -s unlimited
  2. 邻接表顺序:DFS 遍历子节点的顺序不影响子树连续性,但会影响具体的 \(dfn\) 值。在对比答案时,请确保遍历顺序一致。
  3. 1-indexed vs 0-indexed:建议 \(dfn\) 从 1 开始,因为树状数组(BIT)不支持 0 号下标。

Code

学会如何把一棵树拍扁成一个数组。

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

using namespace std;

const int MAXN = 100005;
vector<int> adj[MAXN]; // 邻接表存储树
int dfn[MAXN];         // 记录节点 u 第一次被访问的时间戳
int sz[MAXN];          // 记录以节点 u 为根的子树大小
int rev[MAXN];         // 逆映射:dfn[u] = x  ==> rev[x] = u
int timer = 0;         // 全局时间戳计数器

// 核心 DFS 函数
void dfs(int u, int fa) {
    // 1. 记录进入时间戳
    dfn[u] = ++timer; 
    rev[timer] = u;    // 记录这个时间戳对应的是哪个节点
    sz[u] = 1;         // 初始子树大小为 1(节点本身)

    for (int v : adj[u]) {
        if (v == fa) continue; // 防止在无向图中跑回父节点
      
        dfs(v, u);             // 递归进入子节点
      
        // 2. 关键:子树大小是所有子节点子树大小之和 + 1
        sz[u] += sz[v]; 
    }
    // 此时,节点 u 的子树在 dfn 数组中的区间是 [dfn[u], dfn[u] + sz[u] - 1]
}

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

    dfs(1, 0); // 从根节点 1 开始,父节点设为 0

    cout << "节点 | DFN序 | 子树大小 | 子树区间" << endl;
    for (int i = 1; i <= n; i++) {
        cout << i << "    | " << dfn[i] << "      | " << sz[i] 
             << "       | [" << dfn[i] << ", " << dfn[i] + sz[i] - 1 << "]" << endl;
    }
    return 0;
}

DFS 序 + 树状数组(P2800)

这个阶段的目标是:利用 DFN 序,将“子树求和”转化为“区间求和”。

#include <iostream>
#include <vector>

using namespace std;

const int MAXN = 100005;
vector<int> adj[MAXN];
int dfn[MAXN], sz[MAXN], timer = 0;
long long bit[MAXN]; // 树状数组 (Binary Indexed Tree)
int n;

// --- 树状数组标准操作 ---
void update(int x, int val) {
    for (; x <= n; x += x & -x) bit[x] += val;
}

long long query(int x) {
    long long res = 0;
    for (; x > 0; x -= x & -x) res += bit[x];
    return res;
}

long long query_range(int l, int r) {
    return query(r) - query(l - 1);
}
// -----------------------

void dfs(int u, int fa) {
    dfn[u] = ++timer;
    sz[u] = 1;
    for (int v : adj[u]) {
        if (v != fa) {
            dfs(v, u);
            sz[u] += sz[v];
        }
    }
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0); // 加速 IO
    cin >> n;
  
    // 初始权重输入
    vector<int> weight(n + 1);
    for (int i = 1; i <= n; i++) cin >> weight[i];

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

    dfs(1, 0);

    // 重要:将树上的权值映射到 dfn 序对应的线性数组中
    for (int i = 1; i <= n; i++) {
        update(dfn[i], weight[i]);
    }

    int m; cin >> m;
    while (m--) {
        int opt; cin >> opt;
        if (opt == 1) { // 单点修改
            int u, val; cin >> u >> val;
            // 计算增量,因为 BIT 是累加更新
            int diff = val - weight[u];
            weight[u] = val; 
            update(dfn[u], diff); // 修改 dfn[u] 位置的值
        } else { // 子树查询
            int u; cin >> u;
            // 子树 u  ==>  区间 [dfn[u], dfn[u] + sz[u] - 1]
            cout << query_range(dfn[u], dfn[u] + sz[u] - 1) << "\n";
        }
    }
    return 0;
}

P1689 / P3099

这个阶段的目标是:处理“子树区间修改”(如:将子树内所有节点全部加上 \(v\))。 树状数组难以处理区间修改,此时必须使用线段树。

#include <iostream>
#include <vector>

using namespace std;

const int MAXN = 100005;
vector<int> adj[MAXN];
int dfn[MAXN], sz[MAXN], timer = 0;

struct Node {
    long long sum;
    long long lazy;
} tree[MAXN * 4]; // 线段树空间需开 4 倍

// 向上更新
void pushup(int p) {
    tree[p].sum = tree[p << 1].sum + tree[p << 1 | 1].sum;
}

// 向下传递懒标记
void pushdown(int p, int l, int r) {
    if (tree[p].lazy != 0) {
        int mid = (l + r) >> 1;
        tree[p << 1].lazy += tree[p].lazy;
        tree[p << 1 | 1].lazy += tree[p].lazy;
        tree[p << 1].sum += tree[p].lazy * (mid - l + 1);
        tree[p << 1 | 1].sum += tree[p].lazy * (r - mid);
        tree[p].lazy = 0;
    }
}

// 区间修改
void update(int p, int l, int r, int qL, int qR, int val) {
    if (qL <= l && r <= qR) {
        tree[p].sum += (long long)val * (r - l + 1);
        tree[p].lazy += val;
        return;
    }
    pushdown(p, l, r);
    int mid = (l + r) >> 1;
    if (qL <= mid) update(p << 1, l, mid, qL, qR, val);
    if (qR > mid) update(p << 1 | 1, mid + 1, r, qL, qR, val);
    pushup(p);
}

// 区间查询
long long query(int p, int l, int r, int qL, int qR) {
    if (qL <= l && r <= qR) return tree[p].sum;
    pushdown(p, l, r);
    int mid = (l + r) >> 1;
    long long res = 0;
    if (qL <= mid) res += query(p << 1, l, mid, qL, qR);
    if (qR > mid) res += query(p << 1 | 1, mid + 1, r, qL, qR);
    return res;
}

void dfs(int u, int fa) {
    dfn[u] = ++timer;
    sz[u] = 1;
    for (int v : adj[u]) {
        if (v != fa) {
            dfs(v, u);
            sz[u] += sz[v];
        }
    }
}

int main() {
    int n, m; cin >> n >> m;
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }

    dfs(1, 0);

    while (m--) {
        int opt; cin >> opt;
        if (opt == 1) { // 子树修改
            int u, val; cin >> u >> val;
            update(1, 1, n, dfn[u], dfn[u] + sz[u] - 1, val);
        } else { // 子树查询
            int u; cin >> u;
            cout << query(1, 1, n, dfn[u], dfn[u] + sz[u] - 1) << "\n";
        }
    }
    return 0;
}

1. 为什么 sz[u] 这么重要?

在代码中,你会发现所有的查询区间都是 [dfn[u], dfn[u] + sz[u] - 1]

  • dfn[u] 是子树的起点
  • sz[u] 是子树的长度
  • 起点 + 长度 - 1 = 终点
    这就是 DFS 序最精妙的地方:它把一个复杂的树形结构,变成了一个简单的长度为 \(\text{sz}[u]\) 的连续段

2. 复杂度分析

  • DFS 预处理:每个节点访问一次 \(\rightarrow O(N)\)
  • 单点/区间操作:线段树/树状数组 \(\rightarrow O(\log N)\)
  • 总时间复杂度\(O(N + M \log N)\)。这在 \(N=10^5\) 时运行速度极快(通常在 100ms-300ms 之间)。

3. 常见 Bug 检查清单

如果说【第一卷】是将树“拍扁”成线,那么【第二卷】就是将树“切”成线。

重链剖分(Heavy-Light Decomposition, HLD) 是树形数据结构中的一个里程碑。它解决了一个极其核心的痛点:DFS 序虽然能处理子树,但无法处理路径。

在标准的 DFS 序中,一个节点与其父节点的 dfn 并不一定连续,更不用说一条从 \(u\)\(v\) 的长路径了。重链剖分的精髓在于:通过一种特殊的 DFS 顺序,把树分成了若干条连续的“链”,使得任何一条路径都可以由 \(O(\log N)\) 条连续区间组成。


2

重链剖分

1.1 路径查询?

想象你在一个巨大的城市(树)中,要从地点 \(u\) 走到地点 \(v\)

  • 如果你每次只能走一步(移动到父节点),最坏情况下你需要走 \(N\) 步。
  • 如果你有一张地图,告诉你哪些路是“高速公路”(连续区间),而哪些路是“乡间小路”(跳链),你就能快速到达。

重链剖分的目标就是:在树中构建尽可能多的“高速公路”,使得从任意点到根节点的路径上,经过的“乡间小路”数量尽可能少。

1.2 重儿子(Heavy Son)的定义

对于节点 \(u\),它的所有子节点中,子树大小 \(\text{sz}[v]\) 最大的那个节点 \(v\) 被定义为 \(u\)重儿子

  • 重边:连接 \(u\) 与其重儿子的边。
  • 轻边:连接 \(u\) 与其非重儿子的边。

贪心策略:如果我们优先沿着重边走,我们就能在尽可能长的时间内保持在同一条“高速公路”上。

1.3 核心定理:跳跃次数上限

定理:从节点 \(u\) 到根节点 \(r\) 的路径上,经过的轻边数量不会超过 \(\log_2 N\) 条。

证明(直觉):每次你经过一条轻边跳到另一条链时,你所处的新子树的大小至少只有原子树的一半(因为你跳走的是非重儿子,而重儿子占据了超过一半的规模)。一个规模为 \(N\) 的集合,连续折半最多 \(\log_2 N\) 次就会变成 1。


两次 DFS

重链剖分需要两次 DFS 来完成。第一次是“测量”,第二次是“铺路”。

2.1 第一次 DFS(预处理)

目标:计算每个节点的深度 dep、父节点 fa、子树大小 sz,并找出重儿子 son

void dfs1(int u, int fa, int d) {
    fa[u] = fa; dep[u] = d; sz[u] = 1;
    int max_sz = -1;
    for (int v : adj[u]) {
        if (v == fa) continue;
        dfs1(v, u, d + 1);
        sz[u] += sz[v];
        if (sz[v] > max_sz) {
            max_sz = sz[v];
            son[u] = v; // 记录重儿子
        }
    }
}

2.2 第二次 DFS(打平)

目标:确定每个节点的 top(所在重链的顶端节点)以及特殊的 DFN 序

极其关键的一点:在 DFS 遍历子节点时,必须优先遍历重儿子
这样做的结果是:同一条重链上的所有节点,其 DFN 序是连续的。

void dfs2(int u, int t) {
    top[u] = t;            // u 所在链的顶端
    dfn[u] = ++timer;      // 记录 DFN 序
  
    if (!son[u]) return;    // 叶子节点直接返回
  
    // 1. 优先走重儿子,且重儿子继承当前的链顶 t
    dfs2(son[u], t); 
  
    // 2. 再走轻儿子,且轻儿子开启一条新链,链顶是它自己
    for (int v : adj[u]) {
        if (v == fa[u] || v == son[u]) continue;
        dfs2(v, v); 
    }
}

路径跳跃算法 (HLD Query)

现在,我们有了 top[u] 和连续的 dfn。如何查询 \(u\)\(v\) 的路径?

3.1 跳链逻辑

我们采取“谁的链顶深,谁先跳”的策略:

  1. 检查 \(u\)\(v\) 是否在同一条链上(即 top[u] == top[v])。
  2. 如果不在,将 top 较深的那个节点 \(u\) 向上跳到 fa[top[u]]
  3. 在跳跃过程中,节点 \(u\)top[u] 的这一段路径在 DFN 序中是连续的:[dfn[top[u]], dfn[u]]。我们将这个区间交给线段树处理。
  4. 重复此过程,直到 \(u, v\) 在同一条链上。
  5. 最后,处理同一条链上 \(u, v\) 之间的剩余区间:[min(dfn[u], dfn[v]), max(dfn[u], dfn[v])]

3.2 复杂度分析

  • 每次跳链,轻边数量减少。
  • 最多跳 \(\log N\) 次链。
  • 每次跳链进行一次线段树操作 \(O(\log N)\)
  • 总时间复杂度\(O(\log^2 N)\)

代码实现(以 P1722 为例)

P1722 要求路径修改和路径查询。这是最标准的 HLD 模板。

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

using namespace std;

const int MAXN = 100005;
vector<int> adj[MAXN];
int fa[MAXN], dep[MAXN], sz[MAXN], son[MAXN], top[MAXN], dfn[MAXN], timer;
int weight[MAXN], rev[MAXN]; // rev 用于将 dfn 映射回权值

// --- 线段树部分 (此处省略具体 pushdown/pushup, 逻辑同第一卷) ---
struct SegmentTree {
    long long sum[MAXN * 4], lazy[MAXN * 4];
    void pushup(int p) { sum[p] = sum[p << 1] + sum[p << 1 | 1]; }
    void pushdown(int p, int l, int r) {
        if (lazy[p]) {
            int mid = (l + r) >> 1;
            lazy[p << 1] += lazy[p];
            sum[p << 1] += lazy[p] * (mid - l + 1);
            lazy[p << 1 | 1] += lazy[p];
            sum[p << 1 | 1] += lazy[p] * (r - mid);
            lazy[p] = 0;
        }
    }
    void update(int p, int l, int r, int qL, int qR, int val) {
        if (qL <= l && r <= qR) {
            sum[p] += (long long)val * (r - l + 1);
            lazy[p] += val;
            return;
        }
        pushdown(p, l, r);
        int mid = (l + r) >> 1;
        if (qL <= mid) update(p << 1, l, mid, qL, qR, val);
        if (qR > mid) update(p << 1 | 1, mid + 1, r, qL, qR, val);
        pushup(p);
    }
    long long query(int p, int l, int r, int qL, int qR) {
        if (qL <= l && r <= qR) return sum[p];
        pushdown(p, l, r);
        int mid = (l + r) >> 1;
        long long res = 0;
        if (qL <= mid) res += query(p << 1, l, mid, qL, qR);
        if (qR > mid) res += query(p << 1 | 1, mid + 1, r, qL, qR);
        return res;
    }
} st;

// --- HLD 核心部分 ---

void dfs1(int u, int f, int d) {
    fa[u] = f; dep[u] = d; sz[u] = 1;
    for (int v : adj[u]) {
        if (v == f) continue;
        dfs1(v, u, d + 1);
        sz[u] += sz[v];
        if (sz[v] > sz[son[u]]) son[u] = v;
    }
}

void dfs2(int u, int t) {
    top[u] = t;
    dfn[u] = ++timer;
    rev[timer] = u; // 记录 dfn 对应的原节点
    if (!son[u]) return;
    dfs2(son[u], t); // 优先重儿子
    for (int v : adj[u]) {
        if (v == fa[u] || v == son[u]) continue;
        dfs2(v, v); // 轻儿子开新链
    }
}

// 路径更新:u -> v
void update_path(int u, int v, int val, int n) {
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) swap(u, v);
        st.update(1, 1, n, dfn[top[u]], dfn[u], val);
        u = fa[top[u]];
    }
    if (dep[u] > dep[v]) swap(u, v);
    st.update(1, 1, n, dfn[u], dfn[v], val);
}

// 路径查询:u -> v
long long query_path(int u, int v, int n) {
    long long res = 0;
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) swap(u, v);
        res += st.query(1, 1, n, dfn[top[u]], dfn[u]);
        u = fa[top[u]];
    }
    if (dep[u] > dep[v]) swap(u, v);
    res += st.query(1, 1, n, dfn[u], dfn[v]);
    return res;
}

int main() {
    int n, m; cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> weight[i];
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    dfs1(1, 0, 1);
    dfs2(1, 1);
    // 初始化线段树:将原权值填入 dfn 序位置
    for (int i = 1; i <= n; i++) {
        st.update(1, 1, n, dfn[i], dfn[i], weight[i]);
    }
    while (m--) {
        int op, u, v, w; cin >> op >> u >> v;
        if (op == 1) {
            cin >> w;
            update_path(u, v, w, n);
        } else {
            cout << query_path(u, v, n) << "\n";
        }
    }
    return 0;
}

题目...

5.1 P1723 [SDOI2011] 染色

变化点:这道题不是路径加法,而是路径覆盖(染色)。

  • 核心:线段树需要维护“区间颜色”。如果一个区间所有颜色相同,记录为 color;否则记录为 0
  • 技巧:在 update_path 时,使用线段树的区间覆盖操作。

5.2 P3580 [HAOI2015] 树上操作

极其关键的认知HLD 的 DFN 序依然是一个合法的 DFS 序!

  • 路径操作 \(\rightarrow\) 使用 update_path / query_path(跳链)。
  • 子树操作 \(\rightarrow\) 使用 [dfn[u], dfn[u] + sz[u] - 1](直接区间操作)。
  • 这道题考察的就是你能不能在同一套代码中灵活切换这两种模式

1. 逻辑链条

\(\text{子树规模 } (sz) \rightarrow \text{重儿子 } (son) \rightarrow \text{链顶 } (top) \rightarrow \text{连续 DFN} \rightarrow \text{跳链 } (O(\log N)) \rightarrow \text{线段树 } (O(\log N)) \rightarrow \text{总复杂度 } O(\log^2 N)\).

  • 忘记优先遍历重儿子:如果你在 dfs2 中没有先跑 dfs2(son[u], t),重链就不是连续的,dfn 序失效,结果全错。
  • 跳链方向反了:必须是 dep[top[u]] 较大的那个先跳,否则无法正确收敛到 LCA。
  • LCA 边界:最后同一条链上的 [min(dfn[u], dfn[v]), max(dfn[u], dfn[v])] 很容易写错成 dfn[u]dfn[v]

配合&决策

3.1 核心判别法

需求场景 推荐工具 核心逻辑 复杂度
涉及子树 \(\text{Subtree}(u)\) DFS 序 映射为连续区间 \(\rightarrow\) 线段树 \(O(\log N)\)
涉及路径 \(\text{Path}(u, v)\) 重链剖分 (HLD) 路径 \(\rightarrow\) \(\log N\) 条连续区间 \(O(\log^2 N)\)
涉及距离/点对 \(\sum \text{dist}\) 点分治 (CD) 路径 \(\rightarrow\) 分而治之 (通过重心) \(O(N \log N)\)
涉及动态修改结构 (加边/删边) LCT / 动态树 维护辅助树 (Splay) \(O(\log N)\)
仅需静态 LCA / 距离 倍增 / Tarjan 预处理跳跃表 \(O(\log N)\)\(O(1)\)

3.2 综合题型分析示例

场景 A“给定一棵树,支持单点修改权值,查询路径 \(u \to v\) 的最大值。”

  • \(\text{路径} \rightarrow \text{修改} \rightarrow\) 重链剖分 + 线段树

场景 B“给定一棵树,查询有多少对点 \((u, v)\) 满足 \(\text{dist}(u, v) = K\)。”

  • \(\text{点对} \rightarrow \text{距离} \rightarrow\) 点分治

场景 C“支持子树所有点 \(+v\),且支持查询路径 \(u \to v\) 的和。”

  • \(\text{子树} + \text{路径} \rightarrow\) 重链剖分 (因为 HLD 的 DFN 序同时也支持子树操作)。

一些本质

DFS 序的本质是:\(\text{Tree} \xrightarrow{dfn} \text{Array}\)

HLD 的本质是:\(\text{Path} \xrightarrow{top} \text{Segments}\)

点分治的本质是:\(\text{Path} \xrightarrow{centroid} \text{Star-graph (星形图)}\)

posted @ 2026-04-04 11:46  ExAll  阅读(6)  评论(0)    收藏  举报