☰
二叉树直径求解:从递归后序到边界条件与运行时错误排查
2026/10/10 0:52:26 网站建设 项目流程

1. 面试题的原始形态:直径问题到底在问什么

先还原一下题目。LeetCode 543那道经典题是这么描述的:给你一棵二叉树的根节点,返回这棵树的直径。这里的直径定义是任意两个节点之间路径上的边数最大值。很多人第一次看到这句话会有两个直觉误区:第一,觉得直径应该跟"经过根节点的最长路径"有关;第二,觉得既然叫直径,那就应该是对称的、顺着树的最左和最右叶子走的路径。两个都不对,或者说只是特例,不是一般情况。

我举个例子你就明白了。考虑一棵形状不规则的树:根节点的左子树很深,有 5 层;右子树只有一个节点。那么最长的路径大概率是"从左子树最深的叶子出发,向上走到根,再向下走一步到右孩子",这条路径的边数大约是 5 + 1 = 6。但是,如果左子树内部本身就有一条更长的折线路径——比如左子树的某个节点它的左右两侧各挂了很深的链——那么真正的最长路径可能根本不需要经过整棵树的根节点。它可能在左子树内部就完成了"从某个叶子出发,向上走到某个中间节点,再向下走到另一侧叶子"的全程。

这才是直径问题的关键:直径本质上是"经过某个转折节点"的最长路径,而我们要在所有节点里挑那个转折点。路径本身是"先向上、后向下"的,但在递归视角里,我们可以把它理解为:对于任意节点,以它为最高点(转折点)的路径长度 = 它左子树的高度 + 它右子树的高度。全树的直径就是所有节点这个值里最大的那一个。

注意题目原文问的是边数。如果用节点数来衡量直径,答案就是边数加一,很多国内教材和面试手写题用节点数定义,这会在代码边界条件上造成差异,后面专门讲。先记住一个统一口径:本文默认"直径 = 最长路径的边数",这是 LeetCode 和大部分线上评测系统的口径。

"(一)"这个系列名是我自己起的,因为直径问题深挖下去可以展开的东西非常多:从朴素递归到记忆化、到后序遍历一次搞定、再到带权直径、N 叉树直径、输出具体路径、跟树形 DP 的关联。一篇文章塞不下,我打算分几篇逐步拆。这篇先解决最核心的部分:定义、朴素思路、最优解法、边界条件,以及为什么很多人在写二叉树程序时会莫名报运行时错误——这点跟直径问题关系极深。

2. 为什么"每个节点的左右子树高度之和取最大值"就是答案

2.1 先从路径的几何形态说起

任意一条路径,在二叉树这个结构里,你从起点走到终点,途经的节点序列会有一个唯一的"最高点"。什么叫最高点?就是在这条路径上离整棵树的根节点最近的那个节点。因为二叉树每个节点只有一个父节点,所以任何一条连接两个节点的路径,必然先从一个端点向上走到某个共同祖先,再向下走到另一个端点。这个共同祖先,就是最高点。在递归处理时,它可以是任意一个节点,不一定是最初的根节点。

举一个具体的例子。有一颗树,节点分布如下:

  • 根节点 R,左孩子 A,右孩子 B
  • A 有左孩子 C,C 又有一个很深的左链 C1 -> C2 -> C3
  • A 的右孩子 D,D 的右孩子 E
  • B 是一个叶子

现在从 C3 出发到 E,路径是 C3 -> C2 -> C1 -> C -> A -> D -> E,整条路径的最高点是 A,而不是 R。这条路径的长度(边数)是 6,它就是由 A 的左子树高度(以 A 为根,左子树最深是 C3 到 A 的距离,为 4)加上 A 的右子树高度(E 到 A 的距离,为 2)得到的:4 + 2 = 6。如果只盯着根节点 R 算,左子树高度 5,右子树高度 1,加起来也是 6,巧了。但如果树形再偏一点,最高点不在根节点,那只看根节点就会漏掉正确答案。

这就是核心思维的转换:我们不去枚举路径的起点和终点,而是枚举路径的最高点(转折节点)。对每个节点,以它为最高点的最长路径长度是确定的——就是左右子树各自的最大深度之和。那么全局最长路径,就是对所有节点的这个值取 max。

2.2 高度和深度的纠缠

这里必须讲清两个概念,否则代码边界很容易写错。深度(depth)是从根节点往下数的层数,根节点深度为 0,往下每层加 1。高度(height)是从叶子节点往上数的层数,一般定义叶子节点高度为 0,或者定义空节点高度为 -1、叶子节点高度为 0,也有定义单节点高度为 1 的。我们写代码时最常用的口径是:

  • 空节点(None)的高度为 0
  • 叶子节点的高度为 1
  • 任意节点的高度 = max(左子树高度, 右子树高度) + 1

在这个口径下,以某个节点为转折点、左右子树分别向下延伸到最深时,路径边数恰好就是左子树高度 + 右子树高度。因为左子树高度表示"从左子树最深的叶子向上走到当前节点"的边数,右子树高度同理,二者之和就是整条路径的边数。注意,如果某个节点只有左子树没有右子树,那么右子树高度为 0,此时以它为转折点的路径就是"从左子树最深叶子走到它"这条单侧路径,长度等于左子树高度。这也是合法的候选路径——比如一条纯下坠的链。

很多初学者在这一点上会困惑:路径不是应该有两个端点吗?怎么只有一个子树也算?对,路径的两个端点确实都存在,只是其中一个端点就是当前节点本身。比如节点 X 只有左孩子,并且左孩子往下是一条链,那么从链底叶子到 X 的这条路径,转折点就是 X,路径长度就是左子树高度。直径计算时必须把这些单侧候选也算进去,否则链状树(每个节点只有一个孩子)的直径会算成 0,那就大错特错了。

2.3 为什么这等价于树形 DP

直径问题的本质是一个树形 DP:每个节点需要向上层返回一个信息——"我这棵子树的最大高度是多少",同时利用左右子树返回的信息更新全局答案。这个返回信息的动作,天然对应二叉树的后序遍历:先处理左子树,再处理右子树,最后处理当前节点。因为当前节点需要等左右子树的结果都出来之后才能计算自己的高度,以及更新以自己为转折点的路径长度。

这也就是为什么直径问题跟"二叉树的遍历"热词绑在一起:它不是随便一种遍历都能做的。前序遍历做不了,因为访问当前节点时左右子树信息还没拿到;中序遍历也麻烦,边界不好处理。后序遍历是唯一"先孩子后自己"的天然匹配。理解这一点,你就不会在面试时写出"想先算根节点再递归孩子"的别扭代码。

3. 从错误解法到最优解法的完整演进

3.1 第一版:暴力递归,正确但超时

最容易想到的写法是这样的:对每个节点,都计算一次左右子树的高度,并累加更新答案。写出高度函数,然后递归遍历每个节点做同样的事。伪代码如下:

def height(node): if node is None: return 0 return max(height(node.left), height(node.right)) + 1 def diameter(root): if root is None: return 0 cur = height(root.left) + height(root.right) child = max(diameter(root.left), diameter(root.right)) return max(cur, child)

这个写法逻辑对不对?对。但错在效率上。假设这棵树是一个完全二叉树,节点数量是 n,你每访问一个节点,都要重新递归计算一次它的左右子树高度。高度函数本身是 O(子树节点数),而 diameter 函数对每个节点都调用一次,于是每个节点被 height 函数反复扫描多次。整体时间复杂度会退化到 O(n log n) 甚至 O(n²),取决于树的形状。如果是一棵接近链状的树,height 在每一层都要往下走到叶子,diameter 又是从上到下递归,总复杂度直接是 O(n²)。线上评测数据一刁钻,这个版本必超时。

我在实际刷题时见过不少初学者卡在这一步:代码逻辑完全正确,就是用判断系统超时,最后怀疑是语言问题或者评测系统抽风。根本不是,就是重复计算太多。

3.2 第二版:后序遍历一次搞定,每个节点只访问一遍

优化的核心思路很朴素:既然每个节点的高度反正都要被父节点用,那不如在递归返回高度的过程中,顺手把以当前节点为转折点的路径长度更新掉。每个节点访问一次,每次 O(1) 操作,整体 O(n)。

正确的写法(LeetCode 543 的标准解):

class Solution: def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int: self.ans = 0 def height(node): if node is None: return 0 left = height(node.left) right = height(node.right) self.ans = max(self.ans, left + right) return max(left, right) + 1 height(root) return self.ans

逻辑是这样:height 函数返回的是"以 node 为根的子树最大高度"。在它返回之前,左右子树的高度已经算完了(后序),此时 left + right 正好是以 node 为转折点的最长路径边数。用全局变量 self.ans 去跟它比较。然后当前节点的高度就是左右子树更大的那个加一,返回给上一层。最终答案就是所有节点 left + right 的最大值。

这个代码还有一个小细节值得注意:如果根节点为 None,height 直接返回 0,self.ans 保持 0,结果正确,所以不需要对空树做额外判断。

3.3 第三版:不用全局变量,用返回值封装

有的面试官不喜欢看到全局变量或者类属性,怕多线程环境出问题(虽然算法题里一般无所谓)。可以改成返回两个值的写法:每个递归调用返回 (当前子树高度, 当前子树内部的最大直径)。这样完全无副作用,状态都通过返回值传递。示例如下:

def dfs(node): if node is None: return 0, 0 left_height, left_diam = dfs(node.left) right_height, right_diam = dfs(node.right) cur_height = max(left_height, right_height) + 1 cur_diam = max(left_diam, right_diam, left_height + right_height) return cur_height, cur_diam

最终直接取 dfs(root)[1] 即可。这个写法更函数式,也方便改成其他语言思路。我个人刷题时倾向于全局变量的版本,代码短;但写工程代码或递归层级比较深的场景,返回值封装更安全。面试时建议两个都会,先说全局变量的版本,然后主动提一句"也可以封装成返回值来避免全局状态",这是加分项。

4. 边界条件与最容易翻车的细节

4.1 空节点高度是 0 还是 -1,代码差在哪儿

这是直径问题最大的一个坑。如果你定义空节点高度为 0,那么叶子节点高度为 1,单节点树的直径是 0(left + right = 0 + 0 = 0),正确。如果你定义空节点高度为 -1,那么叶子节点高度为 0,单节点树的直径是 -1 + -1 = -2,明显不对。这时你需要在更新答案时写成 left + right + 2 才能把边数凑回来。其实两条路都走得通,只要你统一口径。我见过有人在一次代码里混用了两套口径——空节点返回 0,但在根节点那里又额外加一,结果差了 1,排查了半小时。

我的建议是:一律用"空节点高度为 0、叶子节点高度为 1"这套,因为它的直觉最简单——高度就是节点数方向的深度,直径更新直接用 left + right,不在返回值上做任何补偿。你唯一要记住的是,这个口径算出来的直径是边数;如果题目要求节点数,最后结果加一即可。

4.2 单节点、空树、链状树的特殊行为

空树直径显然是 0。单节点树直径也是 0,因为没有边。两个节点的树直径是 1。这些在标准解法里都是自动正确的,不需要特殊补丁。真正容易出错的是链状树——每个节点只有一个孩子,比如一直向左延伸。此时任意节点只有一个子树有高度,另一个子树高度为 0,所以 left + right 实际上就是那一条链的深度。例如一条 5 个节点的链,最高的那个节点 left = 4,right = 0,diameter = 4,正是链两端叶子之间的边数,正确。如果有人在更新答案时写了 left + right + 1,这条链就会被算成 5,直接报错。

还有一种特殊情况:树是"完全偏向一侧"的形状,比如根只有右子树,右子树又只有右子树。这时候很多新手会下意识觉得直径应该是 0,因为没有"左右"之分。但路径的两个端点可以是一个叶子到根节点,所以直径是树的高度。标准代码用 left + right 时,由于每个节点都有一侧为 0,累加出来正好是高度,不需要额外处理。

4.3 全局变量在多次调用之间的残留

如果你用的是类属性而不是实例属性,比如写成:

class Solution: ans = 0 def diameterOfBinaryTree(self, root): ...

那么在一个评测进程里连续测试多个用例时,self.ans 不会自动重置,第二次调用时会带着上一次的值继续更新。LeetCode 的测试框架通常会为每个用例新建一个 Solution 实例,所以 class variable 的坑不一定触发,但本地批量测试时一定触发。稳妥做法是写 self.ans,在 diameterOfBinaryTree 方法内部初始化 self.ans = 0,而不是定义成类变量。这是我真实踩过的坑,本地跑了三个用例,第一个结果正常,第二个开始结果虚高,排查了半天才看到是类变量没清零。

4.4 前序遍历的"假答案"

还有一种错误写法:在递归函数里先更新 ans = max(ans, height(root.left) + height(root.right)),再递归左右子树,同时 height 函数内部又重新调用 height 计算子树高度。这就是前面说的 O(n²) 版本,逻辑无害但性能差。如果数据不大也能过,但要注意它的"假象":在小数据集上它和标准解输出一模一样,导致你以为已经最优了。我在给同事做 code review 时就见过这种写法,问作者为什么不在后序位置更新,对方说"我更新了啊"。实际上前序位置的更新没问题,问题在 height 被反复调用,这不是一个位置能修正的,必须改成一次后序遍历同时返回高度和更新答案。

5. "为什么我写二叉树程序总是报运行时错误":排查链路实录

热搜词里有个高频问题:写二叉树程序时为什么总是报运行时错误。我在刷题群里的确反复见到,而且直径问题就是因为有两个特别容易触发运行时错误的点,才把它单独拎出来讲。

5.1 最常见的元凶:None 的 .val

很多非递归二叉树代码会这样写:

cur = node while cur: cur = cur.left # 然后在某个地方 print(cur.val) # 这里 cur 已经变 None 了

while 循环退出时没有意识到 cur 是 None,还在使用 cur 的属性。直径相关的递归代码也有类似变体:有人在 height 里写了 node.left.value 来访问值,但根本没考虑 node.left 为 None 的情况。这是所有二叉树运行时错误里占比最高的,没有之一。排查方法很简单:报错信息定位到某一行,看这一行里访问的到底是 node 还是 node.left / node.right,然后回头检查那一层递归或循环有没有保证它不为 None。

5.2 第二个元凶:递归深度爆栈

直径问题的递归深度等于树的高度。如果树是 10000 层深的链,Python 默认递归深度上限是 1000,直接 RecursionError。这不是你代码逻辑错,而是语言限制。排查方法也简单,看到 RecursionError 不要愣着,先检查树形状是不是退化成链了,再考虑用迭代 + 显式栈重写,或者调高递归上限(但调高有栈溢出的风险,不推荐生产环境这么做)。有个取巧的判断方法:报错信息里 RecursionError: maximum recursion depth exceeded 后面跟着的那一行循环往复出现,基本就是递归层级太深。

5.3 第三个元凶:递归出口写错位置

有人写后序遍历时,把出口放在更新 ans 之后,导致子树为空时提前返回,但之前又访问了 node.val。举例:

def dfs(node): if node.left is None and node.right is None: return 1 ...

这个写法对于叶子节点能处理,但对只有一个孩子的节点,访问 node.left 时它可能是 None 且没有走到正确的出口,就会抛 AttributeError。正确的递归出口应该是 if node is None: return 0,不管节点头部是什么形状,遇到空节点就返回,而不是判断"是否是叶子节点"。凡是写"if 节点没有孩子才返回"的二叉树算法,几乎都会在非满树情况下出 bug。

5.4 一套通用的定位思路

我的建议是:遇到二叉树运行时错误,不要抓瞎。先分成三类——第一类是访问 None 的属性,第二类是递归深度,第三类是递归出口逻辑错误。打印日志或加断点看调用栈。如果栈里面反复出现同一个函数的同一个行号,基本是第三类;如果报错行访问的是某条 Node 的属性而前面的调用栈显示参数就是 None,属于第一类。掌握了这三板斧,二叉树相关的运行时错误能解决九成。很多崩溃其实是逻辑边界没想清楚,跟语言本身无关。

6. 直径问题与遍历、搜索二叉树、线索二叉树的关系

6.1 后序遍历为什么是"命中注定"的解法

写直径题时很多人是背代码,不理解为什么这里要用后序而不是先序。其实只要画出一个节点、它的左子树、右子树,你就明白:直径的候选值(left + right)依赖左右子树的高度,而高度是自底向上汇聚出来的。这决定了你必须先完全处理完左子树和右子树,再处理当前节点——这不就是后序遍历的定义吗?

换一种理解方式:树的遍历顺序本质上是父子节点的处理顺序。先序是"先父后子",后序是"先子后父"。对于任何需要"子结果聚合到父节点"的问题——树的高度、直径、最大路径和、树的同构判断——后序都是天然选择。直径问题只是树形 DP 的一个最基础例子,掌握了它,后面的树形 DP 问题都多了个锚点。

6.2 搜索二叉树对直径计算没有特殊影响,但要注意"直径"和"搜索"思维混用

搜索二叉树(BST)的中序遍历有序,这让很多"查找类"问题变简单。但直径不是查找类问题,它只关心树的形态,不关心节点值的顺序。BST 和非 BST 的直径计算方式完全一致,因为在计算过程中你根本不需要比较 val。你只需要注意一点:如果题目允许树的节点值任意,那直径跟 val 无关;如果题目说"这棵树是 BST",那它应该是为了引入其他约束(比如直径路径上的节点值也要有序?),这时候就要重新读题,不要惯性忽略这个前提条件。

我见过有人在求 BST 直径时非要先中序遍历拿到有序序列,再在序列上做逻辑,绕了一大圈,完全没必要的。树的直径永远是结构问题,不是顺序问题。

6.3 线索二叉树能优化直径吗?不能,但也不是完全无关

线索二叉树(Threaded Binary Tree)的核心思想是利用叶子节点的空指针,指向中序遍历的前驱或后继,从而在不使用栈和递归的情况下做中序遍历。它优化的是"从一个节点找另一个节点"的遍历成本,尤其适合需要频繁中序遍历的场景。

那求直径能用线索二叉树加速吗?答案是不能。因为直径依赖自底向上的高度聚合,你必须先知道叶子层的信息,再一级级往上汇总,线索二叉树加速的是水平方向的遍历,正好不是直径需要的方向。但线索二叉树题里"空指针的利用"这个思想,跟直径递归里"空节点返回 0"的边界处理有异曲同工之妙:两者都在处理空节点,只是出发点不同。所以如果你复习到这个知识点,会发现边界处理经验可以迁移。

6.4 从热词联想到的复习主线

二叉树的遍历、深度、直径、线索化这些热词串起来,其实就是一条完整的二叉树学习路径:先学四种遍历(先序/中序/后序/层次),理解递归序和时间戳;再学深度与高度的求法,理解自底向上的返回值;然后做直径,理解"利用返回值顺便更新答案";最后学各种变体(最大路径和、树形 DP、线索树)。直径是这条链上承上启下的那个节点。我在复习时习惯画一张这样的推进图,每学一个新问题就问自己:跟遍历顺序有关吗?返回值是什么?用递归还是迭代?这三个问题想清楚,二叉树一半的题都能秒解。

7. 一组能直接抄的模板与自测用例

7.1 模板汇总

我把常用的直径模板整理成一个表,方便对照使用:

版本核心代码时间复杂度适用场景
暴力递归每个节点调用 height,取 left+right 最大值O(n log n) ~ O(n²)仅限教学演示,不推荐提交
后序 + 实例属性全局 ans = max(ans, left + right),返回高度O(n)面试刷题首选,代码最短
后序 + 双返回值返回 (高度, 子树直径)O(n)工程安全,避免全局变量
后序 + 可变对象统一更新用 list 或 dict 装 ans,内部函数更新O(n)某些语言没有闭包修改外部变量的场景

Python 里闭包修改外部变量时要加 nonlocal,或者直接用 self.ans / list 包一层,否则 UnboundLocalError 会烦死你。这也是一个运行时错误的高频来源,跟前面第 5 节说的三类并列,可以记为第四类:闭包变量作用域问题。

7.2 自测用例清单

写完代码不要直接交,跑一遍这些用例,基本能覆盖全部边界:

  • 空树:diameter(None) 应为 0
  • 单节点:diameter(TreeNode(1)) 应为 0
  • 两个节点:根 + 左孩子,应为 1
  • 三个节点完全二叉树:根左右各一个孩子,应为 2
  • 一条左链,5 个节点:应为 4???
  • 非平衡树:左子树很深且有内部折线路径,右子树很浅,检查答案是否取在左子树内部节点
  • 根节点左右子树高度相同的形状:确认答案为 left + right,而不是 left + right + 1

真实经验:我第一次写直径树时,就是在"三个节点完全二叉树"上犯了难,结果加了 1,变成 3,提交失败。后来才想明白路径边数是节点间连线的数量,三个节点连成 V 形只有两条边,答案 2。这个"加一还是不加一"的困惑,几乎所有初学者都有,记住一点就不会错:对每个节点更新答案时用的是左子树高度加右子树高度,高度本身就是从该节点到子树最深叶子的边数,自然不需要再加当前节点的"存在"。

8. 从直径出发的下一步延伸(个人经验)

写到这里,"二叉树的直径(一)"的核心内容基本讲完了:定义、数学转化、三种实现、边界条件、运行时错误排查、与相邻热词知识点的关系。照我的习惯,每个经典题至少要再想两个变体,才算真正吃透。直径问题常见的变体有:输出直径路径而不仅仅是长度、带权直径(每条边有不同权值,求最大权路径)、以及 N 叉树的直径。它们的核心思路都能从本文的"后序遍历自底向上聚合"派生出来。

输出直径路径的难点不在找长度,而在记录是哪个节点贡献了最大 left + right,以及如何回溯出具体的路径两端。很多面试官在你写完标准解之后会追问这一条,建议提前思考一下,不要等被问住。带权直径需要把"子树高度"改成"子树最大带权路径长度",仍是一次后序遍历,更新式从 left + right 变成 max_weight_left + max_weight_right。N 叉树直径只是把左右子树的二元逻辑推广到多个子节点,取最大的两个子树值相加。

这些内容篇幅不小,正好留给下一篇。如果你在本篇的边界条件和运行时错误排查里把自己的代码调通,那么下一篇讲变体时你会轻松很多。我一直认为,树的问题最好最笨重地画图、最小规模实例、最极端退化形状跑一遍,比记一百个模板都管用。直径问题就是验证这套方法论的好靶子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询