
1. 面试题的原始形态直径问题到底在问什么先还原一下题目。LeetCode 543那道经典题是这么描述的给你一棵二叉树的根节点返回这棵树的直径。这里的直径定义是任意两个节点之间路径上的边数最大值。很多人第一次看到这句话会有两个直觉误区第一觉得直径应该跟经过根节点的最长路径有关第二觉得既然叫直径那就应该是对称的、顺着树的最左和最右叶子走的路径。两个都不对或者说只是特例不是一般情况。我举个例子你就明白了。考虑一棵形状不规则的树根节点的左子树很深有 5 层右子树只有一个节点。那么最长的路径大概率是从左子树最深的叶子出发向上走到根再向下走一步到右孩子这条路径的边数大约是 5 1 6。但是如果左子树内部本身就有一条更长的折线路径——比如左子树的某个节点它的左右两侧各挂了很深的链——那么真正的最长路径可能根本不需要经过整棵树的根节点。它可能在左子树内部就完成了从某个叶子出发向上走到某个中间节点再向下走到另一侧叶子的全程。这才是直径问题的关键直径本质上是经过某个转折节点的最长路径而我们要在所有节点里挑那个转折点。路径本身是先向上、后向下的但在递归视角里我们可以把它理解为对于任意节点以它为最高点转折点的路径长度 它左子树的高度 它右子树的高度。全树的直径就是所有节点这个值里最大的那一个。注意题目原文问的是边数。如果用节点数来衡量直径答案就是边数加一很多国内教材和面试手写题用节点数定义这会在代码边界条件上造成差异后面专门讲。先记住一个统一口径本文默认直径 最长路径的边数这是 LeetCode 和大部分线上评测系统的口径。一这个系列名是我自己起的因为直径问题深挖下去可以展开的东西非常多从朴素递归到记忆化、到后序遍历一次搞定、再到带权直径、N 叉树直径、输出具体路径、跟树形 DP 的关联。一篇文章塞不下我打算分几篇逐步拆。这篇先解决最核心的部分定义、朴素思路、最优解法、边界条件以及为什么很多人在写二叉树程序时会莫名报运行时错误——这点跟直径问题关系极深。2. 为什么每个节点的左右子树高度之和取最大值就是答案2.1 先从路径的几何形态说起任意一条路径在二叉树这个结构里你从起点走到终点途经的节点序列会有一个唯一的最高点。什么叫最高点就是在这条路径上离整棵树的根节点最近的那个节点。因为二叉树每个节点只有一个父节点所以任何一条连接两个节点的路径必然先从一个端点向上走到某个共同祖先再向下走到另一个端点。这个共同祖先就是最高点。在递归处理时它可以是任意一个节点不一定是最初的根节点。举一个具体的例子。有一颗树节点分布如下根节点 R左孩子 A右孩子 BA 有左孩子 CC 又有一个很深的左链 C1 - C2 - C3A 的右孩子 DD 的右孩子 EB 是一个叶子现在从 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 的最大值。这个代码还有一个小细节值得注意如果根节点为 Noneheight 直接返回 0self.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单节点树的直径是 0left 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 4right 0diameter 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取 leftright 最大值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 叉树直径只是把左右子树的二元逻辑推广到多个子节点取最大的两个子树值相加。这些内容篇幅不小正好留给下一篇。如果你在本篇的边界条件和运行时错误排查里把自己的代码调通那么下一篇讲变体时你会轻松很多。我一直认为树的问题最好最笨重地画图、最小规模实例、最极端退化形状跑一遍比记一百个模板都管用。直径问题就是验证这套方法论的好靶子。