Luogu P3884 [JLOI 2009] 二叉树问题
前置分析:拆解问题
这道题要求我们计算并输出三个值:
- 树的深度 (Depth):从根节点到最远叶子节点路径上的节点数。根节点深度为 1。
- 树的宽度 (Width):所有层中,节点数最多的一层有多少个节点。
- 节点间距离 (Distance):一个自定义的距离。从节点 \(u\) 到节点 \(v\) 的距离定义为:从 \(u\) 向上走到它们最近公共祖先 (LCA) 的路径长度的两倍,加上从 LCA 向下走到 \(v\) 的路径长度。
思路讲解:逐个击破
数据结构:如何表示一棵树?
题目给出了 \(n-1\) 条边,形式为 u v,并保证 u 是 v 的父节点。这是一个非常直接的父子关系。由于每个父节点最多只有两个子节点(题目明确是二叉树),我们可以很自然地想到用一个 Node 类来表示每个节点,包含以下信息:
father:指向父节点的引用(或 ID)。left,right:指向左右子节点的引用(或 ID)。deep:节点的深度。
我们可以用一个字典或数组来存储所有的 Node 对象,通过节点 ID 快速访问。
# 使用字典,可以通过节点 ID (整数) 方便地索引到 Node 对象
nodes = {i: Node() for i in range(1, n + 1)}
核心任务一 & 二:计算深度和宽度
深度和宽度都是与“层”相关的概念。处理与层序相关的问题,广度优先搜索 (BFS) 是最自然、最有效的算法。我们的策略是:
- 从根节点
1开始 BFS。创建一个队列,并将根节点入队。 - 在 BFS 的过程中,我们可以轻易地计算出每个节点的深度。根节点
1的深度为1,对于任何从队列中取出的节点u,其子节点v的深度就是nodes[u].deep + 1。 - 在计算深度的同时,我们可以统计每一层的节点数。用一个字典
depth_counts,键为深度,值为该深度的节点数。每当我们确定一个节点的深度d,就执行depth_counts[d] += 1。 - 整个 BFS 结束后,遍历过程中遇到的最大深度
max_depth就是树的深度,depth_counts字典中所有值的最大值,就是树的宽度。
核心任务三:计算节点距离
根据题目定义,距离 \(d(x, y) = 2 \times \text{dist}(x, \text{LCA}) + \text{dist}(\text{LCA}, y)\)。这里的 \(\text{dist}(a, b)\) 指的是 \(a, b\) 之间的简单路径长度(边数)。这个任务可以分解为两步:
第一步:找到最近公共祖先 (LCA)
对于本题 \(n \le 100\) 的数据范围,我们不需要使用复杂的 Tarjan 算法或倍增法。一个非常简单直观的“爬山法”即可解决:
- 从节点 \(x\) 开始,不断向上访问其父节点,直到根节点。将这条路径上的所有节点都做一个标记(例如,设置
node.visited = True)。 - 接着,从节点 \(y\) 开始,同样不断向上访问其父节点。遇到的第一个被标记过的节点,就是 \(x\) 和 \(y\) 的最近公共祖先。
第二步:计算路径长度
一旦找到了 LCA,计算 \(x\) 到 LCA 和 LCA 到 \(y\) 的距离就变得很简单了。因为我们已经通过 BFS 计算出了所有节点的深度,路径长度可以直接通过深度差得到:
-
\[\text{dist}(x, \text{LCA}) = \text{nodes}[x].\text{deep} - \text{nodes}[\text{LCA}].\text{deep} \]
-
\[\text{dist}(\text{LCA}, y) = \text{nodes}[y].\text{deep} - \text{nodes}[\text{LCA}].\text{deep} \]
当然,也可以在向上爬的时候直接计数,就像你的原始代码那样,同样是正确且直观的。最终,将计算出的路径长度代入公式即可。
代码实现
import sys
import collections
# 定义节点类,存储树的结构和节点属性
class Node:
def __init__(self):
self.father = None
self.left = None
self.right = None
self.deep = 0
self.visited = False # 用于LCA计算
# 使用“爬山法”计算最近公共祖先(LCA)
def find_lca(nodes, x_id, y_id):
"""
1. 从 x 向上标记路径
2. 从 y 向上寻找第一个被标记的节点
"""
# 为了不污染原始节点信息,可以在每次调用前重置 visited 状态,
# 但在本题中 LCA 只调用一次,所以可以直接使用。
curr_id = x_id
while curr_id is not None:
nodes[curr_id].visited = True
curr_id = nodes[curr_id].father
curr_id = y_id
while not nodes[curr_id].visited:
curr_id = nodes[curr_id].father
return curr_id
# --- 主程序 ---
# 1. 初始化
n = int(sys.stdin.readline())
# 使用字典存储所有节点对象,方便通过ID访问
nodes = {i: Node() for i in range(1, n + 1)}
# 2. 构建树的链接关系
# 【关键点】先完整建树,再进行计算。避免因输入顺序导致深度计算错误。
for _ in range(n - 1):
u, v = map(int, sys.stdin.readline().split())
# 根据二叉树的性质,一个节点先有左孩子,再有右孩子
if nodes[u].left is None:
nodes[u].left = v
else:
nodes[u].right = v
nodes[v].father = u
# 3. 通过BFS计算深度和宽度
max_depth = 0
depth_counts = {} # 字典:{深度: 该深度节点数}
queue = collections.deque([1]) # BFS队列,从根节点1开始
nodes[1].deep = 1
nodes[1].father = None # 根节点的父节点为None
while queue:
u_id = queue.popleft()
d = nodes[u_id].deep
# 更新最大深度
if d > max_depth:
max_depth = d
# 统计当前深度的节点数
depth_counts[d] = depth_counts.get(d, 0) + 1
# 将子节点加入队列,并计算其深度
if nodes[u_id].left is not None:
v_id = nodes[u_id].left
nodes[v_id].deep = d + 1
queue.append(v_id)
if nodes[u_id].right is not None:
v_id = nodes[u_id].right
nodes[v_id].deep = d + 1
queue.append(v_id)
# 计算最大宽度
max_width = 0
if depth_counts: # 确保树不为空
max_width = max(depth_counts.values())
# 4. 计算指定两点的距离
s, t = map(int, sys.stdin.readline().split())
# 找到LCA
ancestor_id = find_lca(nodes, s, t)
# 计算 s 到 LCA 的距离 (向上爬的步数)
dist_s_to_lca = 0
curr_s = s
while curr_s != ancestor_id:
curr_s = nodes[curr_s].father
dist_s_to_lca += 1
# 计算 t 到 LCA 的距离 (向上爬的步数)
dist_t_to_lca = 0
curr_t = t
while curr_t != ancestor_id:
curr_t = nodes[curr_t].father
dist_t_to_lca += 1
# 根据题目定义的公式计算总距离
total_distance = dist_s_to_lca * 2 + dist_t_to_lca
# 5. 输出结果
print(max_depth)
print(max_width)
print(total_distance)
经验教训与总结
这次调试和解题的过程给了我们几个宝贵的启示:
-
“先建图,后计算”的原则:
这是本次最重要的教训。如果在读入边的同时计算深度,就默认了一个“事实”:父节点的深度总是比子节点先被正确计算。但题目并未保证输入的顺序。正确的做法是,将数据结构的构建阶段和属性的计算阶段完全分开。先循环读入所有输入,把树的父子、兄弟关系建立完整。然后,再从一个确定的起点(如根节点)开始遍历,去计算深度、宽度等依赖于全局拓扑结构的属性。
这个原则适用于几乎所有的图论和树形问题,能让代码更健壮、更不易出错。
-
为问题选择合适的“武器”:
- BFS vs DFS:对于求解深度、宽度这类和“层级”强相关的问题,BFS是天然的选择。它的遍历顺序就是按层遍历,使得处理这类问题变得非常直观。
- LCA算法:面对不同的数据规模,LCA有多种解法。对于 \(N \le 100\) 的小数据,简单模拟“向上爬”的方法就足够高效且易于实现。如果数据规模达到 \(10^5\),我们就需要考虑更高效的倍增法或 Tarjan 算法。根据数据范围选择算法,是竞赛中平衡实现复杂度和运行效率的关键。
-
仔细审题,不放过任何细节:本题的“距离”是一个自定义的概念,而不是我们通常理解的最短路径。如果忽略了“向根节点的边数的两倍”这个细节,就会导致第三问完全错误。花时间精确理解题目中的每一个定义,是正确解题的前提。
-
模块化编程,提高代码可读性:将LCA的计算逻辑封装成一个独立的函数
find_lca,使得主程序逻辑更加清晰。主程序只负责调用函数并组合结果,而复杂的实现细节则被隐藏在函数内部。这是一种良好的编程习惯,便于调试和维护。

浙公网安备 33010602011771号