☰
二叉树直径全攻略:路径输出、负权、多叉树与动态维护
2026/10/7 12:09:45 网站建设 项目流程

上一篇写完二叉树直径的常规解法之后,后台和评论区收到不少留言,问得最集中的几类包括:能算出长度,但面试官追问“具体是哪条路径”就卡壳;树里带负数权重时公式还能不能套;以及“又是空指针又是栈溢出,到底哪里写错了”。这些问题其实指向同一件事——教科书上那个O(n)的经典解只是起点,真正到了面试或者工程现场,考验的是你对着各种变体和边界的应变能力。

这篇就把直径问题周边的坑一次讲透:先补上“输出完整路径”的两种写法,再说负权值下公式怎么改,然后把问题推广到多叉树和多次查询场景,最后聊聊初学者最常见的运行时错误,以及线索二叉树这个容易被忽略的考点。读的时候建议打开编辑器跟着敲,只看不写记不住。

1. 从“最长路径长度”到“完整路径”:两步走是面试标配

1.1 经典长度解法回顾:它到底丢掉了什么

先复习一下上一篇的经典解法,后面所有变体都从这段代码出发:

def diameter_of_binary_tree(root): ans = 0 def dfs(node): nonlocal ans if not node: return 0 left = dfs(node.left) right = dfs(node.right) ans = max(ans, left + right) return max(left, right) + 1 dfs(root) return ans

逻辑本身很干净:每个节点返回自己的高度,同时把“左子树高度 + 右子树高度”作为“经过该节点的最长路径”去更新全局答案。整个过程只产生一个整数,所以代码非常短。但代价是——你只知道最长路径有多长,完全不知道它长在哪。

工程里这种信息往往很要命。我之前在一个内部工具里做过一次目录树分析,需要定位“哪两个最深层目录之间的文件链路最长”,最后不只是要数字,还要把路径打印出来让人去排查。面试时更是这样:如果你能把长度算出来,却写不出路径复原的代码,面试官基本会认为你是背的模板。

1.2 方法一:拐点记录法,在递归里多存一个方向

最直接的补充思路:既然直径一定会以某个节点为“拐点”,路径就是它左边一条最长链加上右边一条最长链,那我只要记住每个节点的高度来自哪个孩子,然后在全局答案最大的那个节点上顺着方向往下走到叶子即可。

实现分两步:

第一步,DFS时同时记录决策:

def find_diameter_path(root): height = {} # 节点 -> 高度 direction = {} # 节点 -> 高度来自哪个孩子: 'L' / 'R' / None global_best = [0, None] # (最大直径长度, 拐点节点) def dfs(node): if not node: return 0 left = dfs(node.left) right = dfs(node.right) if left >= right: height[node] = left + 1 direction[node] = 'L' if left > 0 else None else: height[node] = right + 1 direction[node] = 'R' if right > 0 else None cur = left + right if cur > global_best[0]: global_best[0] = cur global_best[1] = node return height[node] dfs(root) # 第二步:从拐点向左/右分别找最远叶子 def walk(node, side): path = [] while node: path.append(node.val) d = direction.get(node) if d == side: node = node.left if side == 'L' else node.right else: break return path pivot = global_best[1] left_path = walk(pivot, 'L') right_path = walk(pivot, 'R') return global_best[0], left_path[::-1] + right_path

注意一个边界:如果拐点某一侧为空(高度为0),direction被记成None,walk循环直接退出,最终路径就只是另一侧的一条链。这个写法单树遍历两遍,时间O(n),空间O(n),面试时够用了。缺点是引入了两个字典,代码显得“重”。

1.3 方法二:两次遍历加父指针,最通用也最好讲

如果目标不只是二叉树,你甚至不需要“拐点”这种二叉树概念。树上有一个经典结论:对一棵边权非负的树,从任意节点出发,走到离它最远的节点U;再从U出发走到最远的节点V,U到V的路径就是一条直径。

这个性质成立的核心原因是:树上任意最远点必然落在某条直径的端点处。你可以这样直观理解:如果从P出发的最远点X不在直径端点,那么X与直径两端点的距离矛盾会迫使X“代替”其中一个端点成为更远的点,最终最远点一定长在直径端点上。

所以实现更简单,适合边权非负的普通树:

def farthest_node_and_path(root): parent = {root: None} def dfs_to_far(node, fa, dist): parent[node] = fa far, far_dist = node, dist for child, w in [(node.left, 1), (node.right, 1)]: if child and child != fa: cand, cand_dist = dfs_to_far(child, node, dist + w) if cand_dist > far_dist: far, far_dist = cand, cand_dist return far, far_dist u, _ = dfs_to_far(root, None, 0) v, _ = dfs_to_far(u, None, 0) path = [] cur = v while cur is not None: path.append(cur.val) cur = parent[cur] return len(path) - 1, path[::-1]

这份代码里的parent只在第二次遍历时有效,因为第一次是从root搜的,第二次才是从u搜,第二次的路径才对应真正的直径端点链。实际写的时候要小心:不要在第二次复用第一次的parent字典,否则回溯链是反的。我第一次实现就踩过这个坑,打印出来路径变成了从root到v,再拼一段v到u,明显发生了回溯断链。

对比一下两种方法:拐点记录法更贴合二叉树的递归结构,负权场景也能本地改造;两次遍历法更通用,适合推广到多叉树和带权树,但不能在有负权边的时候直接用。面试先问清楚题目约定,再决定上哪种。

2. 权值出现负数:直径变体题的“最大路径和”陷阱

2.1 负权为什么会直接废掉经典公式

大树底下的经典公式ans = max(ans, left + right)能成立,前提是这两条子树高度链的价值只随长度增长。一旦边权或点权出现负数,“两条链相加”这个动作本身就可能是亏本的——你完全可能宁可只走一条很短但全正的链,也不愿意凑上一条负得离谱的侧链。

面试题里最典型的就是LeetCode 124“二叉树中的最大路径和”。注意它的价值定义在节点上,节点值可以是负数,路径可以只含一个节点。这跟“直径”的区别可以概括成一句话:直径求的是长度最长,路径和求的是数值最大。同样一棵树,这两个问题的答案甚至不在同一个地方。

2.2 正确公式:把负贡献截断为0

核心变化在于,递归返回的不再是“高度”,而是“从当前节点向下能获得的最大贡献值”,而且这个贡献值允许被截断:

def max_path_sum(root): max_sum = float('-inf') def gain(node): nonlocal max_sum if not node: return 0 left_gain = max(gain(node.left), 0) right_gain = max(gain(node.right), 0) # 经过当前节点的最大路径和 cur = node.val + left_gain + right_gain max_sum = max(max_sum, cur) # 作为父节点的一条支链,只能挑一边 return node.val + max(left_gain, right_gain) gain(root) return max_sum

关键就是那两次max(gain(...), 0)。向左或右延伸时,如果子树贡献是负数,就不如不要,相当于那条链被截断为0。这也是和经典直径最大的公式差异所在。

2.3 和经典直径的全面对比

维度经典二叉树直径二叉树最大路径和
价值定义边数 / 路径长度节点数值之和
权值约束默认无负权节点值可为负
递归返回值子树高度单侧最大贡献(可截断为0)
全局更新式left + rightnode.val + left_gain + right_gain
路径能否为空不能,至少两端点为叶子或两个节点可以一个点,因为负贡献可剪掉

如果题目换成了“边权可为负的直径”,处理逻辑类似:递归返回“从当前节点出发能获得的最大加和路径”,更新答案时用两条子链加上当前边权,但每侧子链的返回值先和0比较再使用。这样改完,代码框架几乎不变,变的只是对“负数”的态度。

这个题目我建议所有准备算法面试的人都手写一遍,因为它是测试“你到底是理解了递归,还是记住了模板”的最好题目之一。能把max(gain(child), 0)这一步解释清楚,面试官基本不会再怀疑你是背的。

3. 从二叉树推广到多叉树:top2不是唯一难点

3.1 多叉树里,公式要从“左右”改成“最大和次大”

二叉树里的每个节点最多有两个孩子,所以“经过当前节点的最长路径”就是left_height + right_height。多叉树里孩子数量不定,这个式子要改成:取所有子树高度中的最大值和次大值,两者相加。

为什么一定是最大和次大?因为一条路径拐在这个节点时,能且只能从两个不同的子树各带一条链上来。你如果想带三条链,那路径就出现三叉分岔,不是一条路径了。这是理解这一步的关键,好多初学者在这个地方绕不出来。

3.2 通用树形DP的迭代写法

def diameter_of_tree(root): ans = 0 def dfs(node): nonlocal ans best1 = best2 = 0 for child in node.children: h = dfs(child) if h > best1: best1, best2 = h, best1 elif h > best2: best2 = h ans = max(ans, best1 + best2) return best1 + 1 dfs(root) return ans

顺着代码走一遍:每个子树的内部直径已经在它自己的递归帧里更新过ans,所以每个节点只需要关注“经过自己”的路径。最终任何一个内部直径都会被它对应的拐点节点覆盖到,不需要额外传参。

3.3 工程场景:目录树和层级结构分析

这种多叉树直径不是纯刷题,日常生活中其实很常见。比如分析一个文件目录树,想知道“哪两个外层目录之间的层级跨度最大”。每个目录可以有很多子目录,问题本质就是多叉树直径。

另一个我会主动提的场景是权限系统的组织架构。每个部门下面挂着若干子部门,部门之间最长的汇报链就是树的直径;当你需要评估一次全员消息广播从根节点发出去,多久能覆盖最远的下属层级,算的就是这棵树的“最大深度”,而跨部门最长链路则是直径。这些场景的共同点是:树结构是天然存在的,问题转化也不复杂,但如果你只背过二叉树的解,遇到多叉树时当场改写top2这段逻辑,容易在边界和返回值上卡壳。

4. 多次查询与动态更新:直径问题不想每次O(n)遍历怎么办

4.1 静态树的多次距离查询:欧拉序+LCA

如果业务里需要反复询问“树上任意两节点距离多远”,每次都重新DFS整棵树,复杂度就是O(qn),规模一大立刻撑不住。标准做法是:预处理出每个节点的欧拉序和深度,再用RMQ(ST表)支持O(1)查询LCA。

# 预处理:欧拉序 + 深度 + ST表 first = {} euler = [] depth_list = [] # 对应欧拉序中每个节点的深度 def dfs(u, fa, dep): first[u] = len(euler) euler.append(u) depth_list.append(dep) for v in children[u]: if v != fa: dfs(v, u, dep + 1) euler.append(u) depth_list.append(dep) dfs(root, -1, 0) # 用ST表在euler的[l, r]区间里找深度最小节点,即为lca # 距离 = depth[u] + depth[v] - 2 * depth[lca]

距离公式本质是:u到根的距离加上v到根的距离,减掉两倍最近公共祖先到根的距离。这样每条查询就变成O(1),预处理复杂度O(n log n),空间O(n log n)。在大量“两点距离”类查询的评测和业务里这是标配。

4.2 动态加叶子时的在线直径维护

另一类常见变体是动态插入叶子。每次插入一个新节点x后,问题变成:当前的树直径是多少?

这时候有一个很有用的性质:两个集合合并后的直径端点,一定来自原两个集合各自直径的四个端点中。具体到往树上加一个叶子,新集合可以看成“旧树 ∪ {x}”,旧树的直径端点是d1、d2,那么新直径只需要比较:

  • 旧直径的长度
  • dist(x, d1)
  • dist(x, d2)

取最大即可。为什么是这三个候选?因为如果存在另一条更长的路径经过x,那它一定是从x出发到某个旧节点的路径,而这个旧节点要尽量远。旧树里离x最远的节点,必然出现在旧直径的某个端点上——这一步是直径端点合并性质在“一个点和一棵树合并”时的特例。

配合4.1的LCA距离查询,每次插入后的维护成本几乎就是O(1)次距离计算,总复杂度大幅下降。

4.3 代价与边界:动态维护的前提

这个方法的前提是“只往叶子加”。如果允许在树中间插节点,会把树结构破坏掉,直径端点的维护性质就不再成立。实际面试或设计中遇到动态树,先确认清楚插入形式再决定算法路线。我见过有人把动态加叶子直接推广到任意形态的树插入,结果答案次次错,检查半天才发现性质要求没满足。

5. “写二叉树程序时总是报运行时错误”:六个反复出现的坑

后台热搜里有个词条是“写二叉树程序时为什么总是报运行时错误”,这个问题太典型了,值得单独开一章。

5.1 空指针访问:99%的初学报错源头

没有做if not node: return 0判断,直接node.left裸奔,空节点一访问就崩。放在递归里的隐蔽之处在于:空指针往往不是第一次调用就出事,而是递归深入到某个叶子时才踩到。调试时不要只盯着报错行,往上看是不是漏了基线条件。

5.2 深度失控导致栈溢出

二叉树面试题几乎都是递归写,递归调用深度等于树高。正常平衡树深度log n,没事;但如果输入是一棵退化成链的树,深度就是n,几万层递归一来就栈溢出。

解决办法按场景选:

  • 面试或本地:确认树会不会退化。链表型数据在OJ里很常见。
  • 刷题平台:适当调大递归限制,比如Python设置sys.setrecursionlimit(1000000)。
  • 工程或重度递归场景:改写成显式栈的迭代后序遍历,空间可控。

迭代后序遍历配一个“后序栈”,其实就是在模拟递归,但栈是自己malloc的,可以做得比较大不至于爆。

5.3 全局变量与捕获方式的错位

在直径这类题里,全局答案通常在递归中不断更新。常见错误有两个:

Python里忘记写nonlocal ans,这样递归函数里对ans的赋值只会创建一个局部变量,外层答案永远是0。C++里lambda写[=]捕获却试图修改外部int,编译直接不过。调试技巧也很直接:在更新行打日志,看它到底有没有被走进来、值是多少。

5.4 高度定义不一致:边数还节点数

直径题有个经典歧义:叶子节点的高度到底是1还是0?LeetCode 543是边数路径,空节点return 0、叶子return 1,ans = max(ans, left + right)—— 注意这里left、right是子树高度,不是深度,所以不用加1。如果你把叶子return 0,那么最后答案会少2;反过来则多2。

每次拿到题目先确认单位再动手,免得整段重写。

5.5 多组数据全局变量忘记重置

OJ上多组测试共享同一个全局变量。直径题目特别容易中招:上一组算完ans留着旧值,下一组直接比较出错。解法是每组数据开始处重新赋0,或者干脆把ans包在递归函数外层作为局部变量。

5.6 点权题目误用边权模板

同样是“最长路径”,有的题是节点值累加,有的是边权累加。公式高度相似:点权的全局更新是left + right + node.val,边权是left + right。一混用,所有结果全错。这个坑我见过不下五次,每次都是代码看起来“没什么不对”,其实就是单位错了。

调试建议:拿到样例先手画树,把每个节点对应的递归返回值标出来,跟代码输出对一遍。手算和机器输出不一致时,单位问题立刻暴露。

6. 线索二叉树:让遍历像推货架一样往前走

6.1 线索二叉树省掉了什么

一般的递归遍历,要记录一个栈来保证回溯到父节点。线索二叉树的做法是在空指针里存“前驱/后继”信息,让遍历过程可以一路顺指针走完,不需要栈,也不需要系统递归栈,空间O(1)。

中序线索化里,每个节点的left/right空指针分别指向中序前驱/后继,同时用ltag/rtag标记它到底是指向孩子还是线索。遍历时你从最左节点开始,不断通过右线索或“右子树的最左节点”找后继,就像在超市货架上推着车从一头走到另一头——所有节点被线性排成一条货架路线,顺着走就行。

6.2 线索化能不能直接算直径?别急着跳转

你可能会想:既然遍历能O(1)空间完成,直径是不是也能用线索树省空间?答案是否定的。

直径计算真正的瓶颈不在“能不能遍历所有节点”,而在于每个节点都需要拿到左右子树的统计值。线索化优化的是遍历顺序的存储开销,它并不会额外记录子树高度,也不会给你从后继跳回父节点的能力。所以如果题目考察的是直径,线索化帮不上核心忙;如果考察的是“线性遍历”,线索化才是主角。

判断依据很简单:看看题目是要求“访问节点”,还是要求“聚合子树信息”。前者线索二叉树有价值,后者老老实实走后序遍历或树形DP。

6.3 线索二叉树的实际适用场景

现实里和货架线性遍历最像的需求,是把一棵树状分类体系转成一条物理顺序去处理。举个具体例子:仓库货架分类是树形的——“食品”下面分“零食”“饮料”,“零食”下面再分“膨化”“糖果”。系统要生成一张进货清单,按顺序把每个叶子分类扫一遍,而且要尽量少走冤枉路。把树中序线索化之后,每个分类的后继都提前存好了,程序按指针一路走下去,不用反复回查父级分类。

这类需求用普通递归遍历也写得出来,但线索树把整个“线性遍历”变成了纯指针操作,在嵌入式设备、递归栈受限的环境里有实打实的优势。面试问到线索二叉树时,能说出“它省的是空间,不是时间”这句话,通常就能得分。

我个人带新人时总强调一件事:直径题的核心不在于背下那几行经典代码,而在于你能对着任意变体快速回答“公式为什么不成立了”。从找路径的两种手法,到负权截断,再到多叉树和动态维护,最后到线索遍历的边界,这些内容串起来就是一张完整的二叉树算法地图。刷题时不妨把我上面这些变体每道都手写一遍,然后自己把树的形态改成退化链、全负权、多叉树再跑一遍;跑通之后,你再面对 관련 题目会踏实很多。

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

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

立即咨询