我第一次在力扣上做 543 二叉树的直径 这道题,连续提交了三次才通过。不是没看懂题目,而是被“直径”这两个字带偏了,第一反应就是算左右子树的最大高度之和,结果遇到一个不在根节点处转弯的用例,直接打脸。后来我把这道题当作理解树形递归的样板题反复做了几遍,才发现 543 的核心根本不是“求最长路径”这么简单,而是“如何用一次后序遍历同时解决高度计算和路径枚举”。这篇文章想把整套思路完整讲清楚,附上可以直接跑的三种语言代码,以及我在本地反复测试时踩过的运行时错误。如果你刷树相关的题经常报错,这一篇应该能帮你省下不少排查时间。
1. 初见 543:为什么“左高加右高”是错的
1.1 题目到底在问什么
二叉树的直径,官方定义是“任意两个节点之间路径长度中的最大值”。请注意,这里说的路径长度是边的数量,不是节点的数量。比如一棵只有一个节点的树,直径是 0;一棵只有根节点和左孩子两个节点的树,直径是 1。
很多人看到“直径”会联想到圆的直径,天然觉得应该和根节点有关。但二叉树不是圆,它是一张没有环的图,所谓直径就是这张图里距离最远的两个节点之间的距离。因为树里任意两个节点之间只有一条简单路径,所以问题就变成了:找到两个节点,让它们之间的这条路最长。
端点是什么节点?大多数情况下是两个叶子节点。如果路径的一个端点不是叶子,它还能继续往外延伸,路径长度就会变大,所以最长路径的端点几乎总是叶子。唯一的例外就是整棵树只有一个节点,此时路径长度是 0。
为了让你直接 get 到题目的输出,看官方例子就够了:
1 / \ 2 3 / \ 4 5这棵树直径是 3。路径可以是 4 -> 2 -> 1 -> 3,也可以是 5 -> 2 -> 1 -> 3,都是经过 3 条边。注意这条路径的“最高点”是根节点 1,所以很多人会从这里开始产生错误直觉:直径等于左子树高度加右子树高度。
1.2 只算根节点的路径为什么会漏答案
我第一次写出来的答案是这样:递归求出根节点左子树的最大深度,再求出右子树的最大深度,然后相加,返回。
这个思路在官方例子上完全正确,左子树高度是 2,右子树高度是 1,加起来 3。但遇到下面这种形状的树,就会翻车:
1 / \ 2 3 / \ 4 5 / \ \ 6 7 8 / \ \ 9 10 11根节点 1 的左子树高度是多少?左子树以 2 为根,最深的路径是 2 -> 4 -> 6 -> 9,高度为 3。右子树以 3 为根,高度为 1。按照“左右高度相加”的错误算法,会得到 3 + 1 = 4。
但实际最长路径在哪?我们看节点 9 到节点 11:9 -> 6 -> 4 -> 2 -> 5 -> 8 -> 11,一共走过 6 条边。这条路径的“最高点”是节点 2,它根本没有经过根节点 1。正确答案应该是 6,而不是 4。
问题出在哪?最长路径并不一定经过根节点,它可以在任意节点的子树内部完成“转弯”。每一棵树的结构都不一样,我们不可能只靠根节点的一次计算就覆盖所有情况,必须想办法枚举所有节点作为“转弯点”的可能性。
2. 正确思路:每次递归都同时算高度和更新直径
2.1 为什么返回值必须是子树高度
很多人在看官方题解时最不理解的地方是:为什么递归函数返回的是“高度”,而不是“经过当前节点的最长路径长度”?
因为当前节点的路径长度由左右子树决定,但要计算一条“穿过当前节点”的路径,需要知道左右子树各自能往下延伸多长。这个“能延伸多长”,本质上就是子树高度。所以递归函数必须向上返回高度,而答案要用一个全局变量来维护。
具体来说,定义递归函数height(node),它做的事有两件:
- 计算以
node为根节点的树的高度,也就是从node到最远叶子节点的边数; - 在递归过程中,用
left_h + right_h更新全局的diameter变量。这里的left_h是左子树高度,right_h是右子树高度,两者加在一起,就是“以当前节点为最高点”时,能经过当前节点形成的最长路径长度。
换一种更直白的说法:对于任意一个节点,它都有机会成为某条最长路径的“最高点”。如果一条路径的转折点在这个节点,那这条路径一定由两部分组成:从左子树某个节点一路向上走到当前节点,再一路向下走到右子树某个节点。这条路径长度就是左子树高度加右子树高度。
于是问题就变成了:遍历树上所有节点,对每个节点都计算一次left_h + right_h,取最大值,就是整棵树的直径。递归天然能够做到这一点,因为递归会访问到每一个节点。
2.2 把路径想象成“在某节点转弯”
我后来想明白一个类比:树中任意一条最长路径,都可以看成是一段从某个叶子向上爬到某个“最高点”,再向下爬到另一个叶子的过程。这个最高点一旦确定,路径就被分成了“左半段”和“右半段”。
- 左半段长度,是当前节点左子树的高度;
- 右半段长度,是当前节点右子树的高度;
- 两者相加,就是路径总长度。
所以枚举路径的过程,本质上就是枚举“最高点”的过程。根节点只是树上无数节点中的一个,它可能是最高点,也可能不是。我们需要让每一个节点都有机会参与竞选。
这也解释了为什么这道题需要自底向上。要判断一个节点能不能成为最高点,必须先知道它左右子树各自的高度;而左右子树的高度,又依赖于它们子树的递归结果。这个依赖关系,决定了我们只能用后序遍历:先左,再右,最后处理当前节点。
后序遍历在这里不是一种风格偏好,而是逻辑上的必然。如果你尝试先访问当前节点再递归子树,你会发现此时还没拿到子树高度,什么都做不了。
3. 三种语言的代码实现,以及一次完整的递归模拟
3.1 Python / C++ / Java 的实现
先直接给代码。我用 Python 写一份完整版,再贴 C++ 和 Java 的对照版本。三份代码的核心逻辑完全一致,只是语法略有差异。
Python 版本:
class Solution: def diameterOfBinaryTree(self, root: TreeNode) -> int: self.diameter = 0 def height(node: TreeNode) -> int: if node is None: return 0 left_h = height(node.left) right_h = height(node.right) self.diameter = max(self.diameter, left_h + right_h) return max(left_h, right_h) + 1 height(root) return self.diameterC++ 版本:
class Solution { public: int diameterOfBinaryTree(TreeNode* root) { diameter = 0; height(root); return diameter; } private: int diameter = 0; int height(TreeNode* node) { if (node == nullptr) { return 0; } int left_h = height(node->left); int right_h = height(node->right); diameter = max(diameter, left_h + right_h); return max(left_h, right_h) + 1; } };Java 版本:
class Solution { private int diameter; public int diameterOfBinaryTree(TreeNode root) { diameter = 0; height(root); return diameter; } private int height(TreeNode node) { if (node == null) { return 0; } int leftH = height(node.left); int rightH = height(node.right); diameter = Math.max(diameter, leftH + rightH); return Math.max(leftH, rightH) + 1; } }需要注意一个细节:height函数的返回值是“当前子树的高度”,而题目要求的答案存在diameter里。二者不是同一个东西。如果你把返回值当成答案,那会遇到节点向上传递时路径被重复计算的问题;如果你把diameter当成高度返回,那高度就永远是左右高度之和,整棵树的高度会被夸大,答案也就不对了。
我在刚开始写代码的时候就在这个问题上栽过跟头。函数职责必须单一:向上返回高度,答案交给外部变量维护。
3.2 用官方例子把递归过程完整走一遍
为了让你彻底看懂递归过程,我把刚才那个官方例子拆开:
1 / \ 2 3 / \ 4 5从根节点 1 开始调用height(1),它不会立刻返回值,而是先递归调用height(2)。调用height(2)之后,又会先递归调用height(4)。
height(4)的左右孩子都是空,left_h = 0,right_h = 0,此时用0 + 0更新diameter,仍然是 0,然后返回max(0, 0) + 1 = 1。
height(5)同理,返回 1。
回到height(2),拿到了left_h = height(4) = 1,right_h = height(5) = 1。此时用1 + 1 = 2更新diameter,diameter从 0 变成 2,然后返回max(1, 1) + 1 = 2。这里的 2 表示以节点 2 为根的子树高度,路径是 2 -> 4 或 2 -> 5,长度 1,但节点 2 作为最高点时直径是 2。
再调用height(3),返回 1。
回到height(1),拿到left_h = height(2) = 2,right_h = height(3) = 1。用2 + 1 = 3更新diameter,diameter从 2 变成 3,最后返回max(2, 1) + 1 = 3。
最终答案是 3。
你观察一下:节点 2 那里已经更新过一次diameter = 2,但节点 1 又把它刷新成了 3。这正说明“直径”是在遍历每个节点时不断取最大值得到的,而不是递归函数返回的那个值。
3.3 反例树中每个节点做了什么
再看我之前提到的反例树:
1 / \ 2 3 / \ 4 5 / \ \ 6 7 8 / \ \ 9 10 11我把递归过程中每个节点的“返回值”和“更新后的 diameter 值”列成一张表,这样最直观。注意顺序是后序遍历,也就是先处理左子树,再处理右子树,最后处理当前节点。
| 处理节点 | 返回值 | 此处更新后的 diameter |
|---|---|---|
| 9 | 1 | 0 |
| 10 | 1 | 0 |
| 6 | max(1, 1) + 1 = 2 | 0 |
| 7 | 1 | 0 |
| 4 | max(2, 1) + 1 = 3 | max(0, 2 + 1) = 3 |
| 11 | 1 | 3 |
| 8 | max(1, 0) + 1 = 2 | 3 |
| 5 | max(2, 0) + 1 = 3 | 3 |
| 2 | max(3, 3) + 1 = 4 | max(3, 3 + 3) = 6 |
| 3 | 1 | 6 |
| 1 | max(4, 1) + 1 = 5 | max(6, 4 + 1) = 6 |
关键在节点 2。它的左子树高度是 3,右子树高度也是 3,两者相加等于 6,把diameter更新成了 6。之后根节点 1 虽然也算出了 4 + 1 = 5,但没有超过 6,所以最终答案保持 6。
这张表还能看出一个规律:越靠近叶子,返回值越小,但diameter的最大值往往出现在某个子树内部节点上。所以千万不要只盯着根节点。
时间复杂度和空间复杂度也需要说清楚:每个节点都会被访问一次,时间复杂度是 O(n)。递归过程中调用栈深度等于树的高度,在最坏情况下,二叉树退化成一条链,树高等于节点数 n,空间复杂度是 O(n)。
4. 一写就报运行时错误?这些坑我替你踩完了
4.1 空指针访问:最常见的运行时错误
很多人刷树相关题目时,最常撞见的就是 Runtime Error,而这类错误九成以上都跟空指针有关。Java 里会抛出 NullPointerException,Python 里是 AttributeError,C++ 里就是直接段错误。
我见过最经典的错误写法是递归函数缺少终止条件:
def height(node): left_h = height(node.left) right_h = height(node.right) return max(left_h, right_h) + 1当node为空时,node.left会直接抛错。这本质上是没有处理递归边界,也就是if node is None: return 0这一行。很多初学者写递归时,心里想着“空树高度为 0”,但手一快就漏了判断。
有另一种情况稍微隐蔽些:有些人确实写了if node is None: return,但漏写了后面的0。在 Python 中return和return None等价,后续做加法就会出现 TypeError,报错信息会是unsupported operand type(s) for +: 'int' and 'NoneType'。这种报错看起来不像空指针,但根因仍然是递归边界条件不完整。
正确做法是:在递归函数一开头就先处理None,再访问任何字段。把这个习惯养成之后,树相关的空指针问题能少一半。
4.2 递归深度与栈溢出:遇到链状二叉树怎么办
再一个容易被忽略的运行时错误是递归深度过深。如果二叉树是下面这种形状:
1 \ 2 \ 3 \ 4那么递归会沿着右孩子一路往下,深度就是节点总数。假如这棵树有 10 万个节点,Python 的默认递归深度上限是 1000,直接 RecursionError。Java 和 C++ 虽然没这么早报错,但如果树的规模再大一些,同样逃不掉栈溢出。
遇到这种情况,第一反应不应该是盲目把递归改成迭代,而是先确认数据规模。力扣上的 543 常规用例不会深到把递归栈打爆,所以正常提交没有问题。但如果面试官问“这棵树有 100 万层怎么办”,改迭代才是正解。
改迭代可以用显式栈模拟后序遍历。基本思路是维护栈里的每个节点状态,先按“当前节点、左孩子、右孩子”入栈,再在出栈时计算左右子树高度。不过这道题用递归本身就是最优雅的解法,面试中先说明递归在普通情况下的可行性,再说清楚极端情况下的替代方案,就已经很完整了。
还有一个实用技巧:在本地 Python 环境测试深树时,可以临时加一行:
import sys sys.setrecursionlimit(1000000)这样能缓解递归深度限制。但它不是银弹,递归深度真的超过某个量级时,限制再多内存也扛不住,那时必须想迭代方案。
4.3 全局变量残留:答案为什么会越来越大
第三种坑来自我自己的血泪教训:在本地一次跑多个测试用例时,diameter这个全局变量没有重置,导致后面的测试用例答案越来越大。
比如 C++ 里如果你把diameter设成类成员变量:
class Solution { private: int diameter = 0; int height(TreeNode* node) { ... } public: int diameterOfBinaryTree(TreeNode* root) { height(root); return diameter; } };看起来diameter初始化为 0,好像没问题。但力扣在多次调用时,往往会在同一个 Solution 实例上跑多个测试用例。如果diameterOfBinaryTree每次被调用时没有把diameter重新置为 0,第二次的答案就会在第一次的基础上累加。
解决办法很简单:在入口函数第一行写diameter = 0;,再调用height(root)。Python 里的self.diameter = 0同理。凡是面试中被要求手写多组测试用例,全局变量的重置都应该养成条件反射。
4.4 边数还是节点数:一个容易导致答案错误的细节
还有一个不报运行时错误、但会让答案错误的细节:直径到底是边数还是节点数。
力扣 543 明确要求返回边的数量,所以空树和单节点树答案都是 0。但有些变种题或面试官追问会改成“最长路径经过了多少个节点”。如果是那样,答案就是边数加 1。比如官方例子中边数是 3,节点数是 4。
这个细节很刁钻。我就见过有人把diameter初始化为 1,然后在空树时返回 1,导致空树用例直接错误。还有人在更新答案时写left_h + right_h + 1,把当前节点自己也算了进去,这在“节点数”版本的题里是正确的,但在 543 里就多算了 1。
我的建议是:做题前先确认题目问的是边数还是节点数,把初始化的值写成与之匹配的形式。543 的正确答案是边数,diameter初始化为 0,更新用left_h + right_h,不要额外加 1。
5. 从二叉树直径延伸:深度、遍历和更广义的图直径
5.1 二叉树的深度,其实就是直径的半成品
如果你做过“二叉树的深度”这道题,你会发现它和 543 的代码长得很像。求深度只需要返回max(left_h, right_h) + 1,而求直径只需要在返回之前额外用left_h + right_h更新答案。
可以把 543 理解成“二叉树深度”的加强版:深度只关心从根到最远叶子的距离,直径关心任意两个节点之间的距离。深度是递归函数直接返回的值,直径是递归过程中顺手收集的全局最大值。
很多人在初学树形递归时会混淆“返回值”和“答案”。返回值是给父节点用的,答案是自己维护的。在求深度时,答案恰好就是返回值,混淆起来没感觉;到了求直径时,两者分道扬镳,不提前想清楚的人就会卡住。
5.2 后序遍历为什么是唯一合理的选择
二叉树的遍历方式有前序、中序、后序、层序,543 为什么偏偏用后序?
因为后序遍历的处理顺序是:左子树 -> 右子树 -> 当前节点。计算当前节点作为“转折点”的路径长度时,需要左右子树都已经处理完,并且高度已经算好。这不就是先左后右再当前的天然顺序吗?
前序遍历是先处理当前节点,再递归子树。在访问当前节点时,左右子树高度还是未知数,你拿不到left_h和right_h。中序同理,只能拿到左子树结果,右子树还没处理。层序遍历更不需要提,它天生适合按层处理,不适合这种自底向上的依赖。
所以结论是:看到了“自底向上”的依赖关系,就直接锁定后序。这个判断可以推广到很多树形 DP 题目:只要父节点的答案依赖子节点的答案,后序遍历都是默认选项。
顺便提一句,热搜词里的“线索二叉树”是另一类问题,它解决的是遍历过程中如何高效利用空闲指针,和本题的自底向上递归没有直接关系。求直径的递归实现并不需要线索化的辅助,不用把它和遍历优化混在一起。
5.3 再说说图论里的直径
树是一种特殊的图,无环且连通。二叉树的直径本质上是“树上最远两点距离”。如果推广到更一般的图,问题就变成“图的所有节点对之间最短路径的最大值”,也就是图的直径。
对于无环的树,可以用更简单的两次搜索法:从任意节点出发,找到距离它最远的节点 A;再从 A 出发,找到距离 A 最远的节点 B;A 到 B 的距离就是树的直径。这个方法成立的前提是树中任意两点的路径唯一,你可以用反证法证明它。
但对于带环的普通图,这个结论就不成立了。一般图里的直径需要算出所有节点对之间的最短路径,再取最大值。常用做法是 Floyd-Warshall 算法,复杂度 O(V^3);如果是边权非负的稀疏图,可以用多次 Dijkstra 来算。这里的 V 是节点数,所以一般图的直径在数据规模大时会非常昂贵。
回到 543,这道题用树形 DP 一次递归就能完成,本质上就是因为树的结构足够简单:每个节点只有一个父节点,没有环,路径不可能绕来绕去。理解了这个边界,你再看“图论中图的直径怎么算”这类问题时,就知道核心区别在哪里了。
我个人做了几遍 543 之后的体会是:树形 DP 的突破口往往不在递归函数本身,而在你决定“函数该返回什么、答案该存在哪”这两件事上。如果下次再遇到类似的“最长路径”“最大距离”问题,先想清楚路径是否一定经过当前节点,再想清楚返回值是高度还是路径长度,基本就不会跑偏。另外,本地调试时如果发现答案莫名其妙很大,先怀疑全局变量没有重置,这个毛病我犯过不止一次。