Files
leetcode-go/二叉树/40-二叉树的直径.md
T

13 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
二叉树
深度优先搜索
递归
后序遍历
2026-05-17 12:00

40-二叉树的直径

题面

给你一棵二叉树的根节点,返回该树的 直径。

[!question] 💡 核心定义

  • 直径 = 树中任意两个节点之间最长路径的 边数
  • 这条路径可能经过也可能 不经过 根节点
  • 「长度」= 路径上的 边数(不是节点数!)

[!warning] ⚠️ 易混淆点 「深度」vs「直径」:

  • 最大深度:从根到最远叶子节点的单向距离(一条线)
  • 直径:任意两节点之间的双向路径(可能穿过某个中间节点)

[!tip] 🔑 关键洞察 对于任意一个节点 node,如果有一条最长路径恰好穿过它,那么:

穿过 node 的路径长度 = node 左子树的最大深度 + node 右子树的最大深度

而整棵树的直径就是所有节点中,「穿过它的最大路径」的 最大值。

示例 1:

输入:root = [1,2,3,4,5]
输出:3
解释:取路径 [4→2→1→3] 或 [5→2→1→3],共 3 条边。

    1           ← 直径穿过了 1
   / \
  2   3        ← 路径可以是 4→2→1→3
 / \
4   5

示例 2:

输入:root = [1,2]
输出:1
解释:只有 1→2 这一条边

约束:

  • 树中节点数目在范围 [1, 10^4] 内
  • -100 <= Node.val <= 100

思路

方法一:DFS 后序遍历 + 全局最大值 ⭐⭐⭐

核心洞察

[!question] 🤔 思考

第 37 题我们求过「最大深度」——它是从根出发往下走的最远距离。 现在要求「直径」——它可以是树上任意两节点之间的距离。

想象你在树上找最长路径,它会「穿过」某个节点:从上侧一棵子树上来,再从下侧另一棵子树下去。这个「中间节点」不一定是根!

以 [] (1,2,3,4,5) 为例,逐步计算每个节点的数据:

    1                    ← 左深=2, 右深=1, 穿过它的直径=3 ✅ 答案!
   / \
  2   3                ← 节点3是叶子:左深=0, 右深=0, 直径贡献=0
 / \
4   5                  ← 节点4,5都是叶子:深度各为0, 直径贡献各为0

[!info] 📐 DFS 返回值的约定 在这道题的标准解法中,dfs(node) 的返回值代表 以 node 为根的树的最大深度,约定空节点返回 0,计算公式为:

dfs(nil)     = 0
dfs(leaf)    = max(0, 0) + 1 = 1
dfs(internal) = max(dfs(left), dfs(right)) + 1

此时穿过当前节点的路径长度恰好等于 leftDepth + rightDepth(即边数),无需额外加减。验证:节点 1 的左深度 = 2、右深度 = 1,穿过它的路径 = 2+1 = 3 条边 ✅

执行流程图解:

flowchart TD
    Post["后序遍历:先子节点,后自身"] --> L["遍历左子树,获得左深度"]
    L --> R["遍历右子树,获得右深度"]
    R --> C["当前节点处更新直径\n通过此节点的路径 = 左深度 + 右深度"]
    C --> Ret["返回当前节点的最大深度\n给父节点使用"]
    
    style Post fill:#fff3e0,stroke:#e65100
    style C fill:#fce4ec,stroke:#c62828
    style Ret fill:#e3f2fd,stroke:#1565c0
    
    linkStyle 0,1,2 stroke-width:3px

具体对 [] (1,2,3,4,5) 每个节点的详细计算:

flowchart TD
    N4["节点 4\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
    N5["节点 5\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
    N3["节点 3\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
    N2["节点 2\n左深=1, 右深=1\n直径贡献=1+1=2, ans=2\n返回 max=1"]
    N1["节点 1\n左深=2, 右深=1\n直径贡献=2+1=3, ans=3\n返回 max=2"]
    
    N4 & N5 --> N2
    N3 --> N1
    N2 --> N1
    
    style N4 fill:#e3f2fd
    style N5 fill:#e3f2fd
    style N3 fill:#e3f2fd
    style N2 fill:#fff3e0,stroke:#e65100
    style N1 fill:#fce4ec,stroke:#c62828,stroke-width:3px

执行流程图解:

flowchart TD
    Post["后序遍历:先子节点,后自身"] --> L["遍历左子树,获得左深度"]
    L --> R["遍历右子树,获得右深度"]
    R --> C["当前节点处更新直径\n通过此节点的路径 = 左深度 + 右深度"]
    C --> Ret["返回当前节点的最大深度\n给父节点使用"]
    
    style Post fill:#fff3e0,stroke:#e65100
    style C fill:#fce4ec,stroke:#c62828
    style Ret fill:#e3f2fd,stroke:#1565c0
    
    linkStyle 0,1,2 stroke-width:3px

具体对 [] (1,2,3,4,5) 每个节点的详细计算:

flowchart TD
    N4["节点 4\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
    N5["节点 5\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
    N3["节点 3\n左深=0, 右深=0\n直径贡献=0\n返回 max=0"]
    N2["节点 2\n左深=max(4)=1, 右深=max(5)=1\n直径贡献=1+1=2, ans=2\n返回 max=1"]
    N1["节点 1\n左深=max(2)=2, 右深=max(3)=1\n直径贡献=2+1=3, ans=3\n返回 max=2"]
    
    N4 & N5 --> N2
    N3 --> N1
    N2 --> N1
    
    style N4 fill:#e3f2fd
    style N5 fill:#e3f2fd
    style N3 fill:#e3f2fd
    style N2 fill:#fff3e0,stroke:#e65100
    style N1 fill:#fce4ec,stroke:#c62828,stroke-width:3px

算法框架

global ans = 0

function dfs(node):
    if node == nil:
        return 0
    
    // 后序:先左右子树,再当前节点
    left = dfs(node.left)   // 左子树的最大深度
    right = dfs(node.right) // 右子树的最大深度
    
    // 穿过当前节点的路径长度 = 左深度 + 右深度
    ans = max(ans, left + right)
    
    // 返回当前节点向上延伸的最大深度(供父节点使用)
    return max(left, right) + 1

function diameterOfBinaryTree(root):
    dfs(root)
    return ans

递归三要素

要素 内容
终止条件 节点为空时,返回 0
递归表达式 left = dfs(node.left), right = dfs(node.right)
副作用 在每个节点更新全局变量 ans = max(ans, left + right)
返回值 以当前节点为根的树的最大深度 max(left, right) + 1

为什么用后序遍历?

[!info] 🧠 自底向上的必然选择 要计算「穿过节点 A 的直径」,必须先知道 A 的左右子树各自有多深。这意味着子节点的处理必须在父节点之前完成——正是后序遍历(左 → 右 → 根)的执行顺序。


代码提示

DFS 伪代码

// 全局变量:记录最大直径
ans = 0

func dfs(node):
    if node == nil:
        return 0
    
    left = dfs(node.Left)      // 左子树深度(边数视角下的"层数")
    right = dfs(node.Right)    // 右子树深度
    
    ans = max(ans, left + right)  // 更新直径
    
    return max(left, right) + 1   // 返回当前节点深度给父节点

func diameterOfBinaryTree(root):
    dfs(root)
    return ans

Go 语言实现要点

[!note] 🐹 Go 中没有真正的"全局变量"最佳实践 在这道题中,有几种方式传递和修改 ans:

方案 写法 推荐度
指针传参 func dfs(node *TreeNode, ans *int) ⭐⭐⭐ 函数式风格,无副作用
闭包捕获 在 diameterOfBinaryTree 内定义 var ans int 然后用嵌套函数访问 ⭐⭐⭐ 简洁直观
返回值携带 func dfs(...) (depth, dia int) ⭐⭐ 稍显繁琐

推荐使用 闭包捕获 的方式,既干净又自然。


技巧

[!tip] 🔑 直径 vs 深度的关系速记

  • 深度:从某节点向下到最远叶子的「单向」路径长度
  • 直径:从某节点向「左下 + 右下」延伸的两条分支合并后的总长
  • 公式:diameter(node) = leftDepth + rightDepth
  • 整棵树的直径是所有节点中这个值的 最大值

[!tip] 🔑 为什么可以用一次 DFS 搞定? 很多人第一反应是「枚举每对节点 → BFS/DFS 求距离 → 取最大值」,这样是 O(n²)。但其实:

  • 对于每个节点,穿过它的直径 = 左深度 + 右深度
  • 左深度和右深度可以通过一次后序遍历全部算出
  • 同时更新全局最大值,只需 O(n) 时间

[!warning] ⚠️ 常见错误

  1. 误把直径当作最大深度 × 2:只有满二叉树/完美二叉树时才碰巧相等
  2. 忘记处理单边树的情况:比如左斜树,右深度恒为 0,直径 = 最大深度
  3. 把直径当成经过根的路径:直径可能经过任何节点,不只是根

[!info] 📊 复杂度速查

维度 结果
时间复杂度 O(n) — 每个节点恰好访问一次
空间复杂度 O(h) — h 为树高,递归栈深度;最坏 O(n)(链状),平均 O(log n)(平衡树)

[!connection] 🔗 与相关题目的联系

  • 37-二叉树的最大深度:直径问题的基础,深度是计算直径的子组件
  • 543. Diameter of Binary Tree:原题(英文) 两者本质相同,只是表述不同
  • 最长路径问题:这是一般图论中「树的直径」的特例。对无权树可通过两次 BFS 求解,但二叉树场景下 DFS 更直接

[!exercise] 💪 变体练习

  1. 如果路径长度定义为节点数而非边数,如何修改代码?→ 只需将 left + right 改为 left + right + 1(当前节点也计入)
  2. 如果树中有负权边(加权二叉树),还能用此方法吗?→ 不能,需要换成分治或树形 DP
  3. N 叉树的直径如何求解?→ 取最大的两个子节点深度之和作为直径贡献

代码

DFS + 闭包(推荐)⭐⭐⭐

/**
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
func diameterOfBinaryTree(root *TreeNode) int {
	// ans 通过闭包捕获,DFS 过程中不断刷新最大值
	ans := 0

	// dfs 返回以 node 为根的树的最大深度(层数)
	var dfs func(*TreeNode) int
	dfs = func(node *TreeNode) int {
		if node == nil {
			return 0
		}

		// 后序遍历:先获取左右子树的深度
		left := dfs(node.Left)   // 左子树的最大深度
		right := dfs(node.Right) // 右子树的最大深度

		// 穿过当前节点的路径 = 左深度 + 右深度
		// 这就是以当前节点为"最高点"的最长路径
		dia := left + right
		if dia > ans {
			ans = dia
		}

		// 返回当前节点向上传递的最大深度
		// 供父节点计算穿过它的直径时使用
		if left > right {
			return left + 1
		}
		return right + 1
	}

	dfs(root)
	return ans
}

[!success] ✅ 运行验证 这是 LeetCode 第 543 题,通过率约 62%。核心只有一句话:在每个节点处,用左深度+右深度更新答案,同时返回 max(左,右)+1。面试时可先用一句话概括思路,再展开细节。

DFS + 指针传参(函数式风格)⭐⭐

/**
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
func diameterOfBinaryTree(root *TreeNode) int {
	if root == nil {
		return 0
	}

	maxDia := 0

	// dfs 返回深度,通过指针修改 maxDia
	var dfs func(*TreeNode) int
	dfs = func(node *TreeNode) int {
		if node == nil {
			return 0
		}

		left := dfs(node.Left)
		right := dfs(node.Right)

		*maxDia = left + right
		if d := left + right; d > *maxDia {
			*maxDia = d
		}

		if left > right {
			return left + 1
		}
		return right + 1
	}

	dfs(root)
	return *maxDia
}

[!note] 🐹 两种写法的选择 闭包方式中 ans 是外层函数的局部变量,内层 dfs 直接读取和赋值,无需额外类型声明。指针方式更"显式"地表明 dfs 会修改外部状态,在团队协作中可读性更强。个人偏好闭包——Go 的闭包在语法上是原生支持的,不需要像 Java/C++ 那样用 AtomicInteger 或引用包装类。

对比:错误思路(仅考虑经过根的直径)❌

// ❌ 错误示范:这样做只能得到"经过根节点的直径"
func wrongApproach(root *TreeNode) int {
	if root == nil {
		return 0
	}
	// 只计算了穿过根节点的路径!
	// 如果最长路径不经过根,就会返回错误结果
	left := maxDepth(root.Left)
	right := maxDepth(root.Right)
	return left + right
}

func maxDepth(node *TreeNode) int {
	if node == nil {
		return 0
	}
	l, r := maxDepth(node.Left), maxDepth(node.Right)
	if l > r {
		return l + 1
	}
	return r + 1
}

[!failure] ❌ 为什么不正确? 上面的代码只考虑了穿过根节点的直径,但对于 [] (1,2,null,3,4):

    1
   /
  2
 / \
3   4

经过根的直径 = 1(左)+ 0(右)= 1 但正确答案是经过节点 2 的路径 3→2→4,长度为 2

必须用 DFS 在后序遍历过程中逐个节点尝试,才能覆盖所有可能!