CSP-S 提高级课程中心 / 算法进阶 / 第38讲
CSP-S 提高级 · 算法进阶 · s5-16

树的重心、直径、DFS序与欧拉序

一棵树上最重要的四个「坐标」:谁是最稳的中心、谁到谁最远、子树能不能变成一段连续区间、环游一圈的序列长什么样。

4 大核心概念 O(n) 求出全部 3 个经典案例 2 处交互动画 12 道洛谷原题
本讲 · 第 38/52 讲树的重心、直径、DFS序与欧拉序
模块 01

情景导航

用「仓库选址」和「巡检路线」两个日常场景,把树的重心与直径讲成一件很自然的事。

故事仓库选址与巡检路线

你在一家连锁企业做物流规划。公司有 10 万个门店,它们之间的关系是一张「上下级配送网」——每个门店只从它唯一的上级门店收货,上级再往上级拿货。整张网是一棵树。

任务一:公司要新建一个 中心仓库,要求「所有门店到仓库的总运输路程最短」。你该怎么选址?

任务二:公司要安排一条 巡检路线,从某个门店出发,沿配送网走到另一个门店,要求走的路程最长(要覆盖最远的两个店)。你该怎么找?

任务三:老板还要求:任意一个门店的下属门店(即它的整棵子树)里,共有多少件库存?这个统计要支持随时修改某个门店的库存,并支持随时查询。传统的遍历会超时,怎么办?

三个任务,对应树上的三个核心概念。

• 任务一的答案是树的重心——以它为根时,最大的子树最小,所有点到它的距离和也最小;
• 任务二的答案是树的直径——树上最远两点之间的路径;
• 任务三要借助 DFS 序:DFS 进入/离开的时间戳能把「一棵子树」变成数组上一段连续的区间,于是子树操作就变成了区间操作,可以直接上树状数组或线段树。

重心管「居中」、直径管「最远」、DFS 序管「结构化」、欧拉序管「路径查询」。

💡 为什么这四个概念是同一套思想?

共同点:它们都在回答「如何给一棵树建立坐标」。树没有环、没有横向连接,所有信息都藏在父子关系里。

重心用「最大子树」衡量平衡;直径用「最长的两点链」衡量尺度;DFS 序把树「拍平」成序列;欧拉序把路径问题转成区间最值问题。

工程意义:一旦理解了「子树 = 连续区间」,树的很多问题就能降维成一维问题——这是提高组从「会写树」到「会用树」的关键一步。

①生活类比
⚖️

挑担子 = 树的重心

扁担的支点要在中间,两边重量才平衡。树的重心就是那个「最大一边最轻」的点。

→ 最大子树最小
📏

卷尺量最长 = 树的直径

在小区里量最远两个楼栋的距离,先随便找个楼量出最远的,再从那个楼量一次就得到直径。

→ 两次 BFS/DFS
📚

书的目录 = DFS 序

目录里第 3 章的所有小节连在一起,翻到哪一段就知道是哪个分支。

→ 子树 = 连续区间
🎬

行车记录仪 = 欧拉序

把每次「路过」都录下来,两个地点的共同最近记录,就是它们的最近公共祖先。

→ 欧拉序 + RMQ
②核心定义

树的重心(Centroid):删去节点 u 后,剩下的各个连通块中最大的那个块的节点数最小,则 u 是这棵树的重心。等价刻画:以 u 为根时,u 的最大子树最小。

树的重心的等价性质:u 是重心 ⟺ 删去 u 后每个连通块的大小都 ≤ n/2。

树的直径(Diameter):树上距离最远的两点之间的简单路径长度(按边数或边权和计算均可)。这两点称为直径的端点。

DFS 序(dfn 序):树做 DFS 时,每个点被第一次访问的时间戳 tin[u],以及离开子树的时间戳 tout[u]。子树 u 恰好占据区间 [tin[u], tout[u]]。

欧拉序(Euler Tour Order):DFS 过程中,每次进入一个点(含回溯到它)都把该点记入序列,得到的长度为 2n-1 的序列。任意两点 u、v 之间的欧拉序区间内,dfn 最小的那个点就是它们的 LCA。

📌 本讲核心知识地图

板块核心内容对应大纲
树的重心最大子树最小;至多两个;O(n) 换根求树的重心、直径、DFS序与欧拉序【6】
树的直径两次 BFS/DFS O(n);或树形 DP 记录最长/次长链同上【6】
DFS 序tin/tout 时间戳,子树 = [tin, tout]同上【6】
欧拉序长度 2n-1,配合 RMQ 求 LCA同上【6】
应用重心分治、直径端点性质、子树区间统计同上【6】
③核心口诀
🔑 重心诀

去掉它,最大块最小;换根算,max(n - siz[u], 最大儿子 siz) 取最小。
重心的最大子树一定 ≤ n/2,至多两个重心(n 为偶数时可能有两个相邻的)。

🔑 直径诀

两次搜索法:任取一点找最远点 A,再从 A 找最远点 B,A-B 就是直径。
树形 DP 法:每个点维护「向下的最长链」与「次长链」,两者相加取最大。

🔑 序诀

DFS 序进一次、出一次,点占一位、边不占位;欧拉序进也记、回也记,长度 2n-1。
子树 = 连续区间,路径转区间最值。

模块 02

知识探究

四个概念,四套 O(n) 算法,三个完整案例把它们全部落到代码上。

1树的重心 核心

树的重心回答的是「哪个点最居中」。它的定义并非「距离和最小」,但两者互为等价(后面会看到)。

1
📐
定义

删掉 u 后,最大连通块的点数最小

2
🌳
等价刻画

以 u 为根时,最大子树 size 最小

3
⚙️
求法

一次 DFS 求 siz,再一次「换根」比较

📐 公式拆解

先做一次 DFS 求出每个点的子树大小 siz[u](含 u 自己)。此时,对任意点 u:

删掉 u 后,最大的连通块大小 = max( 所有儿子的 siz, n - siz[u] )

式中第二项 n - siz[u] 表示「u 的父亲那一侧」——它正是换根思想的体现:把根从 1 挪到 u,原来父亲那棵大树就变成了 u 的一个「儿子」。

于是重心就是使这个 max 值最小的点,记 f[u] = max(n - siz[u], max siz[son]),答案 = argmin f[u]。

为什么重心一定满足最大块 ≤ n/2?反证:若某个块 S 的大小 > n/2,则 S 中与 u 相邻的那个点 v,它的最大块一定比 u 的最大块更小(因为 v 的「u 那一侧」大小是 n - siz[v] < n/2),与 u 是重心矛盾。

性质内容用途
唯一性重心至多两个;有两个时它们一定相邻,且 n 必为偶数判断特殊情况
平衡性删掉重心后,每个连通块 ≤ n/2点分治的复杂度保证
距离和最小以重心为根时,所有点到根的距离之和最小P1395「会议」
子树和最小所有点的子树大小之和 Σsiz[u] 在重心为根时最小相关统计题
合并性质两棵树连一条边,新树的重心在原两棵树重心的路径上启发式合并 / 动态维护
▶ 一次 DFS 求 siz + 换根比较,找出重心
1树的结构(以 1 为根):1 的儿子是 2、3;2 的儿子是 4、5;4 的儿子是 6。
2后序回溯求子树大小:siz[6]=1,siz[4]=1+1=2,siz[5]=1,siz[2]=1+2+1=4,siz[3]=1,siz[1]=6
3计算各点的「最大块」f[u]:
f[1]=max(0, siz[2]=4, siz[3]=1)=4(根没有上方部分,n-siz=0)
4f[2]=max(n-siz[2]=2, siz[4]=2, siz[5]=1)=2 —— 这里的 2 就是「1 与 3 那一侧」
5f[3]=max(n-siz[3]=5, 无儿子)=5;f[4]=max(n-siz[4]=4, siz[6]=1)=4;f[5]=max(5)=5;f[6]=max(5)=5
6取最小值:f[2]=2 最小 → 2 号点是重心。检验:删掉 2 后,块为 {1,3}(大小 2)、{4,6}(大小 2)、{5}(大小 1),最大块 = 2 ≤ 6/2 = 3 ✓
注意第 4 步里的 n - siz[u]:它代表「换根之后,父亲那侧变成的子树」。这一步是重心算法的灵魂,也是点分治、换根 DP 的通用套路。
❌ 只比较儿子的 siz

f[u] = max(siz[son])

漏掉了「父亲那一侧」,导致靠近根的点被误判,重心找错。

✅ 加上换根项

f[u] = max(n - siz[u], max siz[son])

完整刻画删掉 u 后所有连通块的大小。

2树的直径 核心

直径是树上最长的两点路径。它有两种 O(n) 求法,各有适用场景。

1 2 3 4 5 6 直径 = 3 → 2 → 4 → 5 → 6,长度为 4 条边
方法一:两次 BFS/DFS 最常用
  1. 第一遍:从任意一点(比如 1 号点)出发做 DFS/BFS,记录距离,找到距离最远的点,记为 A。
  2. 第二遍:从 A 出发再做一次 DFS/BFS,找到距离最远的点,记为 B。
  3. 结论:dist(A, B) 就是直径,路径 A→B 就是一条直径。
  4. 为什么对?关键引理:树上任意一点出发能走到的最远点,一定是某条直径的端点。所以第一次找到的 A 必是直径端点。

注意:这个方法在边权有负数时会失效,此时只能用树形 DP。

📐 方法二:树形 DP(每个点合并两条最长链)

设 d1[u] 为 u 向下(往子树方向)能延伸的最长链长度,d2[u] 为次长链长度(要求两条链来自不同的儿子)。

那么「经过 u 的最长路径」= d1[u] + d2[u]。全局答案就是所有点的这个值的最大值。

实现上只需要一个 DFS,回溯时用儿子更新 d1/d2:

if (d1[v] + w > d1[u]) { d2[u] = d1[u]; d1[u] = d1[v] + w; } else d2[u] = max(d2[u], d1[v] + w);

优势:不依赖「最远点是直径端点」这一性质,边权为负也能工作(但直径本身要限定为非空路径);同时它天然能统计「经过某个点的最长路径」,方便和别的 DP 结合。

劣势:写起来稍长,且不直接给出路径端点(需要额外记录来源)。

对比项两次 BFS/DFS树形 DP
时间复杂度O(n)(两遍)O(n)(一遍)
是否需要枚举根不需要需要先定根
能否处理负权不能能
能否直接输出路径能(记录父亲回溯)较麻烦
常见用途静态求直径、求端点与其它 DP 结合、带权复杂题

🔑 直径的三条重要性质(考场常用)

性质 1(端点性质):从树上任意一点 x 出发,距离它最远的点一定是某条直径的端点。这是两次搜索法的理论基石。

性质 2(相交性质):如果一棵树有多条直径,那么它们一定相交于同一点(更准确地说,所有直径都经过树的中心,且中点重合)。

性质 3(合并性质):两棵树之间连一条边得到新树,新树的直径端点必定来自原两棵树的直径端点集合(4 个端点中取 2 个)。这条性质是「动态维护直径」类题目的核心。

3DFS 序:把子树变成一段区间 必掌握

DFS 序是「树」与「序列」之间的桥梁。它之所以强大,是因为一个极其简洁的事实:

子树 u 的所有节点,在 DFS 序中恰好占据一段连续的区间 [tin[u], tout[u]],长度为 siz[u]。

📐 为什么?

DFS 的规则是「进入一个点后,必须把它整棵子树访问完,才会离开」。所以在时间轴上,进入 u 和离开 u 之间,穿插的全部都是 u 的子孙。

因此把「进入时间戳」按顺序排成一列,u 的子树就自然形成了一段连续片段。

两个时间戳的定义:

tin[u] = ++timer; —— 刚进入 u 时记录;
tout[u] = timer; —— 把 u 的所有子树处理完后记录(注意 tout[u] 记的是「最后一个被访问的子孙的时间戳」,因此 tout[u] - tin[u] + 1 == siz[u])。

另一种常见记法:离开时也 tout[u] = ++timer;,此时区间变为 [tin[u], tout[u]],长度 2·siz[u]-1。两种记法都能用,但要保证「查询区间」和「修改位置」用同一套。本讲统一采用第一种(tout 为真实时间戳)。

操作原来的做法用 DFS 序的做法
查询子树 u 的点权和遍历整棵子树 O(siz)区间查询 [tin[u], tout[u]],O(log n)
子树 u 全部加 v遍历整棵子树 O(siz)区间修改 [tin[u], tout[u]] += v,O(log n)
判断 x 是否为 u 的祖先向上跳 O(depth)tin[u] ≤ tin[x] ≤ tout[u],O(1)
路径 u→v 的操作较复杂配合树链剖分(把路径拆成 O(log n) 段区间)
▶ DFS 打 tin/tout 时间戳全过程
1树(以 1 为根):1 → {2, 3};2 → {4, 5};4 → {6}。DFS 顺序:先 2 后 3,先 4 后 5。
2进入 1:tin[1] = 1,时间戳列 = [1]
3进入 2:tin[2] = 2,列 = [1,2]
4进入 4:tin[4] = 3,列 = [1,2,4]
5进入 6:tin[6] = 4;6 没有儿子 → tout[6] = 4,离开 6。列 = [1,2,4,6]
64 的子树做完了 → tout[4] = 4。于是 4 的子树区间 = [3, 4],长度 2 = siz[4] ✓
7进入 5:tin[5] = 5,无儿子 → tout[5] = 5。列 = [1,2,4,6,5]
82 的子树做完了 → tout[2] = 5。2 的子树区间 = [2, 5],长度 4 = siz[2] ✓
9进入 3:tin[3] = 6,无儿子 → tout[3] = 6。列 = [1,2,4,6,5,3]
101 的子树做完了 → tout[1] = 6。1 的区间 = [1, 6],长度 6 = n ✓
11最终 DFS 序:1, 2, 4, 6, 5, 3。验证祖先关系:2 是否 5 的祖先?tin[2]=2 ≤ tin[5]=5 ≤ tout[2]=5 ✓
关键观察:每个点恰好占一个位置,其子树占据一段连续区间。于是「子树点权和」= 数组区间和,「子树整体加」= 数组区间加。
✅ DFS 序能做
  • 子树查询 / 子树修改 → 区间查询 / 区间修改
  • O(1) 判断祖先关系
  • 配合树链剖分处理路径问题
  • 树上莫队(把子树搬成区间)
⚠️ DFS 序不能直接做
  • 路径 u→v 的区间操作:一条路径在 DFS 序里不连续
  • 因此需要树链剖分或树上差分来辅助
  • 点权路径统计优先考虑「树上差分」(下一讲)
4欧拉序:为 LCA 而生的序列 进阶

欧拉序与 DFS 序最容易混淆。它们的区别只有一句话:

DFS 序:进入时记一次;欧拉序:每次经过(进入 + 回溯)都记一次。

📐 欧拉序的构造与长度

DFS 时:进入 u 记录一次;每从一个儿子 v 回溯到 u,再记录一次 u。最终序列长度为 2n - 1。

(为什么是 2n-1?每条树边恰好贡献一次「回溯记录」,共 n-1 条边,加上 n 次「进入记录」= 2n-1。)

核心性质(求 LCA):设 u、v 在欧拉序中第一次出现的位置分别是 first[u] 和 first[v](设 first[u] < first[v])。则在欧拉序区间 [first[u], first[v]] 中,dfn 值最小的那个点,就是 u 和 v 的最近公共祖先 LCA。

直觉:从 u 走到 v,中途「下去又上来」的过程中,深度最小的那个点,正是要把两条路接起来所必需的最高点——也就是 LCA。

实现:把欧拉序列建成 ST 表(区间最小值查询),每次询问 O(1);预处理 O(n log n)。这比倍增法每次 O(log n) 更快,但空间更大、实现稍繁。

📊 欧拉序示例(树:1 → {2,3};2 → {4,5})

DFS 过程记录:进入记一次,回溯再记一次

11
22
34
42
55
62
71
83
91
长度 2×5-1 = 9。求 LCA(4,5):first[4]=3,first[5]=5,区间 [3,5] 中深度最小的是 2 → LCA(4,5)=2 ✓
对比项DFS 序欧拉序
记录规则只记「进入」「进入」和「回溯」都记
长度n2n-1
区间含义子树 = 连续区间路径端点的 LCA = 区间最小 dfn
典型用途子树统计、树链剖分、树上莫队LCA 的 O(1) RMQ 求法
空间O(n)O(n log n)(ST 表)
🔑 一句话记住

DFS 序管「子树」,欧拉序管「路径」;前者把树拍平,后者把路径变成区间最小值。

案例案例1:换根求重心(P1395 会议) 模板题

题目:给定一棵 n 个点的树(n ≤ 5×10⁴),找一个点作为「会议地点」,使得所有点到它的距离之和最小。输出该点编号与最小距离和(若有多解,输出编号最小的)。

🔍 详细分析过程

第1步:识别模型。「所有点到它的距离之和最小」——这正是树的重心的性质(以重心为根时,Σdepth 最小)。

第2步:求 siz。一次 DFS 求出每个点的子树大小。

第3步:求重心。对每个点计算 f[u] = max(n - siz[u], max siz[son]),取 f 最小的点。由于题目要求编号最小,遍历时用「严格小于」更新即可保证首个最优解被保留。

第4步:求距离和。以重心为根再做一次 DFS 累加深度和。注意:也可以不求重心而直接用换根 DP 一次算出所有点的距离和,但本题 n 很小,直接按重心再搜一遍最简洁。

复杂度:O(n) 三次 DFS,5×10⁴ 级别瞬间完成。

C++会议(树的重心)—— 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 50005;
int n, siz[MAXN], f[MAXN];
vector<int> g[MAXN];

void dfs1(int u, int fa) {          // 求子树大小
    siz[u] = 1;
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs1(v, u);
        siz[u] += siz[v];
    }
}

void dfs2(int u, int fa) {          // 换根计算最大块
    f[u] = n - siz[u];                    // 父亲那一侧
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs2(v, u);
        f[u] = max(f[u], siz[v]);         // 每个儿子那一侧
    }
}

int depSum = 0;
void dfs3(int u, int fa, int d) {     // 求距离和
    depSum += d;
    for (int v : g[u]) if (v != fa) dfs3(v, u, d + 1);
}

int main() {
    ios::sync_with_stdio(0);
    cin >> n;
    for (int i = 1, u, v; i < n; i++) {
        cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }
    dfs1(1, 0);
    dfs2(1, 0);

    int best = 1;
    for (int i = 2; i <= n; i++)
        if (f[i] < f[best]) best = i;     // 严格小于 → 保留编号最小的最优解

    dfs3(best, 0, 0);
    cout << best << " " << depSum << "\n";
    return 0;
}
运行结果
输入: 5
      1 2
      2 3
      3 4
      4 5
输出: 3 6

说明: 链状树的重心是中间点 3,各点到 3 的距离 2+1+0+1+2 = 6

💡 为什么「距离和最小」与「最大子树最小」等价?

把根从 u 挪到相邻的 v(v 是 u 的某个儿子):所有在 v 子树里的点距离都减少 1(共 siz[v] 个),其余点距离都增加 1(共 n - siz[v] 个),总变化量 = n - 2·siz[v]。

只要 siz[v] > n/2,往 v 方向挪就能让总距离变小。一直挪到「所有子树都不超过 n/2」为止——而那正是重心的定义。这就是两条性质等价的原因。

案例案例2:两次 DFS 求直径 必考模板

题目:给定一棵 n 个点的带边权树(n ≤ 5×10⁴,边权为正整数),求树的直径长度与两个端点。

🔍 详细分析过程

第1步:第一遍搜索。从 1 号点出发 DFS,求出所有点到 1 的距离,找到距离最大的点 A。

第2步:第二遍搜索。从 A 出发再 DFS,找到距离 A 最远的点 B,同时记录 distA[i]。

第3步:答案。直径长度 = distA[B],端点 = (A, B)。

第4步:为什么两次就够?引理:树上从任意点出发的最远点必是某条直径的端点。既然 A 是直径端点,从 A 出发的最远点 B 就构成完整直径。

第5步:递归深度。n = 5×10⁴ 时 DFS 递归深度可能达到 5×10⁴,接近默认栈上限。稳妥做法是开大栈或用 BFS 迭代实现。下面的代码用显式栈避免了这个问题。

复杂度:O(n)。

C++两次 DFS 求直径 —— 完整代码(显式栈,防爆栈)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 50005;
struct Edge { int to, w, nxt; } e[MAXN << 1];
int n, tot, head[MAXN];
void add(int u, int v, int w) { e[++tot] = {v, w, head[u]}; head[u] = tot; }

long long dist[MAXN];

// 从 s 出发,用显式栈做 DFS 求所有点的距离,返回距离最远的点编号
int dfs(int s) {
    for (int i = 1; i <= n; i++) dist[i] = -1;   // -1 表示未访问
    stack<int> st;
    st.push(s); dist[s] = 0;
    int far = s;
    while (!st.empty()) {
        int u = st.top(); st.pop();
        if (dist[u] > dist[far]) far = u;
        for (int i = head[u]; i; i = e[i].nxt) {
            int v = e[i].to;
            if (dist[v] == -1) {
                dist[v] = dist[u] + e[i].w;
                st.push(v);
            }
        }
    }
    return far;
}

int main() {
    ios::sync_with_stdio(0);
    cin >> n;
    for (int i = 1, u, v, w; i < n; i++) {
        cin >> u >> v >> w;
        add(u, v, w); add(v, u, w);
    }
    int A = dfs(1);        // 第一遍:从任意点找最远点 A
    int B = dfs(A);        // 第二遍:从 A 找最远点 B
    cout << A << " " << B << " " << dist[B] << "\n";
    return 0;
}
运行结果
输入: 6
      1 2 3
      2 3 1
      2 4 4
      4 5 2
      5 6 3
输出: 6 1 12

说明: 第一遍从 1 出发,最远点是 6(距离 3+4+2+3 = 12)
      第二遍从 6 出发,最远点是 1(距离同样为 12)
      故直径端点 (6,1),长度 12

💡 别被「第一次找到的点」迷惑

第一次搜索找到的 A 一定是某条直径的端点,但树上可能有多条直径。如果你需要输出「编号最小的端点对」,就得多搜几次或枚举所有直径端点。

另一个容易踩的坑:上面例子中第一遍「从 1 出发的最远点」是 6,但如果你实现的判定写成 >=,就会在距离相同的多点中取到最后一个,端点对输出随之改变。这类「多解取最优」的处理是很多树上题目的隐藏坑点。

案例案例3:DFS 序 + 树状数组维护子树和 综合应用

题目:给定一棵 n 个点的树,每个点有点权 a[i]。支持两种操作:
① 1 u x:把点 u 的点权增加 x;
② 2 u:查询以 u 为根的子树中所有点的点权和。
(n, m ≤ 2×10⁵)

🔍 详细分析过程

第1步:暴力为什么不行?单次子树查询 O(siz),链状数据下退化成 O(n),m 次操作共 O(nm) = 4×10¹⁰,必然超时。

第2步:关键转化。先做一次 DFS 求出 DFS 序与 tin/tout,把点权按 DFS 序重排成数组 b[tin[u]] = a[u]。此时「子树 u 的点权和」= sum(b[tin[u] .. tout[u]]),是一段连续区间。

第3步:选数据结构。单点修改 + 区间查询 → 树状数组(常数小、代码短)。

第4步:复杂度。预处理 O(n log n)(建树状数组),每次操作 O(log n),总 O((n+m) log n) ≈ 3.6×10⁶,轻松通过。

方法二(对比):用线段树同样可以,只是常数更大;用树上差分只能处理「多次修改最后一次查询」的情形,不满足本题的在线查询要求。

C++DFS 序 + 树状数组 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int n, m, a[MAXN], tin[MAXN], tout[MAXN], timer;
long long bit[MAXN];
vector<int> g[MAXN];

void dfs(int u, int fa) {
    tin[u] = ++timer;                     // 进入时间戳
    for (int v : g[u]) if (v != fa) dfs(v, u);
    tout[u] = timer;                      // 离开时记的是最后一个子孙的时间戳
}

void update(int i, long long v) {          // 树状数组单点加
    for (; i <= n; i += i & -i) bit[i] += v;
}
long long query(int i) {                   // 前缀和
    long long s = 0;
    for (; i > 0; i -= i & -i) s += bit[i];
    return s;
}

int main() {
    ios::sync_with_stdio(0);
    cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1, u, v; i < n; i++) {
        cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }
    dfs(1, 0);
    for (int i = 1; i <= n; i++) update(tin[i], a[i]);  // 按 DFS 序建树状数组

    while (m--) {
        int op, u; long long x;
        cin >> op >> u;
        if (op == 1) {
            cin >> x;
            update(tin[u], x);                                  // 单点修改
        } else {
            cout << query(tout[u]) - query(tin[u] - 1) << "\n";  // 区间查询
        }
    }
    return 0;
}
运行结果
输入: 5 4
      1 2 3 4 5
      1 2
      1 3
      2 4
      2 5
      2 1
      1 2 10
      2 2
      2 3
输出: 15
      21
      3

说明: DFS 序 = 1,2,4,5,3;点权按序变成 1,2,4,5,3
      子树 1 的总和 = 1+2+3+4+5 = 15
      给点 2 加 10 后,子树 2 = {2,4,5},总和 12+4+5 = 21
      子树 3 只有点 3 自己,总和 3

💡 把这段代码记牢,它能变形出十几种题

只要把「树状数组」换成「线段树 + 懒标记」,就能支持子树整体加 + 子树查询;换成「线段树维护最大值」,就能查询子树最值。

甚至可以把「子树」改成「路径」——那需要把 DFS 序升级为树链剖分序(下一册内容)。可以说,本案例是全部树上数据结构题的母题。

模块 03

实践应用

三道完整题目,分别对应重心、直径、DFS 序三大考点。

题1会议(树的重心) O(n)

题目背景

村子建在一棵树上,「会议」要选一个点作为会场,使得所有村民到会场的距离之和最小。给出选择的点和最小距离和。

输入格式

第一行一个整数 n(1 ≤ n ≤ 5×10⁴)。接下来 n-1 行每行两个整数 u, v,表示 u 与 v 之间有道路。

输出格式

两个整数:最优会场编号(多解取编号最小)和最小距离和。

输入样例

7
1 2
1 3
2 4
2 5
3 6
3 7

输出样例

1 10

数据范围

1 s / 128 MB。n ≤ 5×10⁴,答案不超过 int 范围但建议用 long long 统计。

🔍 方法一:枚举每个点求距离和 O(n²)

对每个点做一次 DFS 统计距离和,n=5×10⁴ 时是 2.5×10⁹ 次运算,超时。

🔍 方法二:求重心 + 一次 DFS O(n) 正解

先 O(n) 求出重心(最大子树最小的点),再以重心为根做一次 DFS 累加深度和。理论保证「以重心为根时总距离最小」,因此答案必然正确。

🔍 方法三:换根 DP(通用性最强) 可迁移

第一次 DFS 求出 siz[u] 与根距离和 sum[1];第二次 DFS 沿边转移:sum[v] = sum[u] + (n - 2*siz[v]),O(1) 得到每个点的距离和,取最小即可。这个换根公式在无数树上题中复用。

C++会议 —— 换根 DP 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 50005;
int n, siz[MAXN], d1[MAXN];
long long sum[MAXN];
vector<int> g[MAXN];

void dfs1(int u, int fa, int d) {
    siz[u] = 1; d1[u] = d;
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs1(v, u, d + 1);
        siz[u] += siz[v];
    }
}
void dfs2(int u, int fa) {         // 换根转移
    for (int v : g[u]) {
        if (v == fa) continue;
        sum[v] = sum[u] + (n - 2 * siz[v]);   // 核心公式
        dfs2(v, u);
    }
}
int main() {
    ios::sync_with_stdio(0);
    cin >> n;
    for (int i = 1, u, v; i < n; i++) { cin >> u >> v; g[u].push_back(v); g[v].push_back(u); }
    dfs1(1, 0, 0);
    for (int i = 1; i <= n; i++) sum[1] += d1[i];
    dfs2(1, 0);
    int best = 1;
    for (int i = 2; i <= n; i++) if (sum[i] < sum[best]) best = i;
    cout << best << " " << sum[best] << "\n";
    return 0;
}
运行结果
输入: 7
      1 2
      1 3
      2 4
      2 5
      3 6
      3 7
输出: 1 10

说明: 以 1 为根时距离和 = 0+1+1+2+2+2+2 = 10(最小)
      换到 2 号点后 sum[2] = 10 + (7 - 2*3) = 11,比 10 大
      故最优会场为 1 号点,最小距离和 10

💡 手算一遍换根公式

树为 1-2、1-3、2-4、2-5、3-6、3-7。以 1 为根时,距离和 = 0(1) + 1+1(2,3) + 2+2+2+2(4,5,6,7) = 10。

把根从 1 换到 2:sum[2] = sum[1] + (7 - 2×siz[2]),siz[2] = 3,所以 sum[2] = 10 + (7-6) = 11。校验:以 2 为根,距离 = 0+1(1)+2(3)+1(4)+1(5)+3(6)+3(7) = 11 ✓

公式的含义:往 v 方向换根,v 子树里 3 个点距离各减 1,剩下 4 个点各加 1,净变化 4 - 3 = 1。

题2树网的核(直径) 提高组经典

题目背景

树网上有若干节点,定义「偏心距」为:树上有一点 x,树网中距 x 最远的点到「核心路径」的距离。请找一条长度不超过 s 的路径(称为「核」)作为旅行核心,使所有点到核的最大距离最小。

输入格式

第一行两个整数 n, s(1 ≤ n ≤ 5×10⁵,0 ≤ s ≤ 10⁹)。接下来 n-1 行,每行三个整数 u, v, w,表示 u 到 v 有一条长度为 w 的边。

输出格式

一行一个整数,表示最小偏心距。

输入样例

5 2
1 2 5
2 3 2
2 4 4
4 5 3

输出样例

5

数据范围

1 s / 128 MB。n ≤ 5×10⁵,树形态。

🔍 关键结论(做题前必须知道)

结论:最优的核一定落在树的直径上。因为如果核不在直径上,可以把它平移到直径上且偏心距不会变大。

算法步骤:

① 两次 DFS 求出直径路径 P = (p₁, p₂, …, p_k);
② 对直径上的每个点,预处理「不经过直径的其他点」到它的最大距离 f[i](一次 DFS 即可);
③ 用双指针在直径上滑动窗口:对每个左端 l,找到最远的右端 r 使路径长度 ≤ s;
④ 对每个窗口计算候选答案 max( f 在窗口内的最大值, 左端点到 p₁ 的距离, p_k 到右端点的距离 ),取全局最小;
⑤ 窗口内 f 的最大值用单调队列或 ST 表维护,总复杂度 O(n)。

为什么答案由这三项构成?任意点 x 到核的距离要么是「它挂在核上某点的外侧分支」的距离(第一项),要么是「沿着直径走到核的两端之一」的距离(后两项)。

C++树网的核 —— 核心框架(求直径 + 滑动窗口)
// ① 两次 DFS 取直径,记录直径上的点序列 path[] 和前缀和 pre[]
int A = dfs(1);  int B = dfs(A);
// 从 B 回溯到 A 得到 path(用父亲数组 fa 还原)

// ② 求每个直径点到「非直径分支」的最大距离 f[i]
for (int i = 1; i <= k; i++) {
    onPath[path[i]] = 1;
}
for (int i = 1; i <= k; i++)
    f[i] = maxDep(path[i], 0);   // 不经过其他直径点向下走的最大深度

// ③ 双指针 + 单调队列求窗口内 f 的最大值
int ans = INT_MAX;
deque<int> q;                       // 存下标,f 值单调递减
for (int l = 1, r = 0; l <= k; l++) {
    while (r + 1 <= k && pre[r + 1] - pre[l] <= s) {
        ++r;
        while (!q.empty() && f[q.back()] <= f[r]) q.pop_back();
        q.push_back(r);
    }
    while (!q.empty() && q.front() < l) q.pop_front();
    int cur = max({f[q.front()], pre[l], pre[k] - pre[r]});
    ans = min(ans, cur);
}
cout << ans << "\n";
运行结果
输入: 5 2
      1 2 5
      2 3 2
      2 4 4
      4 5 3
输出: 5

说明: 直径 = 1 → 2 → 4 → 5,长度 5+4+3 = 12
      取核为「从 2 号点沿边 2-4 前进 2 个单位」的线段(长度 = s = 2)
      此时各点到核的距离:1 号 5,3 号 2,4 号 2,5 号 3+2 = 5
      偏心距 = 5,为最小

💡 这道题的思维链条值得反复回味

「最优解一定在直径上」→「直径上滑动窗口」→「窗口内极值用单调队列」。这三步是提高组树上题目的标准组合拳,把「树」规约成「一条链 + 若干挂件」,复杂度就从 O(n²) 降到 O(n)。

类似的「先求直径再在直径上做文章」的题目还有好几道,建议把本题当作这一类题的模板背下来。

题3子树和查询 DFS 序 + BIT

题目背景

维护一棵点带权的树,支持两种操作:把某个点的权值加上 x;查询某个点子树内所有点的权值和。

输入格式

第一行两个整数 n, m(1 ≤ n, m ≤ 2×10⁵)。第二行 n 个整数表示初始点权。接下来 n-1 行是树边 u v(1 为根)。接下来 m 行是操作:1 u x 或 2 u。

输出格式

对每个 2 类操作输出一行答案。

输入样例

5 3
1 2 3 4 5
1 2
1 3
2 4
2 5
2 1
1 2 10
2 2

输出样例

15
21

数据范围

1 s / 256 MB。点权与答案在 int 范围内,但累加建议用 long long。

🔍 方法一:每次暴力遍历子树 O(nm) 必超时

最坏情况是链状树,每次查询 O(n),m 次共 4×10¹⁰ 级别,完全不可行。

🔍 方法二:DFS 序 + 树状数组 O((n+m) log n)

把子树映射成连续区间后,单点修改 + 区间查询就是树状数组的拿手好戏。常数比线段树小,代码量只有十几行。

🔍 方法三:DFS 序 + 线段树 可扩展

如果题目改成「子树整体加 + 子树查询」,树状数组需要改造(差分),而线段树加懒标记可以直接支持。可扩展性更强,代价是常数更大。

选型建议:只有单点修改 + 区间查询 → 树状数组;带区间修改 → 线段树。

C++子树和查询 —— 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
int n, m, tin[MAXN], tout[MAXN], timer;
long long a[MAXN], bit[MAXN];
vector<int> g[MAXN];

void dfs(int u, int fa) {
    tin[u] = ++timer;
    for (int v : g[u]) if (v != fa) dfs(v, u);
    tout[u] = timer;
}
void update(int i, long long v) { for (; i <= n; i += i & -i) bit[i] += v; }
long long query(int i) { long long s = 0; for (; i; i -= i & -i) s += bit[i]; return s; }

int main() {
    ios::sync_with_stdio(0);
    cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1, u, v; i < n; i++) { cin >> u >> v; g[u].push_back(v); g[v].push_back(u); }
    dfs(1, 0);
    for (int i = 1; i <= n; i++) update(tin[i], a[i]);
    while (m--) {
        int op, u; cin >> op >> u;
        if (op == 1) { long long x; cin >> x; update(tin[u], x); }
        else cout << query(tout[u]) - query(tin[u] - 1) << "\n";
    }
    return 0;
}
运行结果
输入: 5 3
      1 2 3 4 5
      1 2
      1 3
      2 4
      2 5
      2 1
      1 2 10
      2 2
输出: 15
      21

💡 一句话记住本讲的价值

DFS 序让「子树」变成了「区间」,于是所有一维区间数据结构都能搬到树上。这就是为什么树状数组、线段树、分块这些技术在本讲之后会突然变得「好用起来」。

下一讲我们会看到另一半:把「路径」变成「差分标记」,用一次 DFS 的回溯结算出所有路径信息。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心
树的重心f[u]=max(n-siz[u], 最大儿子 siz),取最小;至多两个重心
重心性质删掉重心后每块 ≤ n/2;以重心为根距离和最小
树的直径两次 BFS/DFS(正权)或树形 DP(可负权)
直径性质任意点最远点必是直径端点;多条直径必相交
DFS 序tin/tout;子树 = [tin, tout];siz = tout-tin+1
欧拉序长度 2n-1;区间最小 dfn 即 LCA
换根公式sum[v] = sum[u] + (n - 2·siz[v])
复杂度以上全部 O(n)
C++万能模板:一次 DFS 同时求 siz / 重心 / 直径 / dfs 序
int n, siz[MAXN], tin[MAXN], tout[MAXN], timer, cen, d1[MAXN], d2[MAXN];
int bestMax = INT_MAX;   // 重心的最大子树值
int diam = 0;             // 直径长度
vector<int> g[MAXN];

void dfs(int u, int fa) {
    tin[u] = ++timer;
    siz[u] = 1;
    d1[u] = d2[u] = 0;
    int mx = 0;                        // u 的最大子树(不含上方)
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        siz[u] += siz[v];
        mx = max(mx, siz[v]);

        // 直径:维护最长链与次长链
        int len = d1[v] + 1;             // 无权树;带权则用 e[i].w
        if (len > d1[u]) { d2[u] = d1[u]; d1[u] = len; }
        else if (len > d2[u]) d2[u] = len;
    }
    diam = max(diam, d1[u] + d2[u]);      // 经过 u 的最长路径

    // 重心:加入「上方那一块」再比较
    int cur = max(mx, n - siz[u]);
    if (cur < bestMax) { bestMax = cur; cen = u; }
    tout[u] = timer;
}
🔑 一句话总纲

siz 打底、换根补上方;两次搜索量直径、DP 也能量;tin/tout 一出手,子树立刻变区间。

②拓展案例
拓展1树上距离统计

求树上所有无序点对的距离之总和(n ≤ 2×10⁵)。

查看参考答案

方法一:枚举树边(推荐) O(n)

对每条边 (u,v),设 v 是儿子,这条边会被「v 子树中的点」与「其余 n - siz[v] 个点」之间的路径各经过一次,贡献为 siz[v] × (n - siz[v])。把所有边的贡献相加即为答案。

方法二:换根 DP 通用

先求 sum[1](以 1 为根的距离和),再沿 sum[v] = sum[u] + (n - 2·siz[v]) 推出所有点的距离和,求和后除以 2(每个点对算了两次)。

long long ans = 0;
for (int v = 1; v <= n; v++) {
    if (v == root) continue;
    ans += 1LL * siz[v] * (n - siz[v]);   // 方法一:按边统计
}
cout << ans << endl;

运行结果:输入 4 个点的链 1-2-3-4 → 输出 10(距离对:1+2+3+1+2+1 = 10)。

易错:用 int 存答案(n=2×10⁵ 时答案可达 10¹⁵ 量级),必须 long long;或按边统计时把同一个儿子重复统计。

拓展2重心 + 分治

点分治:统计树上距离不超过 k 的点对数量(n ≤ 10⁴)。

查看参考答案

核心思想:为什么点分治的时间复杂度是 O(n log n)?关键就在重心。

每次在当前连通块中找重心作为分治中心,删掉它后每个子连通块大小 ≤ 当前块的一半。因此递归深度不超过 log n,每一层的总工作量为 O(当前块大小),总复杂度 O(n log n)。

流程:① 求当前块的重心 c;② 统计经过 c 的合法点对;③ 容斥减掉「在同一子树内」的重复计数;④ 对每个子树递归。

void solve(int u) {
    vis[u] = 1;
    // ① 求重心 c(在 u 所在块内)
    int c = findCentroid(u, 0);
    // ② 以 c 为根统计所有点到 c 的距离,双指针计数
    calc(c);
    // ③ 对每个子树递归
    for (int v : g[c]) if (!vis[v]) solve(v);
}

运行结果:输入一棵 5 个点的链、k = 2 → 输出 7。

易错:求重心时用了「整棵树」的 n 而不是「当前连通块」的 size,导致分治失衡、复杂度退化到 O(n²)。这是点分治最经典的错误。

拓展3直径 + 结论

核心城市:在树上选 k 个点作为「核心城市」,使得「不在核心城市中的点到最近核心城市距离的最大值」最小。

查看参考答案

关键结论:最优解中,核心城市一定包含树的中心(直径中点),并且可以证明被选中的 k 个点构成一棵包含中心的连通块。

算法:① 两次 DFS 求直径,得到直径路径与中点 mid;② 以 mid 为根做一次 DFS,记录每个点到 mid 的深度与它「最大子树深度」;③ 用优先队列贪心:每次取出「当前能带来的最大收益」的点扩展核心,重复 k 次;④ 最后被排除在外的点,其到核心的最大距离即答案。

为什么贪心对?因为树上「选点扩张」的收益具有单调性——已经扩张的核心外的最深点,其深度是单调不减的,用堆维护即可。

// 求直径中点 mid,以 mid 为根统计子树最深链
int mid = path[k / 2 + 1];          // 直径路径的中间点
dfs(mid, 0);
// 把除 mid 外的每个点按「属于它的向下最长链」入堆
priority_queue<int> pq;
for (int i = 1; i <= n; i++) if (i != mid) pq.push(dep[i] + mx[i]);
for (int i = 1; i < k; i++) pq.pop();      // 贪心剔除收益最大的 k-1 个
cout << pq.top() << endl;

运行结果:输入 6 个点的树、k = 2 → 输出 2。

易错:忘记「核心必须包含中心」,直接从任意点开始贪心导致答案偏大。

③思考与启发

💡 思考一:为什么树的重心至多两个?

展开参考

设 u 是重心,v 是与 u 相邻的另一个重心。由重心性质,删掉 u 后各块 ≤ n/2,删掉 v 后各块 ≤ n/2。

考虑把 u 删掉后 v 所在的那一块,其大小 ≥ n/2(因为如果 < n/2,那么 u 已经是唯一重心)。由 v 也是重心可知,这一块大小恰好 = n/2。

于是 u 和 v 各自「劈开」的两半大小都是 n/2,所以 n 必为偶数,重心恰好两个且相邻。

推论:n 为奇数时重心唯一。

💡 思考二:为什么两次 DFS 求直径必须用「正权」?负权会怎样?

展开参考

两次搜索法依赖引理「任意点的最远点必是直径端点」。这个引理的证明用到了「路径长度满足三角不等式且距离随路径延伸而单调不减」——只有当所有边权非负时才成立。

反例:点 A - B 边权 1,B - C 边权 -5,C - D 边权 1。真正的直径是 A→D 长度 -3 或 B→D 长度 -3,而从 A 出发的「最远点」计算是 D(-3)…此时搜索法还可能凑对;但只要改一下结构就能构造出错误结果。

结论:带负权一定要用树形 DP(d1[u] + d2[u]),它只依赖「两条链拼接」这一结构,不依赖距离单调性。

💡 思考三:DFS 序和欧拉序能不能统一起来理解?

展开参考

可以。两者都是「DFS 过程的采样」:DFS 序只采样「进入事件」,欧拉序同时采样「进入」和「回溯」事件。

从信息量看,欧拉序更丰富——它记录了完整的遍历轨迹,因此能回答「路径」类问题;DFS 序只保留「子树」这一种结构,因此更适合子树统计。

统一视角:给 DFS 过程加一个「事件序列」,选择不同的事件子集就得到不同的序。树链剖分序则是「优先走重儿子」的 DFS 序,它同时兼顾了「子树连续」和「路径被拆成 O(log n) 段」两个需求。

模块 05

学习检测

四个最容易翻车的地方,考前务必扫一眼。

易错1

求重心时忘了「上方那一块」

❌ 错误
f[u] = max(siz[son]); 只统计儿子,漏掉 n - siz[u]。
✅ 正确
f[u] = max(n - siz[u], max siz[son]); 别忘了换根那一项。
口诀:重心别只看儿子,父亲那一半也要算!
易错2

DFS 递归太深导致爆栈

❌ 错误
n = 5×10⁵ 的链状树用递归 DFS,直接 Runtime Error(栈溢出)。
✅ 正确
① 用 std::stack 写成迭代;② 或在 Linux 下先 ulimit -s unlimited;③ 或把根选在重心/直径中点上减小深度。
口诀:十万以上的链,要么开栈、要么迭代!
易错3

tout 的两种记法混用

❌ 错误
建树状数组时用「tout = ++timer」的记法,查询时又按 [tin, tout] 做,导致区间长度变成 2 倍(区间里混入了别的时间戳位置)。
✅ 正确
统一一种记法:本讲采用 tout[u] = timer(真实的最后时间戳),此时 tout-tin+1 == siz[u],区间查询天然正确。
口诀:tout 是「最后一个子孙的时间」,不是「离开时的新编号」!
易错4

直径在负权图上用两次搜索

❌ 错误
边权可能为负时仍用「两次 BFS/DFS」,得到错误答案。
✅ 正确
负权必须改用树形 DP:ans = max(ans, d1[u] + d2[u]),并在回溯时更新 d1/d2。
口诀:正权两次搜、负权 DP 求!
易错5

多组数据忘了清空

❌ 错误
有多组测试数据时,只清空 head 数组,忘了清空 timer、siz、tin/tout、直径答案等全局变量。
✅ 正确
写一个 init() 函数,把所有用到的全局变量与邻接表一次性重置;或把变量改成局部/每次重新赋值。
口诀:多组数据先 init,timer 归零、答案归零!
📋树上四件套速查表
概念核心公式 / 判据求法复杂度
树的重心f[u] = max(n-siz[u], max siz[son])两次 DFS(求 siz + 换根比较)O(n)
树的直径dist(A,B) 最大;或 max(d1[u]+d2[u])两次搜索 / 树形 DPO(n)
DFS 序子树 = [tin[u], tout[u]],长度 siz[u]一次 DFS 打时间戳O(n)
欧拉序长度 2n-1;区间最小 dfn = LCADFS 记录进入与回溯O(n) / ST 表 O(n log n)
换根距离和sum[v] = sum[u] + (n - 2·siz[v])两次 DFSO(n)
按边统距离Σ siz[v] × (n - siz[v])一次 DFSO(n)
📌笔记卡
📖 必记概念
  • 重心:最大子树最小,至多两个
  • 直径:最远两点路径;任意点最远点必是端点
  • DFS 序:子树 = 连续区间
  • 欧拉序:长度 2n-1,用于 LCA
  • 换根:n - siz[u] 是「父亲那一块」
🧠 必背模板
  • f[u]=max(n-siz[u], mxSon)
  • d1[u], d2[u] 合并最长 / 次长链
  • tin[u]=++timer; ... tout[u]=timer;
  • sum[v]=sum[u]+n-2*siz[v]
  • 子树查询:query(tout[u])-query(tin[u]-1)
⚠️ 考场提醒
  • 大 n 的链要防爆栈(迭代或开栈)
  • 距离和、Σsiz 类答案用 long long
  • 多组数据必须 init()
  • 只有单点改 + 区间查才用 BIT
  • 负权树禁用两次搜索求直径
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念辨析

一棵 6 个点的树:1-2、2-3、2-4、4-5、5-6。求它的重心与直径。

查看参考答案

直径:最远两点是 3 和 6,路径 3-2-4-5-6,长度 4 条边,即 3 与 6 的距离为 4。也可取 1 与 6,距离同样为 4——说明本题有多条直径。

重心:以 1 为根算 siz:siz[6]=1, siz[5]=2, siz[4]=3, siz[3]=1, siz[2]=5, siz[1]=6。 则 f[1]=max(0,5)=5;f[2]=max(1,3,1)=3;f[3]=max(5)=5;f[4]=max(3,2)=3;f[5]=max(4,1)=4;f[6]=max(5)=5。 最小值为 3,在 2 和 4 上取得 → 重心是 2 和 4(两个重心且相邻)。

考点:两个重心出现的条件(n 为偶数);重心与直径的关系。

易错:只报一个重心;或忘了判断 f[2] 中的 n-siz[2]=1 这一项。

A2手工模拟

对 A1 的树写出它的 DFS 序(从 1 出发,先访问编号小的儿子),并写出每个点的 tin/tout。

查看参考答案

DFS 顺序:1 → 2 → 3(回溯)→ 4 → 5 → 6(回溯)。

tin[1]=1, tin[2]=2, tin[3]=3, tin[4]=4, tin[5]=5, tin[6]=6

tout[6]=6, tout[5]=6, tout[4]=6, tout[3]=3, tout[2]=6, tout[1]=6

DFS 序:1, 2, 3, 4, 5, 6(本题恰好是顺序的,因为树本身就是一条链挂着一个点)。

验证:子树 2 = [tin[2], tout[2]] = [2, 6],长度 5 = siz[2] ✓

易错:把 tout[2] 写成「离开 2 时的编号」(会写成 7),导致长度算错。

A3模板默写

默写两次 DFS 求直径的核心代码(含距离数组与最远点返回)。

查看参考答案
int dfs(int s) {
    for (int i = 1; i <= n; i++) dist[i] = -1;
    stack<int> st; st.push(s); dist[s] = 0;
    int far = s;
    while (!st.empty()) {
        int u = st.top(); st.pop();
        if (dist[u] > dist[far]) far = u;
        for (int i = head[u]; i; i = e[i].nxt)
            if (dist[e[i].to] == -1) { dist[e[i].to] = dist[u] + e[i].w; st.push(e[i].to); }
    }
    return far;
}
// 调用:int A = dfs(1); int B = dfs(A); 答案 = dist[B];

易错:把祖先判断写成 if (v == fa) continue; 配合没有 dist[v] == -1 判断,导致重复访问死循环;或忘了清空 dist 数组。

A4编程题

给定一棵 n 个点的树,输出它的重心(多解输出编号最小的)与直径长度(按边数)。

查看参考答案
void dfs(int u, int fa) {
    siz[u] = 1; d1[u] = d2[u] = 0;
    int mx = 0;
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        siz[u] += siz[v]; mx = max(mx, siz[v]);
        int len = d1[v] + 1;
        if (len > d1[u]) { d2[u] = d1[u]; d1[u] = len; }
        else if (len > d2[u]) d2[u] = len;
    }
    diam = max(diam, d1[u] + d2[u]);
    int cur = max(mx, n - siz[u]);
    if (cur < best) { best = cur; cen = u; }
}

思路:一次 DFS 同时求 siz、直径(合并最长与次长链)、重心(换根项 n-siz[u])。

运行结果:输入 A1 的树 → 输出 2 4。

易错:判断重心用 <= 会把编号大的重心覆盖掉,应使用严格小于。

🟡 B组 · 提高(4题)

B1换根 DP

求树上所有点的「以该点为根的距离和」,输出最小的那一个(n ≤ 2×10⁵)。

查看参考答案

思路:第一次 DFS 求 siz 和 sum[1];第二次 DFS 用 sum[v] = sum[u] + (n - 2*siz[v]) 逐个推出所有点的距离和。

void dfs2(int u, int fa) {
    for (int v : g[u]) {
        if (v == fa) continue;
        sum[v] = sum[u] + (n - 2 * siz[v]);
        dfs2(v, u);
    }
}

运行结果:输入 A1 的树 → 最小距离和为 9(在 2 号点取得)。

易错:公式写成 sum[u] + (2*siz[v] - n)(符号反了);或忘记 sum 用 long long。

B2DFS 序应用

给定树与若干次询问,每次问「x 是否是 y 的祖先」。

查看参考答案

思路:x 是 y 的祖先 ⟺ tin[x] ≤ tin[y] ≤ tout[x]。预处理一次 DFS 打时间戳,每次询问 O(1)。

for (int i = 1, x, y; i <= q; i++) {
    cin >> x >> y;
    cout << ((tin[x] <= tin[y] && tin[y] <= tout[x]) ? "YES" : "NO") << "\n";
}

运行结果:对 A1 的树询问「2 是 6 的祖先吗」→ YES;「4 是 3 的祖先吗」→ NO。

易错:x == y 时也应判为「祖先」(按多数题目定义),条件写 tin[x] < tin[y] 会判错。

B3直径性质

给定 n 个点的树,求「经过某条指定边 e 的最长路径」的长度。

查看参考答案

思路:把边 e=(u,v) 断开后分成两棵树 T₁、T₂(分别以 u、v 为「根」)。任何经过 e 的路径都由「T₁ 中一条以 u 为端点的最长链」+ e + 「T₂ 中一条以 v 为端点的最长链」组成。

因此答案 = down[u](T₁ 内以 u 出发的最长向下距离)+ w(u,v) + down[v]。

void dfs(int u, int fa, int ban) {   // ban 为禁止经过的点
    down[u] = 0;
    for (int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to;
        if (v == fa || v == ban) continue;
        dfs(v, u, ban);
        down[u] = max(down[u], down[v] + e[i].w);
    }
}

运行结果:对 A1 的树、边 (4,5) → 输出 4(路径 3-2-4-5-6 或 1-2-4-5-6 均经过该边)。

易错:忘了「最长链必须从 u(或 v)端点出发」,误用整棵子树的最长路径。

B4欧拉序

给定一棵 n 个点的树,输出它的欧拉序长度与序列(从 1 出发)。

查看参考答案

思路:进入 u 时记录一次;每从一个儿子回溯时再记录一次 u。序列长度为 2n-1。

int euler[MAXN * 2], ecnt = 0;
void dfs(int u, int fa) {
    euler[++ecnt] = u;               // 进入记一次
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        euler[++ecnt] = u;           // 回溯再记一次
    }
}

运行结果:对 A1 的树(1-2、2-3、2-4、4-5、5-6)→ 长度 11,序列 1 2 3 2 4 5 6 5 4 2 1。

易错:只在「进入」时记录,得到的是 DFS 序而不是欧拉序;或数组只开到 n 导致越界(需要开 2n)。

🔴 C组 · 挑战(4题)

C1初赛真题

一棵有 n 个结点的树,其重心最多有几个?分别在什么条件下出现?

查看参考答案

答案:至多 2 个。

当 n 为奇数时重心唯一;当 n 为偶数且存在一条边能把树恰好分成 n/2 与 n/2 两个连通块时,这条边的两个端点都是重心。

典型例子:n=4 的四点链 1-2-3-4,重心为 2 和 3。

考点:重心唯一性条件。易错:认为重心总是唯一。

C2综合分析

证明:树上任意一点出发的最远点一定是某条直径的端点。

查看参考答案

证明思路(反证 + 交换论证):设直径是 A-B,从任意点 x 出发的最远点是 P。要证 P 是某条直径的端点(即 dist(P, Q) = dist(A, B) 对某个 Q 成立)。

① 考虑 x 到 A、B 的路径:设它们与 x-P 路径的「分岔点」为 c(在树上,任意三点的路径一定交于某点)。

② 由 A-B 是直径,有 dist(A,B) ≥ dist(P,B),展开得 dist(A,c) ≥ dist(P,c)(因为 A-B 与 P-B 共享 c-B 段)。

③ 于是 dist(A,P) = dist(A,c) + dist(c,P) ≥ 2·dist(c,P),而 P 是距 x 最远点,故 dist(x,P) ≥ dist(x,A),结合展开可推出 dist(A,P) ≥ dist(A,B)。

④ 又 A-B 是最长的,所以 dist(A,P) = dist(A,B),即 A-P 也是一条直径,P 是直径端点。证毕。

启发:这个引理是两次搜索法的全部理论依据;它的证明关键在于「三点路径交于一点」这一树的独有结构。

C3综合编程

给定 n 个点的树与 m 次询问 (u, k),每次要求输出「与 u 距离恰好为 k 的点数」(n, m ≤ 2×10⁵)。

查看参考答案

朴素思路:每次 BFS 统计距离为 k 的点,O(nm),超时。

优化思路(长链剖分):对每个点维护「以它为顶端的长链」上的深度计数数组,用指针共享内存的技巧让儿子的数组被父亲复用,从而把总空间压到 O(n)、总时间压到 O(n)。这是长链剖分优化「与深度相关」DP 的经典应用。

另一条思路(离线 + DSU on tree):把询问挂到 u 上,用「重儿子保留、轻儿子暴力合并」的方式维护每个深度的计数,复杂度 O(n log n),实现比长链剖分简单。

// 长链剖分:son[u] 为最长链儿子,dep[u] 为其链长
void dfs(int u, int fa) {
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        if (len[v] > len[son[u]]) son[u] = v;
    }
    len[u] = len[son[u]] + 1;
}
// 指针复用:f[son[u]] = f[u] + 1,儿子直接共享父亲的数组

运行结果:输入 A1 的树,询问 (2, 1) → 输出 3(点 1、3、4 距 2 均为 1)。

易错:指针复用时光标分配不当导致数组重叠;或忘了「长度为 len 的链恰好是 O(n) 总内存」这一关键分析。

C4构造与证明

构造一棵 n 个点的树,使其重心恰好是某个指定点 r(n ≥ 3)。并说明你的构造为什么正确。

查看参考答案

构造:让 r 连接 ⌈n/2⌉ ... 更简洁的做法是:取一条以 r 为中心的「菊花 + 短链」结构。

构造方案:把 n 个点分成若干组,每组一条从 r 出发的链,各链长度尽量相等(相差不超过 1)。

为什么正确:此时删掉 r 后,每个连通块的大小 = 各组链长 ≤ ⌈(n-1)/组数⌉。只要组数 ≥ 3,各块就都 ≤ n/2,满足重心判据。

反过来,若只有 2 组且 n-1 为奇数,则最大组 ≥ n/2,需要检查是否 ≤ n/2——这会触发「两个重心」的情形。

启发:「重心 = 删掉它后各块 ≤ n/2」这个等价刻画,是构造类题目的直接工具。看到「构造重心」就能立刻想到「均匀分块」。

🏆 洛谷原题精选(12道)

以下 12 道题覆盖树的重心、直径、DFS 序、欧拉序及换根 DP,按难度从普及-到提高排列。

1
P3374
【模板】树状数组 1普及-
2
P3368
【模板】树状数组 2普及-
3
P1395
会议(树的重心)普及+
4
P5002
专心OI - 找祖先(子树大小)普及+
5
P2420
让我们异或吧(树上路径)普及+
6
P1099
树网的核(直径 + 单调队列)提高
7
P3304
[SDOI2013] 直径提高
8
P4408
[NOI2003] 逃学的小孩提高
9
P5536
【XR-3】核心城市(直径中点)提高
10
P3258
[JLOI2014] 松鼠的新家提高
11
P2590
[ZJOI2008] 树的统计(剖分序)提高
12
P3128
[USACO15DEC] 最大流Max Flow提高