1. 问题背景与算法选型
今天想和大家分享一道经典的树上操作题目——AcWing 4963砍树问题。这道题的核心在于如何高效处理树结构上的批量边权修改操作,并在最后快速判断哪些边满足特定条件。
先明确题目要求:给定一棵n个节点的树,我们需要执行m次操作,每次操作指定两个节点u和v,将u到v路径上的所有边权值加1。所有操作完成后,需要找出所有边权值等于m的边(即被所有操作路径覆盖的边)。
为什么选择树上差分算法?因为暴力解法的时间复杂度是O(m*n),对于大规模数据(比如n,m=1e5)显然不可行。而树上差分可以将时间复杂度优化到O(n+m),这正是算法竞赛中常见的优化思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 树结构的基础处理
2.1 树的存储与遍历
首先我们需要选择合适的数据结构来存储这棵树。在算法竞赛中,常用的表示方法有邻接表表示法:
cpp复制vector<int> adj[N]; // 邻接表存储树结构
int depth[N]; // 节点深度
int parent[N]; // 父节点信息
为了后续能快速找到任意两个节点的路径,我们需要先对树进行预处理。BFS预处理是个不错的选择,它可以同时计算每个节点的深度和父节点信息:
cpp复制void bfs(int root) {
queue<int> q;
q.push(root);
depth[root] = 1;
while(!q.empty()) {
int u = q.front(); q.pop();
for(int v : adj[u]) {
if(!depth[v]) {
depth[v] = depth[u] + 1;
parent[v] = u;
q.push(v);
}
}
}
}
2.2 边到点的转换技巧
树上差分有两种类型:点差分和边差分。本题需要对边进行操作,但差分通常是在节点上进行的。这里就需要一个关键技巧——将边权转换为点权。
常用的方法是将每条边关联到其下方的节点上。也就是说,对于边(u, parent[u]),我们将其权值记录在节点u上。这样处理后,对路径上边的操作就等价于对路径上除LCA外的所有节点的操作。
3. 树上差分算法详解
3.1 差分数组的基本原理
差分是一种常见的前缀和逆操作。对于普通的一维数组,差分数组d[i]表示a[i]与a[i-1]的差值。这样,区间[l,r]加k的操作可以转化为d[l]+=k和d[r+1]-=k。
将这个思想扩展到树结构上,就形成了树上差分。我们需要在树上定义类似的差分操作,使得能够高效地进行路径上的批量修改。
3.2 边差分的具体实现
对于边差分,具体实现步骤如下:
- 定义差分数组d[u]表示节点u与其父节点之间边的差分值
- 对于每次操作(u,v):
- 找到u和v的最近公共祖先(LCA)
- 执行以下操作:
cpp复制d[u] += 1; d[v] += 1; d[lca] -= 2;
- 最后通过一次DFS或BFS累加差分值,得到每条边的实际权值
为什么这样设计?因为这样操作后,从u到lca再到v的路径上的所有边都会被+1,而其他边不受影响。
4. LCA的求解方法
4.1 朴素LCA算法
最简单的LCA算法是让两个节点轮流向上跳,直到相遇:
cpp复制int lca(int u, int v) {
while(u != v) {
if(depth[u] > depth[v]) u = parent[u];
else v = parent[v];
}
return u;
}
这种方法在最坏情况下时间复杂度是O(n),对于大规模数据可能不够高效。
4.2 基于二进制拆分的LCA优化
更高效的LCA算法是二进制拆分法(也称为倍增法),预处理每个节点的2^k级祖先:
cpp复制int up[N][LOG]; // up[u][k]表示u的2^k级祖先
// 预处理
void preprocess() {
for(int k=1;k<LOG;k++)
for(int u=1;u<=n;u++)
up[u][k] = up[up[u][k-1]][k-1];
}
int lca(int u, int v) {
if(depth[u] < depth[v]) swap(u,v);
// 将u提到与v同一深度
for(int k=LOG-1;k>=0;k--)
if(depth[u]-(1<<k) >= depth[v])
u = up[u][k];
if(u == v) return u;
// 同时上跳
for(int k=LOG-1;k>=0;k--)
if(up[u][k] != up[v][k])
u=up[u][k], v=up[v][k];
return parent[u];
}
这种方法预处理O(nlogn),每次查询O(logn),适合处理大规模数据。
5. 完整算法实现与优化
5.1 算法整体流程
现在我们可以整合所有组件,形成完整解决方案:
- 使用邻接表存储树结构,记录每条边及其编号
- BFS预处理每个节点的深度和父节点信息
- 预处理LCA所需的倍增表
- 初始化差分数组d[]
- 处理每个查询:
- 找到u和v的LCA
- 执行差分操作:d[u]++, d[v]++, d[lca]-=2
- 通过DFS或BFS累加差分值,计算每条边的最终权值
- 检查哪些边的权值等于m,输出结果
5.2 代码实现示例
cpp复制#include <bits/stdc++.h>
using namespace std;
const int N = 1e5+5, LOG = 20;
vector<pair<int,int>> adj[N]; // to, edge_id
int depth[N], parent[N], up[N][LOG];
int d[N], ans[N];
map<pair<int,int>, int> edge_map;
void bfs(int root) {
queue<int> q;
q.push(root);
depth[root] = 1;
while(!q.empty()) {
int u = q.front(); q.pop();
for(auto [v, id] : adj[u]) {
if(!depth[v]) {
depth[v] = depth[u] + 1;
parent[v] = u;
up[v][0] = u;
q.push(v);
}
}
}
}
void preprocess() {
for(int k=1;k<LOG;k++)
for(int u=1;u<=n;u++)
up[u][k] = up[up[u][k-1]][k-1];
}
int lca(int u, int v) {
if(depth[u] < depth[v]) swap(u,v);
for(int k=LOG-1;k>=0;k--)
if(depth[u]-(1<<k) >= depth[v])
u = up[u][k];
if(u == v) return u;
for(int k=LOG-1;k>=0;k--)
if(up[u][k] != up[v][k])
u=up[u][k], v=up[v][k];
return parent[u];
}
void dfs(int u, int p) {
for(auto [v, id] : adj[u]) {
if(v == p) continue;
dfs(v, u);
d[u] += d[v];
ans[id] = d[v];
}
}
int main() {
int n, m;
cin >> n >> m;
for(int i=1;i<n;i++) {
int u, v;
cin >> u >> v;
adj[u].emplace_back(v,i);
adj[v].emplace_back(u,i);
edge_map[{u,v}] = edge_map[{v,u}] = i;
}
bfs(1);
preprocess();
while(m--) {
int u, v;
cin >> u >> v;
int ancestor = lca(u,v);
d[u]++;
d[v]++;
d[ancestor] -= 2;
}
dfs(1, -1);
vector<int> res;
for(int i=1;i<n;i++)
if(ans[i] == m) res.push_back(i);
if(res.empty()) cout << -1;
else {
sort(res.begin(), res.end());
for(int x : res) cout << x << " ";
}
return 0;
}
5.3 常见错误与调试技巧
在实际编码过程中,有几个容易出错的地方需要注意:
- 边编号处理:确保每条边的编号正确记录和查询
- LCA实现:特别注意二进制拆分法的边界条件
- 差分累加:DFS后向传播时不要重复处理父节点
- 结果收集:注意最终判断条件是权值等于m而非大于等于m
调试时可以构造小样例,打印中间变量(如差分数组d[])来验证算法各阶段的正确性。
6. 算法扩展与应用场景
树上差分算法不仅适用于这道砍树问题,它在许多树结构批量更新问题中都有广泛应用:
- 网络路由优化:统计每条链路的数据包传输量
- 社交网络分析:计算信息传播路径的热度
- 版本控制系统:追踪代码修改的影响范围
- 交通流量统计:分析道路使用频率
理解这个算法的核心在于掌握差分思想如何从线性结构推广到树结构。在实际工程问题中,这种思想可以灵活变通,比如:
- 如果需要支持边权减法操作,差分值相应减即可
- 如果需要区间乘操作,可以考虑对数变换或维护多个差分数组
- 在动态树结构中,可以结合LCT(Link-Cut Tree)实现更复杂的操作
我在实际使用中发现,对于特别大的树结构(1e6节点以上),可以考虑使用离线算法和路径压缩技巧来进一步优化空间复杂度。另外,如果查询是离线的,可以使用Tarjan的离线LCA算法来获得线性时间复杂度。
