1. 问题背景与算法选型
今天想和大家分享一道经典的树上差分题目——洛谷P3258「松鼠的新家」。这道题在算法竞赛圈子里相当有名,因为它完美展现了树上差分技术的实际应用场景。题目大意是:给定一棵树和一组访问顺序,要求统计每个节点被经过的次数。
我第一次做这道题时,直接用了暴力DFS,结果当然是TLE(笑)。后来研究了树上差分才发现,原来这类问题有如此优雅的解法。树上差分本质上是一种利用前缀和思想在树上进行高效区间修改的技巧,时间复杂度能从O(n²)降到O(n)。
关键提示:树上差分分为点差分和边差分两种,本题需要使用点差分版本
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法原理拆解
2.1 树上差分基础概念
树上差分与传统差分数组的思想类似,都是通过标记变化量来优化区间操作。具体到树结构上:
- 点差分:记录节点状态的改变量
- 边差分:记录边权值的改变量
对于本题的访问路径统计问题,我们需要在节点u到v的路径上所有点加1,这正是点差分的典型应用场景。
2.2 点差分实现细节
实现点差分需要三个关键步骤:
-
标记打点:
- 在u和v处各+1
- 在LCA(u,v)处-1
- 在LCA的父节点处-1(如果存在)
-
差分传递:
通过后序遍历将标记从子节点传递到父节点 -
结果计算:
最终的节点值就是原始值加上差分累积量
cpp复制// 伪代码示例
void point_diff(int u, int v) {
int lca = getLCA(u, v);
diff[u]++;
diff[v]++;
diff[lca]--;
if(parent[lca] != -1)
diff[parent[lca]]--;
}
2.3 DFS预处理的重要性
为了高效计算LCA,我们需要预先处理以下信息:
- 每个节点的深度
- 倍增法所需的跳表(2^k级祖先)
- 欧拉序(可选)
预处理的时间复杂度是O(nlogn),但之后每次LCA查询只需要O(logn)时间。这在n较大时(比如1e5级别)优势非常明显。
3. 完整实现方案
3.1 数据结构设计
建议使用以下数据结构:
cpp复制const int MAXN = 3e5+10;
vector<int> tree[MAXN]; // 邻接表存树
int depth[MAXN]; // 节点深度
int up[MAXN][20]; // 倍增表
int diff[MAXN]; // 差分数组
int path[MAXN]; // 访问路径
3.2 关键函数实现
3.2.1 倍增预处理
cpp复制void dfs_pre(int u, int p) {
depth[u] = depth[p] + 1;
up[u][0] = p;
for(int i = 1; i < 20; ++i)
up[u][i] = up[up[u][i-1]][i-1];
for(int v : tree[u]) {
if(v != p) dfs_pre(v, u);
}
}
3.2.2 LCA查询
cpp复制int getLCA(int u, int v) {
if(depth[u] < depth[v]) swap(u, v);
// 提升到同一深度
for(int i = 19; i >= 0; --i) {
if(depth[u] - (1<<i) >= depth[v]) {
u = up[u][i];
}
}
if(u == v) return u;
// 同时上跳
for(int i = 19; i >= 0; --i) {
if(up[u][i] != up[v][i]) {
u = up[u][i];
v = up[v][i];
}
}
return up[u][0];
}
3.3 差分统计实现
cpp复制void dfs_sum(int u, int p) {
for(int v : tree[u]) {
if(v == p) continue;
dfs_sum(v, u);
diff[u] += diff[v];
}
}
4. 实战注意事项
4.1 常见错误排查
-
起点重复计数问题:
- 题目中连续的路径会使中间节点被重复计算
- 解决方法:最后对路径中除起点外的所有节点减1
-
倍增表越界问题:
- 确保预处理时跳表层级足够(20层对3e5节点足够)
-
差分数组初始化:
- 必须初始化为0,否则会影响统计结果
4.2 性能优化技巧
-
输入输出加速:
cpp复制ios::sync_with_stdio(false); cin.tie(0); -
内存访问优化:
- 使用vector代替普通数组
- 预先reserve空间
-
递归改迭代:
- 对于特别深的树,DFS可能爆栈
- 可以改用显式栈实现
5. 完整AC代码参考
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 3e5+10;
vector<int> tree[MAXN];
int depth[MAXN], up[MAXN][20], diff[MAXN], path[MAXN];
void dfs_pre(int u, int p) {
depth[u] = depth[p] + 1;
up[u][0] = p;
for(int i = 1; i < 20; ++i)
up[u][i] = up[up[u][i-1]][i-1];
for(int v : tree[u])
if(v != p) dfs_pre(v, u);
}
int getLCA(int u, int v) {
if(depth[u] < depth[v]) swap(u, v);
for(int i = 19; i >= 0; --i)
if(depth[u] - (1<<i) >= depth[v])
u = up[u][i];
if(u == v) return u;
for(int i = 19; i >= 0; --i)
if(up[u][i] != up[v][i])
u = up[u][i], v = up[v][i];
return up[u][0];
}
void dfs_sum(int u, int p) {
for(int v : tree[u]) {
if(v == p) continue;
dfs_sum(v, u);
diff[u] += diff[v];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n; cin >> n;
for(int i = 1; i <= n; ++i) cin >> path[i];
for(int i = 1; i < n; ++i) {
int u, v; cin >> u >> v;
tree[u].push_back(v);
tree[v].push_back(u);
}
dfs_pre(1, 0);
for(int i = 1; i < n; ++i) {
int u = path[i], v = path[i+1];
int lca = getLCA(u, v);
diff[u]++;
diff[v]++;
diff[lca]--;
if(up[lca][0]) diff[up[lca][0]]--;
}
dfs_sum(1, 0);
// 修正重复计数
for(int i = 2; i <= n; ++i)
diff[path[i]]--;
for(int i = 1; i <= n; ++i)
cout << diff[i] << "\n";
return 0;
}
6. 算法扩展思考
这道题的解法可以推广到许多类似场景:
- 树上区间更新:比如给子树或路径加某个值
- 边权统计:稍作修改就能处理边差分问题
- 结合其他算法:比如与树链剖分结合处理更复杂的查询
在实际工程中,这种思想也能应用于:
- 网络路由的流量统计
- 社交网络的关系分析
- 版本控制系统的变更追踪
最后分享一个调试技巧:对于树上问题,可以先用小规模样例(比如5-10个节点)手动模拟算法过程,验证每个步骤的正确性。我在做这道题时,就是通过画图发现初始方案漏掉了对LCA父节点的处理。
