给一整棵子树加上一个数,你准备怎么改?如果要问两点之间的距离,是不是每次都沿着树爬一遍?
树上的关系看着不像数组那么整齐,但我们手里已经有不少工具了。这节课我们要做三件事:把子树“拍扁”成一段区间,用倍增找到路径转弯的枢纽,再把整条路径的修改压缩成几个标记。
学完这套组合拳,遇到复杂的树上操作,你就能直接降维打击,套用线段树或树状数组。
一、降维打击:用 DFS 序把树“拍扁”
树形结构难处理,是因为它分叉。我们能不能把它拉直,变成我们最熟悉的一维数组?
场景:有一棵 7 个节点的树,以 1 为根。
1
/ \
2 3
/ \ \
4 5 6
\
7
按照深度优先搜索 (DFS) 的规矩,一条路走到黑。访问顺序是:dfn[4] = 3。
我们定义几个“灵魂数组”:
dep[u]:节点的深度。sz[u]:以为根的子树里一共包含了多少个节点。 dfn[u]:第一次走到这个点时,给它发的编号(时间戳)。
1. 为什么子树恰好是一段连续区间?
问一个极其核心的问题:DFS 进入节点 2 以后,会不会搜到一半,突然跑去访问节点 3?
绝对不会。它一定先把 2 这一家的点全部搜完,才退回 1,轮到下一家。于是,2 的子树
第一个编号是 dfn[u],整个家族一共占了 sz[u] 个位置。这就意味着,整棵子树被完美地映射到了一个连续的一维区间:
拿节点 5 验算一下:dfn[5] = 4,sz[5] = 2,对应区间
这下彻底打通了老工具的任督二脉:
- 修改一个节点
:直接修改数组里的 dfn[u]位置。 - 查询子树
的和:查询数组区间 。 - 给子树
统一加数:给这个区间加上一个数。
树状数组和线段树根本不需要知道原来长着一棵树,它们只管维护这个拍扁后的数组。

2. 核心模板:显式栈求 DFS 序
虽然递归 DFS 很好写,但在极端情况下,树可能退化成一条长链,导致深层递归爆栈。这里给出一个用显式栈迭代遍历的写法,作为底层保障。
// 预处理片段,假设 node 是建好的无向树邻接表,tot 为计数器
const int N = 500005;
vector<int> node[N];
int par[N], dep[N], dfn[N], sz[N], ord[N], tot;
void build_dfn(int root) {
stack<int> st;
st.push(root);
dep[root] = 1;
while (!st.empty()) {
int u = st.top();
st.pop();
dfn[u] = ++tot;
ord[tot] = u; // 记录第 tot 个被访问的节点是谁
sz[u] = 1; // 自己先占 1 个位置
// 逆序压栈:为了让邻接表靠前的孩子先出栈(先被访问)
for (int i = (int)node[u].size() - 1; i >= 0; i--) {
int v = node[u][i];
if (v == par[u]) continue; // 不回头走父亲
par[v] = u;
dep[v] = dep[u] + 1;
st.push(v);
}
}
// 倒着扫一遍 ord 数组,孩子把自己的 size 汇总给父亲
for (int i = tot; i >= 2; i--) {
int u = ord[i];
sz[par[u]] += sz[u];
}
}
最后这个倒着扫的循环非常精妙。因为出栈晚的(后代)必定在 ord 的后面,倒着扫天然满足了“先算孩子,再算父亲”的拓扑依赖。
二、路径在哪里转弯:倍增求 LCA
在树上,从节点 4 走到节点 7,路径是
如果每次询问两点之间的路径,都傻傻地一步步往上爬去找交点,遇到长链直接退化成
1. 灵魂数组:fa[k][u] 存什么?
定义 fa[k][u] 表示:从节点
这正是《倍增思想》中的跳转表:把“走到下一个位置”换成“走到父亲”,二进制拆分的方法不变。
如果我想往上跳 8 步,可以拆分成:先跳 4 步,再从那个位置继续跳 4 步。物理意义直接翻译成转移方程:
2. LCA 求解两步走
第一步:先站到同一层
如果两点深度不同,贸然一起起跳,高度差会一直存在,永远遇不到。
比如求 lca(7, 4),深度分别是 4 和 3。先让较深的 7 往上跳一步到达 5。现在问题变成了求同一层的 5 和 4 的 LCA。
第二步:为什么“祖先不同才跳,相同反而不跳”? 这一步是初学者最容易凭直觉写反的地方:不是在找公共祖先吗,看见他俩祖先相同,直接跳过去不行吗? 绝对不行! 因为公共祖先不止一个。LCA 的父亲、爷爷全都是公共祖先。如果你一大步直接跳到了树根(那是所有人的公共祖先),你就把真正的“最近”公共祖先远远甩在了身后。
所以我们反其道而行之:只要跳完之后,两点还不重合,说明还没到 LCA,可以放心大胆地跳!
if (fa[k][u] != fa[k][v]) {
u = fa[k][u];
v = fa[k][v];
}
从大步长到小步长依次尝试,跳到最后,两人一定停在 LCA 的正下方(下一层)。最后再往上走一步 fa[0][u],就是真正的 LCA。
3. 实战代码:洛谷 P3379 【模板】最近公共祖先
协议约定:输入 #define int long long 的同学注意,这里开个 int32_t。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 500005, LOG = 20;
vector<int> node[N];
// 明确使用 32 位整型,防止大数组 MLE
int32_t fa[LOG + 1][N];
int32_t dep[N], q[N];
// 使用 BFS 预处理,不仅防爆栈,还能顺手把层序遍历队列 q 留给后面的差分用
void bfs(int root) {
int head = 1, tail = 0;
q[++tail] = root;
dep[root] = 1;
while (head <= tail) {
int u = q[head++];
// 自己的层数已定,计算自己的 2^k 祖先
for (int k = 1; k <= LOG; k++) {
fa[k][u] = fa[k - 1][fa[k - 1][u]];
}
for (int i = 0; i < node[u].size(); i++) {
int v = node[u][i];
if (v == fa[0][u]) continue; // 不往回走
fa[0][v] = u;
dep[v] = dep[u] + 1;
q[++tail] = v;
}
}
}
int query_lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v); // 强制让 u 是较深的那个
// 1. 先跳到同一层
for (int k = LOG; k >= 0; k--) {
if (dep[u] - (1 << k) >= dep[v]) {
u = fa[k][u];
}
}
if (u == v) return u; // 如果已经相遇,说明一个是另一个的祖先
// 2. 两人同步向上逼近
for (int k = LOG; k >= 0; k--) {
if (fa[k][u] != fa[k][v]) {
u = fa[k][u];
v = fa[k][v];
}
}
// 最后停在 LCA 的正下方,再走一步就是 LCA
return fa[0][u];
}
void solve() {
int n, m, s;
cin >> n >> m >> s;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
node[u].push_back(v);
node[v].push_back(u);
}
bfs(s);
while (m--) {
int u, v;
cin >> u >> v;
cout << query_lca(u, v) << '\n';
}
}
signed main() {
ios::sync_with_stdio(0), cin.tie(0);
solve();
return 0;
}
三、树上距离计算:减掉重复走的那一段
知道 LCA 之后,求两点距离就成了
记
带权树怎么办?只要再维护一个 dist[u] 表示根到
四、树上差分:修改整条路径,能不能只动几个位置?
场景:给
回想一维数组的区间加法,我们用差分只修改两头,最后再求一次前缀和。 树上路径也可以差分!只不过,贡献不是从左往右流,而是从叶子向根节点(自底向上)汇总。
1. 点差分:LCA 自己要留一份
先在路径的两端
- 在
扣掉一份:现在 正常了,但剩下的一份还会继续往上流窜。 - 在
的父亲扣掉一份:这下刚好把多余的贡献彻底拦住!
int p = query_lca(u, v);
d[u]++;
d[v]++;
d[p]--;
d[fa[0][p]]--;
拿路径
- 节点 4, 7:拿到自己身上的
= 1 - 节点 5:收到 7 的
= 1 - 节点 2:收到 4 的
和 5 传上来的 ,加上自己身上的 ,结果 - 节点 1:收到 2 传上来的
,加上自己身上的 ,结果
只有
2. 边差分:LCA 向上的边不能留
如果是给路径上的“边”加权呢?
常规套路:把边的权值存在它较深的那个端点上。即 d[x] 记录的是节点
当
int p = query_lca(u, v);
d[u]++;
d[v]++;
d[p] -= 2;
区别死死卡在“LCA 自己到底要不要保留”:点差分要保留这个点,扣 1;边差分不保留向上的边,扣 2。

3. 如何汇总?反转 BFS 序列!
怎么做到“自底向上,先算孩子再算父亲”?
还记得我们在前面 LCA 模板里存下的 q 数组吗?那是 BFS 层序遍历的顺序。入队时父亲在前、孩子在后。
只要倒着遍历 BFS 队列,轮到某个点的时候,它的所有孩子一定已经把结果上交完毕了!
4. 核心模板:点差分统计节点经过次数
协议约定:输入
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 500005, LOG = 20;
vector<int> node[N];
int32_t fa[LOG + 1][N], dep[N], q[N], d[N];
void bfs(int root) {
int head = 1, tail = 0;
q[++tail] = root;
dep[root] = 1;
while (head <= tail) {
int u = q[head++];
for (int k = 1; k <= LOG; k++) {
fa[k][u] = fa[k - 1][fa[k - 1][u]];
}
for (int i = 0; i < node[u].size(); i++) {
int v = node[u][i];
if (v == fa[0][u]) continue;
fa[0][v] = u;
dep[v] = dep[u] + 1;
q[++tail] = v;
}
}
}
int query_lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v);
for (int k = LOG; k >= 0; k--) {
if (dep[u] - (1 << k) >= dep[v]) u = fa[k][u];
}
if (u == v) return u;
for (int k = LOG; k >= 0; k--) {
if (fa[k][u] != fa[k][v]) {
u = fa[k][u];
v = fa[k][v];
}
}
return fa[0][u];
}
void solve() {
int n, m;
cin >> n >> m;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
node[u].push_back(v);
node[v].push_back(u);
}
bfs(1);
// 读入 M 条路径,打上差分标记
while (m--) {
int u, v;
cin >> u >> v;
int p = query_lca(u, v);
d[u]++;
d[v]++;
d[p]--;
d[fa[0][p]]--; // 扣除 LCA 父亲的贡献
}
// 灵魂一步:倒序遍历 BFS 队列,把孩子的贡献累加给父亲
for (int i = n; i >= 2; i--) {
int u = q[i];
d[fa[0][u]] += d[u];
}
for (int u = 1; u <= n; u++) {
cout << d[u] << (u == n ? "" : " ");
}
cout << '\n';
}
signed main() {
ios::sync_with_stdio(0), cin.tie(0);
solve();
return 0;
}
把所有的修改离线存下来,最后
五、树上路径再探:往上走 K 步与路径上的第 K 个点
刚才我们用倍增找出了两个点的 LCA。但倍增数组 fa[k][u] 的威力远不止于此,它天生就是用来解决“跳跃”问题的。
1. 怎么快速找到往上走 K 步后的节点?
场景:从节点
比如
代码实现:
沿用前面预处理的 fa、dep 和 LOG,约定 dep[u]-1 步;超过这个步数就返回 0,走 0 步则仍是自己。
int get_kth_ancestor(int u, int k) {
if (k >= dep[u]) return 0;
for (int i = LOG; i >= 0; i--) {
if ((k >> i) & 1) { // 如果 k 的二进制第 i 位是 1
u = fa[i][u];
}
}
return u;
}
这段代码直接把
2. 路径上的第 K 个点
场景:我想知道从节点
这条路一定是通过
- 先算算上升段有多长:
。从 走到 一共包含了 个点。 - 如果在上升段:如果
,说明我们要找的点还没越过枢纽 。它就是从 往上走 步到达的节点。直接调用 get_kth_ancestor(u, K - 1)即可。 - 如果在下降段:如果
,说明点已经翻过 ,在往 下降的半坡上。这时从 正向推算很难,我们反过来从 往上找。 整条路径的边数是 (其中 ),总点数是 。正数第 个点,倒数过来就是第 个点,也就是从 往上走 步到达的节点。
手推验证:
六、选学:动态换根 LCA——到底是谁当了枢纽?
场景:有一棵树,本来以 1 为根。现在系统动态发号施令:“如果此时把根换成
其实原树的结构并没有被破坏,我们根本不需要重新建树。 在以原根(设为 1)为基础的树中,新 LCA 的答案必定只在三个点中产生:
这三个候选点全部在原树里用 dep 最大)的那个,就是新根
物理直觉:
把
实战验证程序:
数据范围:
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 100005, LOG = 18;
vector<int> node[N];
int32_t fa[LOG + 1][N], dep[N], q[N];
void bfs(int root) {
int head = 1, tail = 0;
q[++tail] = root;
dep[root] = 1;
fa[0][root] = 0;
while (head <= tail) {
int u = q[head++];
for (int k = 1; k <= LOG; k++) {
fa[k][u] = fa[k - 1][fa[k - 1][u]];
}
for (int i = 0; i < node[u].size(); i++) {
int v = node[u][i];
if (v == fa[0][u]) continue;
fa[0][v] = u;
dep[v] = dep[u] + 1;
q[++tail] = v;
}
}
}
int query_lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v);
for (int k = LOG; k >= 0; k--) {
if (dep[u] - (1 << k) >= dep[v]) u = fa[k][u];
}
if (u == v) return u;
for (int k = LOG; k >= 0; k--) {
if (fa[k][u] != fa[k][v]) {
u = fa[k][u];
v = fa[k][v];
}
}
return fa[0][u];
}
void solve() {
int n, m;
cin >> n >> m;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
node[u].push_back(v);
node[v].push_back(u);
}
bfs(1); // 永远以 1 为原树的根进行一次预处理
while (m--) {
int r, u, v;
cin >> r >> u >> v;
int p1 = query_lca(u, v);
int p2 = query_lca(u, r);
int p3 = query_lca(v, r);
// 选出三者中在原树深度最深的点
int ans = p1;
if (dep[p2] > dep[ans]) ans = p2;
if (dep[p3] > dep[ans]) ans = p3;
cout << ans << '\n';
}
}
signed main() {
ios::sync_with_stdio(0), cin.tie(0);
solve();
return 0;
}
/*
【输入样例】
5 3
1 2
1 3
2 4
2 5
2 4 5
4 4 5
3 4 5
【输出样例】
2
4
2
*/
七、在线修改的抉择:单点修改与路径求和
前面学的树上差分是离线的——必须把所有的加法标记打完,最后从底向上一次性算清。如果题目要求“改一次,查一次”这种在线操作呢?
我们手里的武器是 DFS 序,它能把子树变成一维数组的连续区间。
1. 子树修改与子树求和
这个最简单。既然子树是一段连续区间
2. 单点修改与路径求和
场景:每次修改某个节点
如果硬要去更新路径,你会发现路径在 DFS 序上是被打断成好几截的。我们转变思路,维护每个点到根节点的路径和 sum[x]。
任意两点
那么,当我给点 sum 会受到影响?
只有在 sum 统一加上
3. 树链剖分的前奏
如果既要“在线修改整条路径”,又要“随时查询整条路径”呢? 这就超出了 DFS 序和倍增的管辖范围。我们要查的路径在数组里七零八落,怎么办?
我们需要一种技术:沿着树的重力方向,把树切成一条条笔直的“链”。使得任意两点间的路径,都能被拼凑成极少段在数组里连续的区间。 只要能切成连续区间,就能无缝扔进线段树里维护。这就是树链剖分,本套不展开,作为后续拓展方向。
八、实战练习题单
- 洛谷 P3379 【模板】最近公共祖先
- 训练指引:先独立手敲一遍 LCA。跟自己解释清楚代码里“不相同才跳”的逻辑,比把代码背下来重要得多。
- 极端数据自测
- 自己造几组小数据测试:只有一个点的情况;路径两端相同;一端是另一端的祖先;LCA 恰好是树根。用这些边界情况检查你的点差分和边差分是否写错。
- 洛谷 P3128 [USACO15DEC] Max Flow P
- 训练指引:最纯正的树上点差分模板题。跑完差分后不再是输出每个点,而是扫一遍数组取个最大值。别被名字里的 Flow 骗去写网络流了。
当你下次遇到子树修改,第一反应应该是“DFS 序拍扁”;遇到路径问题,先找 LCA 拆分;需要反复在路径上加加减减,把树上差分掏出来。工具没有变多难,只是现在,你终于把它们接起来了。