📖 第 8.4 章 ⏱️ 约 65 分钟 🎯 Gold / 困难

第 8.4 章:欧拉游览与树的展开

📝 前置要求: 本章需要第 5.4 章(二叉树与树算法:遍历、欧拉序)、第 5.6~5.7 章(线段树和树状数组)以及第 8.3 章(树形 DP 基础)。欧拉游览将树的问题转化为数组问题——必须先熟练掌握区间查询数据结构。

欧拉游览(又称 DFS 序或重链剖分线性化)是将树展开为线性数组的技术。展开后,子树查询变为数组上的区间查询——用树状数组或线段树可在 O(log N) 内求解。

本章还介绍用于 LCA(最近公共祖先)的倍增,它能在 O(log N) 时间内解决树上路径查询。

学习目标:

  • 实现带进/出时间戳的 DFS 序欧拉游览
  • 用游览结果将子树查询转化为数组区间查询
  • 实现倍增 LCA:O(N log N) 预处理 + 每次查询 O(log N)
  • 结合欧拉游览 + LCA 高效回答路径查询

8.4.0 动机:为什么要展开树?

假设有一棵树,需要:

  • 更新 v 子树中所有顶点的值
  • 查询 v 子树中所有顶点的值之和

仅用 DFS,最坏情况下每次操作需 O(N)。但若将 v 的子树转化为连续的数组区间 [in[v], out[v]],就可以用 BIT 或线段树,每次操作 O(log N)。

欧拉游览恰好提供了这种能力: 子树与连续区间之间的一一映射。


8.4.1 DFS 进/出时间戳(欧拉游览)

为每个顶点分配两个时间戳:

  • in[v]:DFS 第一次访问 v 时的时间("进入时刻")
  • out[v]:DFS 完成 v 的子树时的时间("退出时刻")

关键性质: 顶点 u 在顶点 v 的子树中,当且仅当 in[v] ≤ in[u] ≤ out[v]

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int in_time[MAXN], out_time[MAXN];
int order[MAXN];       // order[i] = 第 i 个被访问的顶点
int timer_val = 0;

void dfs(int u, int par) {
    in_time[u] = ++timer_val;   // 记录进入时刻
    order[timer_val] = u;        // 记录该位置对应的顶点

    for (int v : adj[u]) {
        if (v != par)
            dfs(v, u);
    }

    out_time[u] = timer_val;    // 记录退出时刻(等于或晚于 in_time)
}

示例:

📄 Code 完整代码
树(以 1 为根):         DFS 顺序:          进/出时刻:
       1                 访问 1             in[1]=1, out[1]=7
      /|\                访问 2             in[2]=2, out[2]=4
     2  5  7             访问 4             in[4]=3, out[4]=3
    /|   \               返回 2             
   4  3   6              访问 3             in[3]=4, out[3]=4
                         返回 1             
                         访问 5             in[5]=5, out[5]=6
                         访问 6             in[6]=6, out[6]=6
                         返回 1             
                         访问 7             in[7]=7, out[7]=7

顶点 2 的子树 = {2, 4, 3} → 区间 [2, 4]  ✓ (in[2]=2, out[2]=4)
顶点 5 的子树 = {5, 6}    → 区间 [5, 6]  ✓ (in[5]=5, out[5]=6)
顶点 1 的子树 = 全部       → 区间 [1, 7]  ✓ (in[1]=1, out[1]=7)

8.4.2 用 BIT/线段树处理子树查询

得到欧拉游览结果后,可以将顶点值映射到数组位置,并用树状数组处理区间查询。

📄 得到欧拉游览结果后,可以将顶点值映射到数组位置,并用树状数组处理区间查询。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int in_time[MAXN], out_time[MAXN];
int val[MAXN];       // 顶点值
int flat[MAXN];      // flat[i] = 欧拉游览第 i 个位置对应顶点的值
int timer_val = 0;

// ── 树状数组(BIT),支持区间求和 ─────────────────
long long bit[MAXN];
int n;

void bit_update(int i, long long delta) {
    for (; i <= n; i += i & (-i))
        bit[i] += delta;
}

long long bit_query(int i) {
    long long s = 0;
    for (; i > 0; i -= i & (-i))
        s += bit[i];
    return s;
}

long long bit_range(int l, int r) {
    return bit_query(r) - bit_query(l - 1);
}

// ── 欧拉游览 DFS ────────────────────────────────────
void dfs(int u, int par) {
    in_time[u] = ++timer_val;
    flat[timer_val] = val[u];        // 将顶点值放到游览对应位置

    for (int v : adj[u]) {
        if (v != par) dfs(v, u);
    }

    out_time[u] = timer_val;
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> val[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);

    // 用 flat 数组初始化 BIT
    for (int i = 1; i <= n; i++)
        bit_update(i, flat[i]);

    // 查询示例:
    // 顶点 v 的子树和:
    int v;
    cin >> v;
    cout << bit_range(in_time[v], out_time[v]) << "\n";

    // 将顶点 u 的值增加 delta:
    int u; long long delta;
    cin >> u >> delta;
    bit_update(in_time[u], delta);

    return 0;
}

复杂度:

  • 预处理:O(N) DFS + O(N log N) BIT 初始化
  • 子树查询:O(log N)
  • 单点更新(顶点 v):O(log N)——在位置 in_time[v] 更新
  • 子树更新(给 v 子树中所有顶点加 delta):用差分 BIT,在 in_time[v]out_time[v]+1 处更新

8.4.3 最近公共祖先(LCA)

两个顶点 u 和 v 的 LCA 是同时为它们祖先的最深顶点。

树:           LCA(4, 6) = 2
    1          LCA(4, 7) = 1
   / \         LCA(5, 3) = 2
  2   7
 / \
3   4
   /
  5
  |
  6

LCA 有许多应用:

  • u 和 v 之间的距离: dist(u, v) = depth[u] + depth[v] - 2·depth[LCA(u,v)]
  • 路径查询: u 到 v 路径上的查询可以用 LCA + 欧拉游览来回答

倍增 LCA

预处理: 对每个顶点 v,预计算 up[v][k] = v 的第 2^k 个祖先。

up[v][0] = parent(v)         (直接父节点)
up[v][1] = parent(parent(v)) (祖父节点)
up[v][2] = 往上 2 步
...
up[v][k] = up[up[v][k-1]][k-1]
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
const int LOG = 17;  // 2^17 > 10^5

vector<int> adj[MAXN];
int up[MAXN][LOG];   // up[v][k] = v 的第 2^k 个祖先
int depth[MAXN];

void dfs(int u, int par, int d) {
    depth[u] = d;
    up[u][0] = par;  // 直接父节点

    // 填充倍增表
    for (int k = 1; k < LOG; k++) {
        up[u][k] = up[up[u][k-1]][k-1];
        // 第 2^k 个祖先 = 第 2^(k-1) 个祖先的第 2^(k-1) 个祖先
    }

    for (int v : adj[u]) {
        if (v != par)
            dfs(v, u, d + 1);
    }
}

// 求 u 和 v 的 LCA
int lca(int u, int v) {
    // 第 1 步:将 u 和 v 提升到同一深度
    if (depth[u] < depth[v]) swap(u, v);
    int diff = depth[u] - depth[v];

    for (int k = 0; k < LOG; k++) {
        if ((diff >> k) & 1)   // 若 diff 的第 k 位为 1
            u = up[u][k];      // 向上跳 2^k 步
    }

    // 现在 depth[u] == depth[v]
    if (u == v) return u;      // u 在 v 的子树中(或反之)

    // 第 2 步:同时对 u 和 v 二分倍增,找 LCA
    for (int k = LOG - 1; k >= 0; k--) {
        if (up[u][k] != up[v][k]) {  // 若祖先不同则向上跳
            u = up[u][k];
            v = up[v][k];
        }
    }

    return up[u][0];  // 当前位置再往上一步 = LCA
}

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

    int n, q;
    cin >> n >> q;

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

    // 初始化:根 = 1,根的父节点指向自身(哨兵)
    up[1][0] = 1;
    dfs(1, 1, 0);

    while (q--) {
        int u, v;
        cin >> u >> v;
        cout << lca(u, v) << "\n";
    }

    return 0;
}

复杂度: O(N log N) 预处理 + 每次 LCA 查询 O(log N)。

两顶点之间的距离

int dist(int u, int v) {
    return depth[u] + depth[v] - 2 * depth[lca(u, v)];
}

8.4.4 欧拉游览 + LCA 处理路径查询

问题: 每个顶点有一个值,回答 Q 次查询:"从 u 到 v 路径上所有顶点的值之和"。

方法: 定义 prefix[v] = 根到 v 路径上所有顶点的值之和,则:

path_sum(u, v) = prefix[u] + prefix[v] - prefix[LCA(u,v)] - prefix[parent(LCA(u,v))]

当值需要动态更新时,需要以 DFS 序为下标的 BIT——这正是欧拉游览的作用。

// 用前缀和 + LCA 求 path_sum(u, v)
// 定义:prefix[v] = 根到 v 的路径上所有顶点值之和(含两端)
// 则:path_sum(u,v) = prefix[u] + prefix[v] - prefix[lca] - prefix[parent[lca]]

long long path_sum(int u, int v) {
    int l = lca(u, v);
    return prefix[u] + prefix[v] - prefix[l] - prefix[up[l][0]];
    //                                                  ↑ LCA 的父节点
}

8.4.5 USACO Gold 欧拉游览题型模式

模式 1:子树更新 + 查询

"对 v 子树中的所有顶点值加 x。查询所有顶点的总和。"

欧拉游览将子树映射到区间 [in[v], out[v]]。使用区间更新的 BIT(懒惰传播或 BIT 差分数组)。

模式 2:带更新的路径和

"更新顶点 v 的值。查询 u 到 w 路径上的总和。"

LCA + 前缀和 + 以 DFS 序为下标的 BIT。

模式 3:基于 LCA 区间的欧拉游览

在欧拉游览中,当 in[u] ≤ in[v] 时,区间 [in[u], in[v]] 恰好包含 u 到 v 路径上的顶点(还有一些额外的,取决于 u 是否是 v 的祖先)。用于高级 LCA 变体。


8.4.6 预览:重链剖分(HLD)

欧拉游览 + LCA 的组合能处理大多数 Gold 级别的树题。但有一类问题需要对路径进行区间更新和查询——而不仅仅是子树。标准的欧拉游览对此不够用。

重链剖分(HLD) 是 Platinum 级别处理此类问题的技术,它直接建立在欧拉游览的概念上。

HLD 解决的问题

"给定 N 个顶点的树,处理 Q 次查询,每次为以下两种之一:

  • update(u, v, delta):给 u 到 v 路径上所有顶点加 delta
  • query(u, v):返回 u 到 v 路径上所有顶点的总和"

欧拉游览能高效处理子树更新/查询,但跨树的路径查询需要 HLD 的 O(log²N)。

核心思路

通过始终沿"重子节点"(子树最大的子节点)将树分解为重链,可以保证:

  • 任意根到叶路径最多经历 O(log N) 次链的切换
  • 每条链在 DFS 序中是连续区间
  • 路径查询 = 对各段链的 O(log N) 次区间查询 → 配合线段树为 O(log²N)
📄 Code 完整代码
重子节点:子树最大的子节点
重路径:从某顶点沿重子节点向下到叶节点的链

树:            sz[] 值:       重边(→):
    1  (sz=7)      7
   / \            / \
  2   3 (sz=4)   3   4
 / \ / \        / \ / \
4  5 6  7      1 1 2  1

重子节点:1→3 (sz=4 > sz=3), 3→6 (sz=2 > sz=1)
重链:{1, 3, 6}、{2}、{4}、{5}、{7}

HLD 实现草图(仅供参考)

📄 查看代码:HLD 实现草图(仅供参考)
// 重链剖分 — O(N log N) 预处理,O(log²N) 路径查询
// 完整实现是 Platinum 级别;以下是概念性草图

int heavy[MAXN];   // heavy[v] = v 的重子节点(叶节点为 -1)
int head[MAXN];    // head[v] = v 所在重链的顶端节点
int pos[MAXN];     // pos[v] = v 在 HLD 展开数组中的位置
int cur_pos = 0;

// 第 1 步:找重子节点(需先用树形 DP 计算 sz[])
void find_heavy(int u, int par) {
    sz[u] = 1;
    heavy[u] = -1;
    int max_sz = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        find_heavy(v, u);
        sz[u] += sz[v];
        if (sz[v] > max_sz) {
            max_sz = sz[v];
            heavy[u] = v;   // v 是重子节点
        }
    }
}

// 第 2 步:沿重链分配位置
void decompose(int u, int par, int h) {
    head[u] = h;            // 链头
    pos[u] = cur_pos++;     // 在展开数组中的位置

    if (heavy[u] != -1)
        decompose(heavy[u], u, h);      // 延续重链

    for (int v : adj[u]) {
        if (v == par || v == heavy[u]) continue;
        decompose(v, u, v);             // 从 v 开始新链
    }
}

// 第 3 步:利用 LCA + 链跳转进行路径查询
long long path_query(int u, int v) {
    long long result = 0;
    while (head[u] != head[v]) {
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        // u 的链头更深;查询 pos[head[u]]..pos[u]
        result += seg_query(pos[head[u]], pos[u]);
        u = parent[head[u]];  // 跳到链头的父节点
    }
    // 现在 u 和 v 在同一条链上
    if (depth[u] > depth[v]) swap(u, v);
    result += seg_query(pos[u], pos[v]);  // 查询链上该段
    return result;
}

复杂度:

  • 预处理:find_heavy O(N) + decompose O(N) = O(N)
  • 路径查询:O(log N) 次链切换 × O(log N) 线段树 = O(log²N)

📘 何时用 HLD vs 欧拉游览:

  • 子树查询/更新 → 欧拉游览 + BIT(O(log N))
  • 路径查询/更新,无区间更新 → LCA + 前缀和(O(log N))
  • 路径区间更新 + 区间查询 → HLD + 线段树(O(log²N)) ← Platinum 级别

关键结论:本章的所有内容(欧拉游览、LCA、倍增)都是 HLD 的先修知识。掌握第 8.4 章后,HLD 是自然而然的延伸。


💡 思路陷阱

陷阱 1:把"路径查询"误用欧拉序(应用 LCA)

错误判断: "欧拉序把树压成数组,路径查询 u→v 就是 [in[u], in[v]] 区间查询"
实际情况: 欧拉序只保证子树对应连续区间,路径不是连续区间

树:1-2-3-4(链),欧拉序:in[1]=1, in[2]=2, in[3]=3, in[4]=4
查询路径 1→4 看起来是 [1,4],但路径 2→4 是 [2,4] ——偶然正确
查询路径 3→1(往上走):in[3]=3, in[1]=1,区间 [1,3] 包含节点 2,
  而 2 不在 3→1 的路径上 ← 错误!

正确方法:path_sum(u,v) = prefix[u] + prefix[v] - prefix[LCA] - prefix[parent(LCA)]

识别信号: 查询涉及两点之间的路径(非子树) → 必须用 LCA 分解,不能直接用欧拉游览区间


陷阱 2:LCA 倍增时 LOG 取值过小

错误判断: "树最多 10⁴ 个节点,LOG=13 够了(2^13=8192 > 10⁴/2)"
实际情况: 需要 2^LOG > N,对于 N=10⁴ 需要 LOG=14(2^14=16384)

// 安全做法:LOG 永远用 ceil(log2(N)) + 1,或直接用 20 覆盖 10^6
const int LOG = 20;  // 2^20 = 1048576,覆盖 N≤10^6 的所有情形
// 不要"精确计算",用大一点的常数不会影响复杂度

识别信号: LCA 在深链上得到错误答案 → 检查 LOG 是否足够大


⚠️ 常见错误

  1. LOG 取值有误: N ≤ 10⁵ 时用 LOG = 17(2^17 = 131072 > 10⁵);N ≤ 10⁶ 时用 LOG = 20

  2. 根节点的父节点哨兵: 根节点没有父节点。将 up[root][0] = root(指向自身),避免倍增时越界。

  3. 欧拉游览计时器的差一: 若 BIT 是 1-indexed,则计时器从 1 开始(而非 0)。

  4. 路径和公式出错: 注意要减去 prefix[parent(LCA)],而非 prefix[LCA]。LCA 顶点本身在路径上,应被计入一次。

  5. LCA 算法假设树有根: 倍增是在固定根的基础上建立的。若题目没有指定根,选一个(通常是顶点 1)。


📋 章节小结

📌 核心要点

概念说明
欧拉游览DFS 时间戳 in[v], out[v];v 的子树 = 区间 [in[v], out[v]]
子树查询映射为数组区间查询;用 BIT/线段树;O(log N)
倍增up[v][k] = v 的第 2^k 个祖先;O(N log N) 预处理
LCA深度对齐后二分查找分叉点;O(log N)
距离dist(u,v) = depth[u] + depth[v] - 2·depth[LCA(u,v)]
路径和prefix[u] + prefix[v] - prefix[LCA] - prefix[parent(LCA)]

❓ 常见问题

Q:存在 O(1) 的 LCA 算法吗?
A:有——对特殊欧拉游览应用 RMQ(区间最值查询)可以在 O(N log N) 预处理后实现 O(1) 查询。但 USACO Gold 中 O(log N) 的倍增已完全够用。

Q:如果树以无向图形式给出(没有指定根)怎么办?
A:以顶点 1 为根。根的选择不影响 LCA 算法的正确性,只影响 depth[] 的值。

Q:欧拉游览可以用于边权树吗?
A:可以。将边权赋给下方端点(子节点)。路径查询方式相同,但公式改为 prefix[u] + prefix[v] - 2*prefix[LCA](不减 parent(LCA),因为 LCA 顶点承载的是它到父节点的边权)。

🔗 与后续章节的联系

  • Platinum:重链剖分(HLD): HLD 将树分解为链,再用欧拉游览 + 线段树实现 O(log²N) 的带区间更新的路径查询。
  • 第 8.3 章(树形 DP): LCA 允许"在线"回答树形 DP 查询——无需离线处理即可处理路径查询。
  • 第 3.9 章(线段树): 当 BIT 不支持带懒惰传播的区间更新时,用懒惰线段树替代 BIT。

🏋️ 练习题

🟢 简单

8.4-E1. 子树和查询
给定每个顶点有值的树,回答 Q 次查询:"顶点 v 的子树中所有顶点的值之和是多少?"

提示

计算欧拉游览(进/出时刻)。对 DFS 序建立前缀和数组。查询为 prefix[out[v]] - prefix[in[v] - 1],复杂度 O(N + Q)。


8.4-E2. LCA 基础
给定 N 个顶点的树,回答 Q 次查询:"u 和 v 的 LCA 是什么?"

提示

使用 8.4.3 节的倍增模板。O(N log N) 预处理,每次查询 O(log N)。


🟡 中等

8.4-M1. 距离查询
给定 N 个顶点、单位边权的树,回答 Q 次查询:"顶点 u 和顶点 v 之间的距离是多少?"

提示

dist(u, v) = depth[u] + depth[v] - 2 * depth[LCA(u, v)]。用倍增求 LCA。


8.4-M2. 子树更新 + 查询 (USACO 风格)
给定一棵树,处理 Q 次操作:

  • update v delta:给 v 子树中所有顶点加 delta
  • query v:返回顶点 v 的当前值
提示

欧拉游览将 v 的子树映射为区间 [in[v], out[v]]。使用差分 BIT:O(log N) 区间更新,O(log N) 单点查询。


🔴 困难

8.4-H1. 带更新的路径查询 (USACO Gold 难度)
给定带权树,处理 Q 次操作:

  • update u val:将顶点 u 的值设为 val
  • query u v:u 到 v 路径上所有顶点的值之和
提示

LCA + 欧拉游览路径和。对于动态更新,用以 DFS 序为下标的 BIT 维护前缀和。path_sum(u, v) = bit_prefix(in[u]) + bit_prefix(in[v]) - bit_prefix(in[lca]) - bit_prefix(in[parent(lca)])


🏆 挑战

8.4-C1. 统计经过某顶点的路径数 (困难)
给定一棵树。对每个顶点 v,统计满足"v 在 u 到 w 路径上"的路径 (u, w) 的数量(含 u=v 或 w=v 的情形)。

提示

对固定的 v,路径 (u, w) 经过 v 当且仅当 LCA(u, w) = v,或 LCA(u, v) = v,或 LCA(v, w) = v。使用欧拉游览 + 计数:v 的答案等于 C(sz[v], 2) 减去完全在 v 某棵子树内的路径数。这需要对 v 各子节点的子树大小进行仔细的容斥。