1. 从“区间修改”到“树上修改”的思维跃迁
如果你已经熟悉了前缀和与差分,那么恭喜你,你已经掌握了处理一维数组上“区间修改、单点查询”这类问题的利器。差分数组的精妙之处在于,它将一个对原数组的区间修改操作,转化为了对差分数组的两个单点修改操作,从而将时间复杂度从 O(n) 降到了 O(1)。这种“化繁为简”的思想,在算法设计中极具魅力。
然而,当我们的数据结构从一维的“线”升级为二维的“树”时,问题就变得复杂了。想象一下,你不再是在一条直线上标记区间,而是在一棵枝繁叶茂的大树上,需要在某些路径上“撒下信息”(比如,路径上所有节点的权值加一,或者路径上所有边的权值加一),最后再询问每个节点或每条边最终承载了多少信息。这就是典型的“树上路径修改、单点查询”问题。
暴力做法是,对于每条需要修改的路径,我们都从起点走到终点,沿途给每个节点或每条边加上值。如果树有 N 个节点,有 M 次修改操作,最坏情况下每次修改路径长度可达 O(N),那么总时间复杂度就是 O(M*N),这在 N 和 M 都达到 10^5 级别时是完全不可接受的。
这时,我们就需要将一维差分的思想“移植”到树上。树上差分,正是为了解决这类问题而生的高效算法。它的核心目标与一维差分一致:将路径上的区间修改,转化为树上少数几个关键点的单点修改。最终,我们只需要通过一次树的遍历(通常是深度优先搜索 DFS),就能利用这些关键点的信息,汇总出每个节点或每条边的最终权值。整个过程的时间复杂度可以优化到 O(N+M),这是一个质的飞跃。
本文将深入拆解树上差分的两种核心类型:点差分与边差分。我会结合具体的场景和代码示例,带你理解它们为何这样设计,以及在实现中又有哪些容易踩坑的细节。无论你是正在备战算法竞赛,还是单纯对这类精巧的算法思想感兴趣,相信这篇总结都能给你带来清晰的认知和实用的代码模板。
2. 前置知识:LCA与树上倍增法
在深入树上差分之前,我们必须先解决一个基础问题:如何快速找到树上任意两个节点的最近公共祖先(Lowest Common Ancestor, LCA)。因为无论是点差分还是边差分,其修改操作的核心都围绕着 LCA 及其父节点展开。
2.1 为什么LCA如此关键?
考虑树上的任意一条简单路径u -> v。这条路径可以被拆分为两段:u -> LCA(u, v)和LCA(u, v) -> v(注意 LCA 本身是这两段的交汇点)。当我们想要对这条路径上的所有点进行修改时,LCA 就是一个承上启下的关键“枢纽”。差分操作需要在u和v处做“加法”标记,但为了将修改范围精确地限制在路径上,而不是扩散到整棵树,我们就必须在 LCA 及其父节点处做相应的“减法”标记来抵消多余的影响。因此,快速求解 LCA 是高效实现树上差分的前提。
2.2 倍增法求LCA的实现细节与踩坑点
求解 LCA 的算法有很多,如 Tarjan 离线算法、树链剖分等。这里介绍最常用且易于理解的倍增法。其核心思想是预处理每个节点向上跳 2^k 步所能到达的祖先节点。
第一步:预处理深度与祖先表我们通常使用深度优先搜索(DFS)来完成初始化。需要两个数组:
depth[i]: 记录节点 i 的深度(根节点深度为0或1,需统一)。fa[i][k]: 记录节点 i 向上跳 2^k 步后到达的祖先节点。如果跳出了根节点,则记为 0 或 -1(根据实现习惯)。
const int MAXN = 100005; const int LOG = 17; // 因为 2^17 > 100000,足够用 vector<int> tree[MAXN]; int depth[MAXN]; int fa[MAXN][LOG]; void dfs(int u, int parent) { fa[u][0] = parent; depth[u] = depth[parent] + 1; // 假设根节点深度为1 // 预处理倍增数组 for (int k = 1; k < LOG; k++) { fa[u][k] = fa[fa[u][k-1]][k-1]; } for (int v : tree[u]) { if (v != parent) { dfs(v, u); } } }注意1:根节点的选择与初始化。务必确保根节点的
fa[root][0]指向一个不存在的节点(如0),并且depth[root]要正确设置(通常设为1,方便后续判断)。一个常见的错误是忘记设置根节点的父节点,导致fa[root][k]计算错误。注意2:LOG 值的估算。LOG 只需满足2^LOG > N即可,通常取 17 (对应1e5) 或 20 (对应1e6)。开得过大浪费空间,过小则可能无法跳到目标位置。
第二步:查询LCA的步骤给定两个节点 u 和 v,查询其 LCA 的步骤如下:
- 统一深度:将深度较大的节点向上跳,直到与另一个节点深度相同。这里就用到了倍增思想,从大步伐(2^k)开始尝试。
- 同步上跳:如果此时 u 和 v 不相等,则它们一定在同一深度且位于 LCA 的不同子树中。我们让它们同时向上跳最大的、且不使它们相遇的步数,最后它们的父节点就是 LCA。
int lca(int u, int v) { // 确保 u 是深度较大的节点,方便后续操作 if (depth[u] < depth[v]) swap(u, v); // 1. 统一深度:u 向上跳 int diff = depth[u] - depth[v]; for (int k = LOG-1; k >= 0; k--) { if (diff & (1 << k)) { // 如果差值的二进制表示中第 k 位为1 u = fa[u][k]; } } // 如果此时 u == v,说明 v 就是 u 的祖先,直接返回 if (u == v) return u; // 2. 同步上跳 for (int k = LOG-1; k >= 0; k--) { // 如果跳 2^k 步后祖先不同,说明还没跳到 LCA 或之上,可以安全跳 if (fa[u][k] != fa[v][k]) { u = fa[u][k]; v = fa[v][k]; } } // 最后,u 和 v 的父节点就是 LCA return fa[u][0]; }踩坑实录:二进制跳跃的判断逻辑。
if (diff & (1 << k))这一行是精髓。它检查深度差diff的二进制表示中第 k 位是否为 1。如果是,说明需要跳 2^k 步。务必从大到小(k从LOG-1到0)遍历,这样才能用最少的跳跃次数完成深度对齐。我曾经错误地写成从小到大遍历,导致在深度差很大时,跳跃效率极低,甚至可能跳不到正确位置。关键理解:为什么同步上跳时判断的是fa[u][k] != fa[v][k]?因为我们的目标是让 u 和 v 跳到 LCA 的直接子节点位置。如果fa[u][k] == fa[v][k],说明跳 2^k 步后它们到达了同一个节点,这个节点可能是 LCA 本身,也可能是 LCA 的祖先。为了避免跳过头,我们只在祖先不同时才跳。这样循环结束后,u 和 v 就恰好位于 LCA 的两个不同儿子节点上。
掌握了高效的 LCA 求法,我们就可以放心地开始探讨树上差分的核心内容了。你会发现,差分公式里的那些加减操作,都与 LCA 息息相关。
3. 点差分:如何给树上的路径“打点”?
点差分处理的问题是:给定若干条路径,每条路径上的所有节点权值增加一个值 c。最后询问每个节点的最终权值。
3.1 差分数组的定义与一次DFS统计
和一维差分类似,我们定义一个差分数组diff[],其维度与节点数相同,初始值全为0。 当我们需要对路径u -> v上的所有节点权值加c时,我们执行以下四个操作:
diff[u] += cdiff[v] += cdiff[lca] -= cdiff[fa[lca][0]] -= c(如果 lca 不是根节点)
这里的lca是 u 和 v 的最近公共祖先,fa[lca][0]是 lca 的父节点。
为什么是这四个点?我们可以这样理解:我们的目标是让从 u 到 v 路径上的每个点最终都加上 c。在后续的统计 DFS 中,每个节点的权值等于其子树中所有 diff 值之和。那么:
- 在
u和v处+c,意味着以 u 为根的子树和以 v 为根的子树中的所有节点,在统计时都会“感受到”这个+c。 - 但是,路径
u->v以外的节点不应该受到影响。多加了c的地方在哪里?一个是lca的子树中,包含了 u 和 v 的整个分支,但实际上只有lca到 u 和lca到 v 这两条链应该被加 c,lca上方的部分不应该加。另一个是,lca这个节点本身被 u 和 v 两个+c各贡献了一次,所以它被加了2c,但我们只希望它被加c。 - 因此,我们在
lca处-c,可以抵消掉一次多余的c(解决 lca 节点被加两次的问题),同时也阻止了c向lca的祖先传递。 - 最后,在
lca的父节点fa[lca][0]处-c,是为了彻底阻止c向上扩散到lca的祖先节点。因为lca的父节点在统计其子树和时,会包含lca子树的和(其中已经包含了我们想要的路径修改),我们通过-c将其抵消,确保修改范围严格限定在u->v路径上。
所有修改操作完成后,我们通过一次 DFS(后序遍历)来统计每个节点的最终权值val[x]:val[x] = diff[x] + sum(val[child_i]),其中child_i是 x 的所有子节点。
3.2 实战场景与代码模板
典型例题:有一棵 N 个节点的树,进行 M 次操作,每次操作给定两个节点 u, v,表示从 u 走到 v 的路径上每个节点的“访问次数”加1。问所有操作完成后,每个节点被访问了多少次。
#include <bits/stdc++.h> using namespace std; const int MAXN = 50005; const int LOG = 16; vector<int> g[MAXN]; int depth[MAXN], fa[MAXN][LOG]; int diff[MAXN]; // 点差分数组 int final_val[MAXN]; // 最终每个点的权值 // 预处理深度和倍增祖先表 void dfs_init(int u, int p) { depth[u] = depth[p] + 1; fa[u][0] = p; for (int k = 1; k < LOG; k++) { fa[u][k] = fa[fa[u][k-1]][k-1]; } for (int v : g[u]) { if (v != p) dfs_init(v, u); } } // 求LCA int lca(int u, int v) { if (depth[u] < depth[v]) swap(u, v); int diff_depth = depth[u] - depth[v]; for (int k = LOG-1; k >= 0; k--) { if (diff_depth & (1 << k)) u = fa[u][k]; } if (u == v) return u; for (int k = LOG-1; k >= 0; k--) { if (fa[u][k] != fa[v][k]) { u = fa[u][k]; v = fa[v][k]; } } return fa[u][0]; } // 执行点差分修改:路径 u-v 上所有点权值 +c void point_update(int u, int v, int c) { int p = lca(u, v); diff[u] += c; diff[v] += c; diff[p] -= c; if (fa[p][0] != 0) { // 如果p不是根节点(假设根节点为1,父节点为0) diff[fa[p][0]] -= c; } } // 第二次DFS,统计子树和,得到最终权值 void dfs_sum(int u, int p) { final_val[u] = diff[u]; // 初始化为自身的差分值 for (int v : g[u]) { if (v != p) { dfs_sum(v, u); final_val[u] += final_val[v]; // 累加子树的权值和 } } } int main() { int n, m; scanf("%d %d", &n, &m); for (int i = 1; i < n; i++) { int a, b; scanf("%d %d", &a, &b); g[a].push_back(b); g[b].push_back(a); } // 初始化,假设1为根节点 depth[0] = 0; // 虚拟的根节点的父节点深度为0 dfs_init(1, 0); // 执行M次路径修改 for (int i = 0; i < m; i++) { int u, v; scanf("%d %d", &u, &v); point_update(u, v, 1); // 每次访问+1 } // 统计最终答案 dfs_sum(1, 0); // 输出每个节点的最终权值 for (int i = 1; i <= n; i++) { printf("%d\n", final_val[i]); } return 0; }实操心得1:根节点父节点的处理。在
point_update函数中,给fa[lca][0]做-c操作时,必须判断lca是否为根节点。如果lca就是根节点,它没有父节点,再执行diff[fa[p][0]] -= c就会访问到diff[0],这通常会导致错误(数组越界或逻辑错误)。我的习惯是将根节点的父节点设为0,并在操作前判断fa[p][0] != 0。实操心得2:DFS统计的顺序。dfs_sum必须是后序遍历(先处理所有子节点,再处理当前节点)。因为当前节点的权值依赖于其所有子节点的权值之和。如果顺序错了,结果必然错误。这是一个非常隐蔽的坑,在调试时如果发现结果不对,可以优先检查DFS的遍历顺序。
4. 边差分:如何统计每条边的“流量”?
边差分处理的问题是:给定若干条路径,每条路径上的所有边权值增加一个值 c。最后询问每条边的最终权值。
边差分比点差分更绕一点,因为我们的操作对象是边,但存储和统计的单元仍然是节点。常见的技巧是:将每条边的权值,记录在这条边连接的两个节点中,深度较大的那个节点上。也就是说,对于连接父节点parent和子节点child的边(其中child是parent的儿子),我们把这条边的权值“附着”在child节点上。
4.1 差分公式的推导与理解
定义差分数组diff[],初始为0。 当需要对路径u -> v上的所有边权值加c时,我们执行以下两个操作:
diff[u] += cdiff[v] += cdiff[lca] -= 2 * c
为什么这次只需要三个点,而且 lca 处是减 2c?让我们沿着“权值附着在深度较大的节点”这个规则来思考。在统计 DFS 中,一个节点的diff值代表了从该节点到其父节点的那条边的权值(对于根节点,它没有父节点,所以其diff值无意义,最终统计时应忽略)。
当我们对路径u->v上的所有边加c时:
- 在
u处+c,意味着从u到其父节点的边(如果存在)需要加c。但更重要的是,这个+c会随着子树和向上传递。 - 在
v处+c,同理。 - 现在考虑
lca。路径u->v实际上由u->lca和v->lca两条链组成,它们在lca处汇合。注意,lca这个节点本身并不代表任何一条边(它代表的是其父节点到它的那条边,而这条边不在路径u->v上)。然而,在统计过程中,u和v处的+c会沿着树向上传递,最终在计算lca的子节点时,会汇聚到lca的子树和中,这会导致lca到其父节点的那条边被错误地加上c(从u方向传来)和另一个c(从v方向传来),总共2c。 - 为了抵消这个错误的影响,我们必须在
lca处-2c。这样,在后续统计lca的子节点权值时,u和v传来的+c效应就被lca处的-2c抵消了,从而保证了只有u->lca和v->lca这两条链上的边被正确修改,而lca上方的边不受影响。
最终,每条边的权值,等于该边下端那个子节点(深度较大的节点)的最终diff值(即统计后的子树和)。
4.2 边差分实战:统计网络流量
典型例题:一棵树代表一个通信网络,节点是交换机,边是网线。有 M 个数据包需要从节点 u 发送到节点 v,每个数据包会占用路径上每条网线 1 个单位的带宽。求所有数据包发送完毕后,每条网线被占用的总带宽是多少。
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; const int LOG = 17; vector<int> g[MAXN]; int depth[MAXN], fa[MAXN][LOG]; int diff[MAXN]; // 边差分数组 int edge_val[MAXN]; // 最终每条边的权值,存储在边的下端节点上 // 初始化DFS和LCA函数与点差分相同,此处省略... // dfs_init, lca 函数照搬 // 执行边差分修改:路径 u-v 上所有边权值 +c void edge_update(int u, int v, int c) { int p = lca(u, v); diff[u] += c; diff[v] += c; diff[p] -= 2 * c; // 关键区别在这里 } // 第二次DFS,统计子树和。 // 注意:这里统计的结果 diff[x] 已经代表了“节点x到其父节点的边”的权值。 void dfs_sum_edge(int u, int p) { // 不需要像点差分那样额外开一个final_val数组,diff[u]在递归返回时就是最终值 for (int v : g[u]) { if (v != p) { dfs_sum_edge(v, u); diff[u] += diff[v]; // 将子节点的权值累加到当前节点 } } // 递归返回后,diff[u] 就是 u 到其父节点 p 的那条边的最终权值 // 我们可以把它存到 edge_val[u] 中,方便后续按边查询 edge_val[u] = diff[u]; } int main() { // ... 读入树结构,初始化 depth, fa (同点差分) int n, m; // ... 读入 n, m 和边 // 执行M次边修改操作 for (int i = 0; i < m; i++) { int u, v; scanf("%d %d", &u, &v); edge_update(u, v, 1); // 每条边流量+1 } // 统计最终每条边的权值 dfs_sum_edge(1, 0); // 假设1是根节点 // 输出每条边的权值。注意,我们存储的是每条边下端节点的权值。 // 对于边 (fa, child),其权值存储在 edge_val[child] 中。 // 通常题目会要求按输入边的顺序输出,所以我们需要在输入时记录边的对应关系。 // 这里假设我们只关心每个节点对应的边权,输出 edge_val[2..n] (因为根节点1没有对应的边) for (int i = 2; i <= n; i++) { printf("Edge from %d to %d has weight: %d\n", fa[i][0], i, edge_val[i]); } return 0; }核心理解:边权存储的映射关系。这是边差分最需要想清楚的一点。
dfs_sum_edge函数返回后,diff[child]的值就是连接child与其父节点parent的那条边的最终权值。因此,我们通常用edge_val[child] = diff[child]来记录。在输出时,要清晰地知道edge_val[i]对应的是哪条边。易错点:根节点的处理。在边差分中,根节点没有对应的“到父节点的边”,所以edge_val[root]或diff[root]的值是没有意义的(它可能是所有修改的某种总和,但不代表任何一条边)。在输出或使用时,要跳过根节点。这也是为什么上面代码示例中从i=2开始输出。与点差分的对比记忆:可以这样记忆:点差分修改了4个点(u, v, lca, fa[lca]),边差分修改了3个点(u, v, lca)。点差分在 lca 处-c,在 fa[lca] 处-c;边差分在 lca 处-2c。这个区别源于修改的对象是“点”还是“边”,以及权值统计时的传递方式不同。
5. 复杂场景:多次修改与最终查询的整合
前面的例子都是先进行所有修改操作,最后进行一次统一的查询(统计每个点或每条边的最终权值)。这是树上差分最标准的用法。但在一些更复杂的问题中,修改和查询可能会交替出现,或者我们需要动态地知道当前某个节点或某条路径的权值。
5.1 离线处理与在线处理
树上差分算法本质上是离线算法。它的高效性建立在“先集中所有修改,再统一计算最终结果”的基础上。因为第二次DFS(统计子树和)的过程是O(N)的,且必须等所有修改都施加到diff[]数组上之后才能进行。如果你在中间穿插着问“现在节点x的权值是多少”,差分算法是无法直接回答的,因为diff[]数组里存储的只是“增量标记”,真正的权值还没有通过DFS汇总起来。
那么,如果题目要求在线查询怎么办?有几种思路:
- 树链剖分 + 线段树:这是处理树上路径修改和查询的通用在线方法。虽然代码复杂,但可以支持任意顺序的修改和查询。
- 将问题转化为离线:如果查询的总数可以接受,有时我们可以记录下所有查询,在所有修改完成后,再执行差分统计,然后一并输出答案。这在竞赛中是一种常见技巧。
- 差分 + 树状数组:如果修改只是单点增加(而不是路径增加),查询是子树和,那么可以用DFS序将树“拍平”成数组,然后用树状数组维护。但这已经超出了标准树上差分的范畴。
所以,当你决定使用树上差分时,首先要确认问题的模式是否匹配:批量路径修改,最后批量单点查询。
5.2 权值为负数或零的情况
树上差分算法本身对权值c的正负没有限制。c可以是正数、负数或零。负数就相当于给路径上的点或边“减”去一个值。算法流程完全不变。这在一些需要“增加”和“减少”两种操作的问题中非常有用。
例如,一个经典问题是:树上有些路径是“建设道路”(权值+1),有些路径是“拆除道路”(权值-1),最后问每条边当前的状态(被覆盖的次数)。这可以直接用边差分,c值根据操作类型取+1或-1即可。
5.3 结合其他技巧:树上差分与前缀和
有时,问题不是简单的“路径修改、单点查询”,而是“路径修改、路径查询”或“子树修改、子树查询”。单纯的差分可能不够用。
一个强大的组合技是:树上差分 + 树上前缀和。 思路是:我们先利用差分处理路径修改,得到每个节点的权值(对于点权)或每个节点到父节点边的权值(对于边权)。然后,如果我们想要求某个节点x到根节点路径上所有点的权值和,我们可以再做一次DFS,求出每个节点到根节点的前缀和prefix[x]。那么x到y路径上的权值和,就可以通过prefix[x] + prefix[y] - prefix[lca] - prefix[fa[lca]]来计算(对于点权)。这实际上是将树上的路径求和,转化为了四个点到根路径前缀和的加减。
这种技巧在需要快速回答路径权值查询时非常有效,但前提是修改操作仍然是离线的、批量的。我们先用差分O(N+M)处理完所有修改,再用O(N)预处理出前缀和,之后每次查询就是O(1)或O(logN)(如果需要再次求LCA)的了。
6. 从原理到实现:深度剖析一个综合案例
为了融会贯通,我们来看一个结合了点差分和LCA,且需要一些思维转换的经典问题。
问题描述:有一棵 N 个节点的树,树上有 M 个补给站(位于某些节点上)。现在有 K 支小队,每支小队从节点s_i出发,前往节点t_i。小队在行进过程中,每经过一个节点(包括起点和终点),如果该节点有补给站,就可以获得一份补给。每份补给只能被一支小队获取。求一种分配方案,使得获得补给的小队数量尽可能多。实际上,我们可以简化:求所有小队的路径覆盖的节点中,有多少个节点至少被一条路径覆盖?因为每个被覆盖的节点上的补给站最多只能服务一支小队,但一支小队可以获取路径上所有补给站。这个问题等价于先求出每个节点被多少条路径覆盖,然后对于每个节点,它能为min(该节点补给量, 该节点被覆盖次数)支小队提供补给。但如果我们只关心“有多少个节点被覆盖”,问题就简化为:给定 K 条路径,求树上至少被一条路径经过的节点个数。
分析:这不是一个简单的求和问题,而是一个“是否存在”的问题。我们需要知道每个节点是否被至少一条路径覆盖。
- 我们可以使用点差分,统计出每个节点被路径覆盖的次数
cnt[x]。 - 如果
cnt[x] > 0,那么这个节点就被覆盖了。 - 但是,直接这样做对吗?考虑一条路径
u->v,点差分会在u,v,lca,fa[lca]四个点做标记。统计后,路径上所有节点的cnt值都至少为1。这没问题。 - 那么答案就是
cnt[x] > 0的节点个数。
实现细节:
- 我们用点差分计算出每个节点的覆盖次数
cnt[x]。 - 遍历所有节点,统计
cnt[x] > 0的节点数。 - 注意,根节点(如果它被覆盖)也应该被计入。点差分能正确处理根节点吗?可以。如果某条路径的
lca就是根节点,那么fa[lca][0]是0,我们不执行diff[0] -= c的操作。在统计时,根节点的权值cnt[root]会正确累加其子树中的所有diff值,包括从u和v传来的+c。所以根节点如果被覆盖,其cnt也会大于0。
代码框架:
// ... 省略树结构构建、LCA预处理、点差分更新函数 point_update ... int cnt[MAXN]; // 即之前的 final_val int main() { // ... 读入树,初始化 int k; // 小队数量 scanf("%d", &k); for (int i = 0; i < k; i++) { int s, t; scanf("%d %d", &s, &t); point_update(s, t, 1); // 每条路径覆盖次数+1 } // 统计覆盖次数 dfs_sum(1, 0); // 假设1是根,cnt[] 现在存储每个节点被覆盖的次数 int ans = 0; for (int i = 1; i <= n; i++) { if (cnt[i] > 0) ans++; } printf("%d\n", ans); return 0; }这个例子展示了如何将树上差分应用于一个简单的存在性判断问题。关键在于理解差分统计出的cnt数组的实际含义——节点的“覆盖次数”。
7. 常见错误排查与性能优化指南
即使理解了原理,实现时也难免掉坑。下面是一些我踩过的坑和对应的解决方案。
7.1 数组越界与初始化
- 倍增数组
fa的大小:fa[MAXN][LOG]的第二维 LOG 必须足够大,满足2^LOG > N。通常 N=1e5 时 LOG=17,N=1e6 时 LOG=20。开小了会导致跳跃时数组越界或无法跳到正确位置。 - 深度数组
depth的初始化:depth[root]通常设为1(如果根节点有父节点0,则depth[0]=0)。确保在DFS中depth[child] = depth[u] + 1逻辑正确。 - 差分数组
diff清零:在多组测试数据时,忘记将diff数组清零是致命错误。必须在每组数据开始时,用memset(diff, 0, sizeof(diff))或循环清零。
7.2 LCA 计算错误
- 二进制跳跃顺序:在
lca函数中,for (int k = LOG-1; k >= 0; k--)必须是从大到小遍历。因为我们要用最大的步长尝试跳跃。如果写成从小到大,当深度差很大时,会变成一步一步跳,退化到 O(N),不仅超时,在有些情况下还可能因为跳跃逻辑问题导致错误。 - 同步上跳的判断条件:
if (fa[u][k] != fa[v][k])这个条件非常关键。它保证了 u 和 v 不会跳到 LCA 的祖先去。如果写成if (fa[u][k] != v && fa[v][k] != u)之类的就全错了。 - 根节点没有父节点:在
lca函数最后返回fa[u][0]时,要确保当 u 和 v 相等时,之前已经直接返回了。否则,如果 u 和 v 本身就是根节点,fa[root][0]可能是0,这需要调用者能处理0的情况(通常0代表没有节点)。
7.3 差分标记应用错误
- 点差分漏掉
fa[lca][0]的-c:这是最经典的错误。只记得diff[u]+=c,diff[v]+=c,diff[lca]-=c,忘记了diff[fa[lca][0]]-=c。这会导致修改的影响扩散到 lca 的祖先节点,使结果偏大。 - 边差分中
lca处-2c写成-c:错误地套用了点差分的公式。这会导致 lca 上方的边被错误地修改。 - 修改值
c的类型:如果c可能很大,或者操作次数很多,diff数组需要用long long类型,避免累加时溢出。
7.4 遍历与统计错误
- DFS统计顺序:第二次DFS(
dfs_sum)必须是后序遍历。即先递归处理所有子节点,再将子节点的权值累加到当前节点。如果写成先序或中序,结果会完全错误。一个简单的记忆方法是:当前节点的值依赖于子节点的值,所以必须先算孩子,再算自己。 - 遍历整棵树:确保第二次DFS从根节点开始,并且遍历了所有节点。如果图不连通(但题目通常保证是树),需要对每个连通分量都做DFS。
7.5 性能优化
- 使用链式前向星存图:当节点数 N 很大(>1e5)时,使用
vector<int> g[MAXN]存图可能会因为动态内存分配带来一些开销。在极端追求性能时,可以使用链式前向星,但vector在大多数情况下已经足够且更易写。 - 输入输出优化:在 N, M 达到 1e5 甚至 1e6 级别时,使用
scanf/printf或关闭同步的cin/cout是必要的。避免使用未优化的cin。 - 减少函数调用开销:可以将
lca函数设为内联函数 (inline),但现代编译器优化已经很好了,这不是主要矛盾。 - 空间优化:
fa数组是空间大头,MAXN * LOG * sizeof(int)。如果 LOG=17,MAXN=1e5,那么大约占用 1e5 * 17 * 4 bytes ≈ 6.5 MB,可以接受。如果内存紧张,可以考虑使用short类型存储深度(如果深度不超过65535),或者使用 Tarjan 离线算法求LCA,它只需要 O(N) 的额外空间。
树上差分是一个“想通了就很简单,想不通就处处是坑”的算法。最好的学习方法就是亲手实现几遍,用不同的测试数据去验证,特别是构造一些边界情况,比如根节点在路径上、路径的两个端点相同、修改值 c 为负数等。当你能够独立、正确地写出解决上述例题的代码时,你对树上差分的掌握就相当牢固了。