树上差分算法详解:从LCA到点差分与边差分的工程实践

发布时间:2026/8/13 5:10:08
树上差分算法详解:从LCA到点差分与边差分的工程实践
1. 从“区间修改”到“树上修改”的思维跃迁如果你已经熟悉了前缀和与差分那么恭喜你你已经掌握了处理一维数组上“区间修改、单点查询”这类问题的利器。差分数组的精妙之处在于它将一个对原数组的区间修改操作转化为了对差分数组的两个单点修改操作从而将时间复杂度从 O(n) 降到了 O(1)。这种“化繁为简”的思想在算法设计中极具魅力。然而当我们的数据结构从一维的“线”升级为二维的“树”时问题就变得复杂了。想象一下你不再是在一条直线上标记区间而是在一棵枝繁叶茂的大树上需要在某些路径上“撒下信息”比如路径上所有节点的权值加一或者路径上所有边的权值加一最后再询问每个节点或每条边最终承载了多少信息。这就是典型的“树上路径修改、单点查询”问题。暴力做法是对于每条需要修改的路径我们都从起点走到终点沿途给每个节点或每条边加上值。如果树有 N 个节点有 M 次修改操作最坏情况下每次修改路径长度可达 O(N)那么总时间复杂度就是 O(M*N)这在 N 和 M 都达到 10^5 级别时是完全不可接受的。这时我们就需要将一维差分的思想“移植”到树上。树上差分正是为了解决这类问题而生的高效算法。它的核心目标与一维差分一致将路径上的区间修改转化为树上少数几个关键点的单点修改。最终我们只需要通过一次树的遍历通常是深度优先搜索 DFS就能利用这些关键点的信息汇总出每个节点或每条边的最终权值。整个过程的时间复杂度可以优化到 O(NM)这是一个质的飞跃。本文将深入拆解树上差分的两种核心类型点差分与边差分。我会结合具体的场景和代码示例带你理解它们为何这样设计以及在实现中又有哪些容易踩坑的细节。无论你是正在备战算法竞赛还是单纯对这类精巧的算法思想感兴趣相信这篇总结都能给你带来清晰的认知和实用的代码模板。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足够用 vectorint 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]计算错误。注意2LOG 值的估算。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 这两条链应该被加 clca上方的部分不应该加。另一个是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; vectorint 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。实操心得2DFS统计的顺序。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; vectorint 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]的值是没有意义的它可能是所有修改的某种总和但不代表任何一条边。在输出或使用时要跳过根节点。这也是为什么上面代码示例中从i2开始输出。与点差分的对比记忆可以这样记忆点差分修改了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(NM)处理完所有修改再用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。通常 N1e5 时 LOG17N1e6 时 LOG20。开小了会导致跳跃时数组越界或无法跳到正确位置。深度数组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统计顺序第二次DFSdfs_sum必须是后序遍历。即先递归处理所有子节点再将子节点的权值累加到当前节点。如果写成先序或中序结果会完全错误。一个简单的记忆方法是当前节点的值依赖于子节点的值所以必须先算孩子再算自己。遍历整棵树确保第二次DFS从根节点开始并且遍历了所有节点。如果图不连通但题目通常保证是树需要对每个连通分量都做DFS。7.5 性能优化使用链式前向星存图当节点数 N 很大1e5时使用vectorint g[MAXN]存图可能会因为动态内存分配带来一些开销。在极端追求性能时可以使用链式前向星但vector在大多数情况下已经足够且更易写。输入输出优化在 N, M 达到 1e5 甚至 1e6 级别时使用scanf/printf或关闭同步的cin/cout是必要的。避免使用未优化的cin。减少函数调用开销可以将lca函数设为内联函数 (inline)但现代编译器优化已经很好了这不是主要矛盾。空间优化fa数组是空间大头MAXN * LOG * sizeof(int)。如果 LOG17MAXN1e5那么大约占用 1e5 * 17 * 4 bytes ≈ 6.5 MB可以接受。如果内存紧张可以考虑使用short类型存储深度如果深度不超过65535或者使用 Tarjan 离线算法求LCA它只需要 O(N) 的额外空间。树上差分是一个“想通了就很简单想不通就处处是坑”的算法。最好的学习方法就是亲手实现几遍用不同的测试数据去验证特别是构造一些边界情况比如根节点在路径上、路径的两个端点相同、修改值 c 为负数等。当你能够独立、正确地写出解决上述例题的代码时你对树上差分的掌握就相当牢固了。