图 1:最长通路的最高点不一定是根,这决定了递归该怎么设计。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。「求二叉树的最大通路长度」——也叫二叉树的直径——是树形 DP 的入门招牌题。题目一句话:任意两个节点之间的最长路径有多少条边。很多人张口就是「左子树高度加右子树高度」,这个答法在根节点上碰巧经常对,但作为通用解法它是错的。
先拆掉那个直觉答法。
「根的左高加右高」隐含了一个假设:最长通路一定穿过根节点。构造一个反例很容易——让根的左子树又深又宽,右子树只有一个叶子。比如左子树里藏着一条 6 条边的通路,它的最高点是根的左孩子;而经过根的路径因为要分一条边给右边的独苗叶子,最多只有 5 条边。只在根上算一次,答案就漏了。
正确的观察是:最长通路一定存在某个「最高点」,路径从这个点左臂下来、右臂上去(允许某条臂长度为 0)。最高点可以是树上任意节点,所以——每个节点都要当一次候选最高点。
递归设计的关键:返回值和全局答案各管一件事。
这是本题真正的考点,很多人把两件事揉在一起就乱了。把它拆干净:
递归函数返回什么?返回「以本节点为端点、只往一边走的最长单边」,也就是高度 h = 1 + max(左子树高, 右子树高)。为什么只能报单边?因为父节点要用这个值接着往上拼路径,而路径穿过父节点时只能从一侧上来,两边都占就不是一条简单路径了。返回值是为父节点服务的。
全局答案在哪更新?在每个节点内部,顺手算一个候选直径 = 左臂 + 右臂,拿它刷新一个全局最大值。左臂加右臂描述的是「以本节点为最高点」的完整通路,这个值不上报、只用来竞争答案。每个节点都算一遍候选,最高点是谁都漏不掉。
一句话总结这个范式:返回值服务父节点,全局量在本节点侧更新。
代码实测。
用 Node.js 22.15.0 运行(node diameter.js),构造的正是上面那棵反例树:
class Node {
constructor(v, l = null, r = null) { this.v = v; this.l = l; this.r = r; }
}
let best = 0;
function height(node) {
if (!node) return 0;
const lh = height(node.l), rh = height(node.r);
best = Math.max(best, lh + rh); // 候选直径在本节点侧更新
return 1 + Math.max(lh, rh); // 上报给父节点的只是单边高度
}
// A
// B I
// C D
// E F
// G H
const root = new Node('A',
new Node('B',
new Node('C', new Node('E', new Node('G'))),
new Node('D', null, new Node('F', null, new Node('H')))),
new Node('I'));
height(root);
console.log('最大通路长度(边数)=', best);
输出:
最大通路长度(边数)= 6
手工数一遍:G—E—C—B—D—F—H 恰好 6 条边,最高点是 B 而不是根 A;如果在根处算,左高 4 加右高 1 只有 5,漏掉正解。代码与推演一致。
复杂度也顺手交代:每个节点只做一次 O(1) 的取 max 和比较,整棵树一次后序遍历,总时间 O(N),递归栈深度等于树高。如果面试官追问非递归写法,用栈模拟后序遍历即可,候选更新的时机不变。
还有一个表述细节值得提前对齐:通路长度按边数还是节点数算,两种口径差 1,答题前先说清自己用哪种,避免和面试官的参考答案对不上。
图 2:每个节点既向上报高度,又在本地更新全局候选,两条线互不干扰。
这个范式能迁移到一大片题。
「返回值服务父节点、全局量就地更新」是所有树形 DP 的通用骨架:求最大路径和(节点带权值)只是把左臂右臂换成带权路径和,还要处理负值时宁可不要这条臂;求二叉树最大坡度、最长同值路径,改的也只是候选公式。答题时把这个骨架亮出来,再针对题面套候选公式,面试官就知道你不是背的。
面试现场的推荐节奏:先复述题面确认「通路长度按边数算、允许不过根」,然后主动给出反例说明为什么不能只在根算,再讲返回值与全局量分工,最后写代码并交代 O(N) 复杂度。你做这道题时有没有掉进过「根节点陷阱」?或者被追问过「节点带负权值怎么办」?评论区交流一下。