Luogu P3884 [JLOI 2009] 二叉树问题

前置分析:拆解问题

这道题要求我们计算并输出三个值:

  1. 树的深度 (Depth):从根节点到最远叶子节点路径上的节点数。根节点深度为 1。
  2. 树的宽度 (Width):所有层中,节点数最多的一层有多少个节点。
  3. 节点间距离 (Distance):一个自定义的距离。从节点 \(u\) 到节点 \(v\) 的距离定义为:从 \(u\) 向上走到它们最近公共祖先 (LCA) 的路径长度的两倍,加上从 LCA 向下走到 \(v\) 的路径长度。

思路讲解:逐个击破

数据结构:如何表示一棵树?

题目给出了 \(n-1\) 条边,形式为 u v,并保证 uv 的父节点。这是一个非常直接的父子关系。由于每个父节点最多只有两个子节点(题目明确是二叉树),我们可以很自然地想到用一个 Node 类来表示每个节点,包含以下信息:

  • father:指向父节点的引用(或 ID)。
  • left, right:指向左右子节点的引用(或 ID)。
  • deep:节点的深度。

我们可以用一个字典或数组来存储所有的 Node 对象,通过节点 ID 快速访问。

# 使用字典,可以通过节点 ID (整数) 方便地索引到 Node 对象
nodes = {i: Node() for i in range(1, n + 1)}

核心任务一 & 二:计算深度和宽度

深度和宽度都是与“层”相关的概念。处理与层序相关的问题,广度优先搜索 (BFS) 是最自然、最有效的算法。我们的策略是:

  1. 从根节点 1 开始 BFS。创建一个队列,并将根节点入队。
  2. 在 BFS 的过程中,我们可以轻易地计算出每个节点的深度。根节点 1 的深度为 1,对于任何从队列中取出的节点 u,其子节点 v 的深度就是 nodes[u].deep + 1
  3. 在计算深度的同时,我们可以统计每一层的节点数。用一个字典 depth_counts,键为深度,值为该深度的节点数。每当我们确定一个节点的深度 d,就执行 depth_counts[d] += 1
  4. 整个 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 算法或倍增法。一个非常简单直观的“爬山法”即可解决:

  1. 从节点 \(x\) 开始,不断向上访问其父节点,直到根节点。将这条路径上的所有节点都做一个标记(例如,设置 node.visited = True)。
  2. 接着,从节点 \(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)

经验教训与总结

这次调试和解题的过程给了我们几个宝贵的启示:

  1. “先建图,后计算”的原则
    这是本次最重要的教训。如果在读入边的同时计算深度,就默认了一个“事实”:父节点的深度总是比子节点先被正确计算。但题目并未保证输入的顺序。

    正确的做法是,将数据结构的构建阶段和属性的计算阶段完全分开。先循环读入所有输入,把树的父子、兄弟关系建立完整。然后,再从一个确定的起点(如根节点)开始遍历,去计算深度、宽度等依赖于全局拓扑结构的属性。

    这个原则适用于几乎所有的图论和树形问题,能让代码更健壮、更不易出错。

  2. 为问题选择合适的“武器”

    • BFS vs DFS:对于求解深度、宽度这类和“层级”强相关的问题,BFS是天然的选择。它的遍历顺序就是按层遍历,使得处理这类问题变得非常直观。
    • LCA算法:面对不同的数据规模,LCA有多种解法。对于 \(N \le 100\) 的小数据,简单模拟“向上爬”的方法就足够高效且易于实现。如果数据规模达到 \(10^5\),我们就需要考虑更高效的倍增法或 Tarjan 算法。根据数据范围选择算法,是竞赛中平衡实现复杂度和运行效率的关键。
  3. 仔细审题,不放过任何细节:本题的“距离”是一个自定义的概念,而不是我们通常理解的最短路径。如果忽略了“向根节点的边数的两倍”这个细节,就会导致第三问完全错误。花时间精确理解题目中的每一个定义,是正确解题的前提。

  4. 模块化编程,提高代码可读性:将LCA的计算逻辑封装成一个独立的函数 find_lca,使得主程序逻辑更加清晰。主程序只负责调用函数并组合结果,而复杂的实现细节则被隐藏在函数内部。这是一种良好的编程习惯,便于调试和维护。

posted @ 2025-07-24 09:29  AFewMoon  阅读(11)  评论(0)    收藏  举报