1. 问题背景与核心思路
这道来自洛谷的题目编号P4427,是2018年BJOI(北京信息学奥林匹克)的竞赛真题。题目要求处理树结构上的多次查询,每次查询给出树上的两个节点u和v,以及一个整数k,需要计算从u到v路径上所有节点深度的k次方之和。
这类问题在实际应用中非常常见,比如社交网络分析中计算两个用户之间路径的某种特征值总和,或者物流路径规划中计算某条运输路线的累计成本。题目考察的核心能力是如何高效处理树结构上的路径统计问题。
我最初看到这个问题时,意识到直接暴力计算每条路径显然不可行。假设树有n个节点,m次查询,每次查询平均路径长度是O(logn),那么暴力算法的时间复杂度是O(mn),在n和m都是1e5量级时根本无法承受。
2. 关键技术解析:树上前缀和
2.1 前缀和在树结构上的扩展
前缀和在数组上是一个非常基础但强大的技巧。对于数组a,我们预处理一个前缀和数组s,其中s[i] = a[1]+a[2]+...+a[i]。这样,求区间[l,r]的和就可以用s[r]-s[l-1]在O(1)时间内完成。
将这个思想扩展到树结构上,我们需要解决两个关键问题:
- 如何定义树上的"前缀"
- 如何处理树的分叉结构
在树结构中,我们选择以根节点为起点的路径作为前缀方向。对于每个节点u,定义sum[u][k]为从根到u路径上所有节点深度的k次方之和。这里的深度指的是节点到根的距离(根节点深度为0)。
2.2 前缀和的递推计算
计算sum数组可以采用DFS或BFS遍历树:
- 根节点的sum[root][k] = depth(root)^k = 0^k
- 对于非根节点u,设其父节点为p,则:
sum[u][k] = sum[p][k] + depth(u)^k
这样,我们可以在O(n)时间内预处理所有节点的sum值(对于固定的k)。
2.3 路径和的计算公式
对于查询u到v路径上的和,设它们的LCA(最近公共祖先)为l,则路径可以分为三部分:
- u到l的路径
- v到l的路径
- l节点本身
根据前缀和的性质,总和可以表示为:
sum[u][k] + sum[v][k] - sum[l][k] - sum[parent[l]][k]
其中parent[l]是l的父节点。这个公式的推导需要仔细画图理解,是这类问题的核心所在。
3. 倍增法求LCA的实现细节
3.1 LCA问题概述
LCA(Lowest Common Ancestor)是指树中两个节点的最近公共祖先。计算LCA是处理树上路径问题的关键步骤。对于这个问题,我们需要在O(logn)时间内完成每次查询。
3.2 倍增算法原理
倍增法的核心思想是预处理每个节点向上2^k层的祖先,使得我们可以快速"跳跃"式地查找LCA。具体步骤:
-
预处理阶段:
- 对每个节点u,计算up[u][k],表示u的2^k级祖先
- 使用动态规划:up[u][k] = up[up[u][k-1]][k-1]
- 同时记录每个节点的深度depth[u]
-
查询阶段(求u和v的LCA):
- 先将较深的节点上提到与另一个节点相同深度
- 然后两个节点一起向上跳跃,直到找到LCA
3.3 实现中的注意事项
- 树的存储:通常使用邻接表存储树结构
- 根的选择:可以任意选择,通常选节点1为根
- 边界处理:注意根节点的父节点设为0或-1
- 跳跃顺序:从大到小尝试跳跃(先尝试大的步长)
4. 完整解决方案与优化
4.1 预处理阶段
我们需要预处理两个关键信息:
- 每个节点的sum[u][k],对于k=1到50(题目限制)
- 每个节点的倍增祖先up[u][k],k=0到log2(n)
预处理伪代码:
cpp复制void dfs(int u, int p) {
depth[u] = depth[p] + 1;
up[u][0] = p;
for(int k=1; k<=LOG; k++)
up[u][k] = up[up[u][k-1]][k-1];
for(int k=1; k<=50; k++)
sum[u][k] = sum[p][k] + pow(depth[u], k);
for(int v : adj[u]) {
if(v != p) dfs(v, u);
}
}
4.2 查询处理
对于每个查询(u, v, k):
- 计算l = LCA(u, v)
- 结果为sum[u][k] + sum[v][k] - sum[l][k] - sum[up[l][0]][k]
LCA计算函数:
cpp复制int lca(int u, int v) {
if(depth[u] < depth[v]) swap(u, v);
// 提升u到与v同深度
for(int k=LOG; k>=0; k--)
if(depth[u] - (1<<k) >= depth[v])
u = up[u][k];
if(u == v) return u;
// 一起向上跳跃
for(int k=LOG; k>=0; k--)
if(up[u][k] != up[v][k])
u = up[u][k], v = up[v][k];
return up[u][0];
}
4.3 复杂度分析
- 预处理:O(n logn)时间(倍增表) + O(n*50)时间(sum数组)
- 每次查询:O(logn)时间(LCA) + O(1)时间(计算结果)
- 总复杂度:O(n logn + n*50 + m logn),完全可处理1e5量级的数据
5. 实现细节与常见错误
5.1 幂次计算的优化
直接使用pow函数计算depth^k可能会比较慢,特别是k达到50时。可以采用预处理幂次表:
- 预处理所有可能的depth(1到n)的1到50次幂
- 或者对每个k,预处理depth的k次幂
5.2 树的存储方式
使用vector邻接表比传统的链表式邻接表更高效(缓存友好):
cpp复制vector<int> adj[MAXN];
5.3 常见错误排查
- 忘记初始化根节点的sum和up数组
- 在LCA计算中,跳跃顺序错误(必须从大到小)
- 没有处理u或v就是LCA的特殊情况
- 模数运算时出现负数(需要加模数再取模)
5.4 空间优化技巧
对于sum数组,注意到k最大50,而n可能1e5,直接存储sum[n][51]会占用约20MB空间(在允许范围内)。如果空间紧张,可以:
- 按需处理k值,而不是预处理所有k
- 使用更紧凑的数据类型(如int代替long long)
6. 扩展与变式思考
6.1 支持动态修改
如果题目要求支持修改节点的深度,我们可以考虑:
- 树链剖分+线段树维护路径信息
- 动态树(LCT)维护路径统计
但这会大大增加实现复杂度,需要权衡查询和修改的效率。
6.2 其他路径统计问题
类似的技巧可以应用于:
- 路径上节点值的和/最大值
- 路径上满足某种条件的节点计数
- 路径上的位运算结果
关键在于如何设计前缀和的定义以及路径的拆分方式。
6.3 实际应用场景
这类算法在以下场景有实际应用:
- 网络路由中的路径指标统计
- 家谱分析中的亲属关系计算
- 组织结构图中的信息汇总
理解这些背景可以帮助我们更好地把握算法的核心思想。