☰
LeetCode 124 二叉树最大路径和:递归贡献值与后序遍历详解
2026/10/6 9:39:21 网站建设 项目流程

我把这道题放在刷题计划第168天来做,说实话第一眼看到"最大路径和"四个字,我还以为又是求根节点到叶子节点的最大值。真正动手写的时候才发现,这题的"路径"定义要比我想的宽得多——它允许你从任意节点出发,沿着父子连接走到任意节点结束,路径可以斜着穿过某个节点,甚至可以完全不过根。这一下就把难度拉上来了,因为你要处理的不是一个方向的递归结果,而是"节点左右两边到底能贡献多少"的问题。

做LeetCode-124这题,最核心的知识点就是递归,但和单纯的"遍历二叉树"不一样,这里的递归返回值得经过精心设计:它既要服务于父节点的计算,又要参与全局最大值的更新。很多人卡在这道题上,不是不会写递归,而是搞不清"递归函数返回什么"和"全局答案怎么更新"这两件事的区别。这篇文章我会把自己当时的思考过程、最后落地的代码、以及调试时踩过的坑完整写下来,希望能帮到正在刷二叉树系列的朋友。

1. 这道题真正的难点:最大路径和是个"横跨式"结果

1.1 先看题目到底在求什么

题目给了一棵二叉树,每个节点上有一个整数值,可能是正数也可能是负数。你要找出一条路径,使得路径上所有节点值之和最大。这里的路径定义有几个关键点:

  • 路径可以从任意节点出发,到任意节点结束。
  • 路径必须沿着父子之间的连接走,不能跳。
  • 一个节点在路径中只能出现一次。
  • 路径至少包含一个节点。

举个例子,如果树是[1,2,3],也就是根节点为1,左孩子2,右孩子3,那最大路径和就是2 + 1 + 3 = 6。注意这里路径经过了根节点,连接了左右两个孩子。

再看一个更典型的例子,树是[-10,9,20,null,null,15,7],也就是根节点是-10,左孩子9,右孩子20,20的左孩子15,右孩子7。肉眼扫一遍,最值钱的路径是15 -> 20 -> 7,这条路径根本不经过根节点-10,总和是42。如果你脑子里只有"从根出发的路径",那这道题就直接做错了。

所以这个题的本质是:在任何一棵子树里,路径可能穿过某个节点,把它的左子树贡献、自身值、右子树贡献拼在一起;也可能只在某一侧向下延伸。我们要找的就是所有可能的"穿过节点而形成的路径"中的最大值。

1.2 为什么"左右子树最大路径"这个直觉会失败

我看到不少题解在讲这道题的时候会说"求左子树的最大路径和,求右子树的最大路径和"。这句话很有误导性。我第一版代码就是按这个思路写的,结果样例都过不了。

原因在于:父节点在利用子节点信息时,只允许从子节点那里"借"一条单边的路径。什么叫单边?就是从某个子树的根节点出发,只往左走或者只往右走,一路向下延伸,不回头。为什么必须是单边?因为父节点要把这条路径和自己拼起来,构成一条完整的路径时,子树的路径必须有一个端点能继续连到父节点上。如果子节点返回的是"穿过它的最大路径和",那条路径可能已经横跨了它的左右子树,父节点再往上一接,路径就分叉了,这在二叉树里是不合法的。

举一个很实在的反例。假设一棵树,根节点是1,左孩子是-2,右孩子是-3,左孩子的左右孩子都是10。那么左子树内部的最大路径是10 -> -2 -> 10 = 18,这个值确实很大。但父节点1需要的是从左孩子往下走的一条"通路",它只能选择10 -> -2或者-2 -> 10,也就是从-2出发往某一侧延伸的单边路径,值是10 + (-2) = 8或8。父节点如果把18直接拿过来拼接,路径就变成了10 -> -2 -> 10 -> 1,这显然不合法。

所以,这道题的第一步突破,就是认识到递归函数返回的应该是一个"单边最大贡献值",而不是"子树内部最大路径和"。这两个概念,是彻底理解本题的分水岭。

2. 把递归函数拆成两个使命:一个向上汇报,一个全局记牌

2.1 一个节点在递归里扮演的两个角色

如果你仔细观察上面说的"谬误",会发现每个节点其实同时承担了两个任务:

  • 任务A:作为"路径终点链"的某个中间段,把从自己向下延伸到某一侧的"最大贡献值"返回给父节点,帮助父节点拼出更长的路径。
  • 任务B:作为"路径最高点",把经过自己、连接左右两边的最优路径和计算出来,去更新全局答案。

任务A对应的就是递归函数的返回值,任务B对应的则是我们在递归过程中维护的一个全局变量。很多人写不出这题,就是因为没有在代码层面把这两个任务分开。一个返回,一个记录,两者各司其职,代码就会非常清晰。

用生活化的类比来说:每个节点就像一家加盟商,它既要向总部(父节点)汇报自己这一侧"单线生意"最多能赚多少,又在本地偷偷算一笔"如果我把左右两家店连接起来,一次性生意最多能赚多少",后者直接上报给总部不代表加盟商自己能不能干,而是让总部知道历史最高纪录。

2.2 贡献值公式的推导过程

先说"贡献值"(contribution)这个概念。对于任意一个节点node,它的单边贡献值定义为:从node出发,沿着左孩子或右孩子方向一路向下行走,能获得的最大路径和(包含node自身)。

这个定义要求只能选一条边往下走,不能左右都选。它对应的递归公式是:

singleGain(node) = node.val + max(singleGain(node.left), singleGain(node.right))

但有一个很关键的细节:如果某个孩子的贡献值是负数,那还不如不选它。因为负数只会让总路径和变小,而路径本身允许从任意节点开始,没必要拖着一个负数的尾巴。所以公式要修正为:

singleGain(node) = node.val + max(max(singleGain(node.left), 0), max(singleGain(node.right), 0))

换句话说,每个分支在参与"贡献"之前,先过一个0的门槛。这个max(..., 0)的写法,正是这道题优美的地方之一:它把"可选"这个语义直接写进了公式里。

而任务B的全局更新公式是:

maxPathSum(node) = max(maxPathSum(node.left), maxPathSum(node.right), node.val + max(singleGain(node.left), 0) + max(singleGain(node.right), 0))

也就是说,经过当前节点、并把它作为最高点的完整路径,等于"左边的正向贡献 + 自身值 + 右边的正向贡献"。因为当前节点是最高点,所以左右两边都可以接上,不需要考虑向上延伸的问题。这个值,只用来更新全局答案,不返回给父节点。

说到这里,核心思路已经完整了:递归函数向下走得是单边贡献,向上汇报的也是单边贡献;全局答案则是拿"左右贡献都选上"的结果去碰运气。理解了这句话,这题就算真正掌握了。

2.3 后序遍历顺序:先算孩子,再算自己

整个递归过程天然是后序遍历,因为你要先拿到左右孩子的贡献值,才能计算当前节点的贡献值和路径和。这也是二叉树递归题里最常见的一种依赖关系。很多初学者会试图用前序遍历来写这道题,结果发现左右孩子的值还没算出来,根本没法计算当前节点的贡献,代码直接卡壳。记住:凡是节点值需要聚合孩子信息的题目,几乎都是后序遍历。这一点在"二叉树的最大路径和""二叉树的最大深度""二叉树直径"等题目里都是通用的。

后序遍历还有一个隐藏的好处:你可以保证每个节点只被访问一次。因为路径最多穿过每个节点一次,整个算法的时间复杂度就是O(N),其中N是节点总数。对于树结构问题,这个复杂度已经是最好的情况了,不需要额外的优化。

3. 完整代码实现与一次递归过程的逐步推演

3.1 Java实现:全局变量加递归函数

下面是我最终提交的Java版本,代码结构上没有多余的修饰,每一行都有明确的分工:

/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { // 全局答案,初始化为最小整数,因为节点值可能是负数 private int maxSum = Integer.MIN_VALUE; public int maxPathSum(TreeNode root) { maxGain(root); return maxSum; } // 返回从当前节点出发,向某一侧延伸所能获得的最大贡献值 private int maxGain(TreeNode node) { if (node == null) { return 0; } // 后序遍历:先算左右孩子 int leftGain = Math.max(maxGain(node.left), 0); int rightGain = Math.max(maxGain(node.right), 0); // 任务B:经过当前节点的完整路径和,用它更新全局最大值 int currentPathSum = node.val + leftGain + rightGain; maxSum = Math.max(maxSum, currentPathSum); // 任务A:返回单边最大贡献值给父节点 return node.val + Math.max(leftGain, rightGain); } }

这段代码有几个值得注意的细节:

  • maxSum必须初始化为Integer.MIN_VALUE,而不是0。因为如果整棵树所有节点都是负数,合法的最大路径和也必然是负数,初始化为0会直接导致答案错误。
  • 递归出口返回0,这里的0也有讲究:空节点没有贡献,所以返回0;而在父节点那里,Math.max(maxGain(node.left), 0)已经做了"负数剪枝",所以即使函数内部返回了负数,外部也会把它当作0处理。
  • 我是先调maxGain(root)然后返回maxSum,这样一遍后序遍历就完成了所有计算,不需要额外遍历。

实际提交到LeetCode,时间在1ms左右,空间约44MB,这已经是非常标准的答案了。如果你是用Python写的,思路上完全一样,只是把类的私有成员换成了非局部变量或者列表包一层,但概念不能变。

3.2 用一个具体例子完整走一遍递归

我们还是用上面那个例子:[-10,9,20,null,null,15,7],结构是:

-10 / \ 9 20 / \ 15 7

从根节点-10开始调用maxGain(-10)。

先算左孩子9:maxGain(9)调用后,它的左右孩子都是null,返回0,所以leftGain = 0, rightGain = 0。currentPathSum = 9 + 0 + 0 = 9,更新maxSum = 9。然后返回9 + max(0,0) = 9。

再算右孩子20,进入maxGain(20)。继续算它的左孩子15,maxGain(15)得到左右贡献都是0,currentPathSum = 15,更新maxSum = max(9,15) = 15,返回15。右孩子7同理,currentPathSum = 7,此时maxSum还是15,返回7。

回到节点20,leftGain = max(15,0) = 15,rightGain = max(7,0) = 7。currentPathSum = 20 + 15 + 7 = 42,更新maxSum = 42。返回20 + max(15,7) = 35。

回到根节点-10,leftGain = max(9,0) = 9,rightGain = max(35,0) = 35。currentPathSum = -10 + 9 + 35 = 34,比42小,不更新。返回-10 + max(9,35) = 25。

最终maxSum = 42。这个42正是路径15 -> 20 -> 7的和,完美命中预期。注意这个过程中根节点虽然返回了25给上层,但它的路径和34并没有更新全局答案,这就是"向上汇报单边贡献、全局记录完整路径"两个使命分开的典型体现。

3.3 复杂度与常见边界情况

时间复杂度是O(N),每个节点恰好访问一次。空间复杂度是O(H),H是树的高度,由递归调用栈决定。最坏情况是树退化成链表,高度为N,递归深度达到N,也就是热搜词里反复出现的"写二叉树程序时为什么总是报运行时错误"的一个主要来源——栈溢出。关于这个问题,我在第4部分会详细展开。

边界情况有三类,我在测试的时候都专门验证过:

  • 单节点树:[1],答案就是1。
  • 全负数树:[-1,-2,-3],答案应该是 -1,也就是单节点路径。如果全局变量初始化为0,这里就会错。
  • 空树:LeetCode这题默认至少有一个节点,所以不用单独处理,但如果你在本地测试空树,maxGain(null)返回0,maxSum会保持Integer.MIN_VALUE,但这不算是这题的输入范围。

4. 刷题时最容易踩的运行时错误:栈溢出、空指针与初始化陷阱

4.1 递归深度过大导致的栈溢出(StackOverflowError)

这道题本身是树形结构,通常递归深度不会太大。但有一个常见的变形:输入树是单链结构,比如每个节点只有一个右孩子,深度达到几万个节点。这时候如果直接递归,JVM的默认调用栈深度(通常在几千层到一万层之间)根本扛不住,程序会直接抛出StackOverflowError。

我在本地测试时造了一棵10000层的单链表树,一跑直接栈溢出。LeetCode的测试用例通常不会这么极端,但理解这个问题仍然很重要。你可以用这样的小工具验证:

TreeNode generateChain(int n) { TreeNode dummy = new TreeNode(0); TreeNode cur = dummy; for (int i = 1; i <= n; i++) { cur.right = new TreeNode(i); cur = cur.right; } return dummy.right; }

如果真遇到上万层的树,有两条路可以走:

  • 把递归改成显式迭代 + 后序遍历,使用栈模拟调用过程。
  • 用Morris遍历的变体,但这种题很少出现,实际工程意义也不大。

从我个人的刷题经验来看,掌握递归写法、明白"后序依赖"的推演方式,比直接上迭代写法重要得多。因为面试官更看重思路是否清晰,而不是你能否用迭代硬啃一个深度极端的数据。

4.2 空指针异常:递归内部到底要不要判空

还有一个常见的报错场景:在递归过程中,你可能会写出类似node.left.val这样的代码,结果node.left是null,直接抛NullPointerException。这道题的代码里,我们通过递归函数的天然出口规避了这个问题——当node为null时立即返回0,上层就不需要再访问node.left了。但如果你在递归函数里额外写了针对孩子节点的访问逻辑,比如:

if (node.left.val > 0) { ... }

那就有空指针风险了。正确做法是把"判空"与"取值/递归"分开:先看当前节点是否为空,为空就直接返回;再看孩子节点是否存在,如果需要访问,则通过调用递归函数(它内部会处理null)来间接完成。

一句话概括:凡是递归函数能接收null的,你就没必要在调用前判空,但你在访问任何节点的字段之前,必须确保这个节点不是null。这两件事别混在一起想。

4.3 全局答案初始化的隐蔽陷阱

这题的初始值,我见到过三个版本:

初始化写法结果
int maxSum = 0;全负数树时答案错
int maxSum = Integer.MIN_VALUE;正确
int maxSum = Integer.MAX_VALUE;完全错误

为什么0不对?因为如果你的树里全是负数,任何一个节点单独作为路径都比0小,但路径又必须非空,所以最终答案必然小于0。初始化成0之后,所有节点的currentPathSum都比0小,maxSum永远是0,答案自然就错了。

那为什么Integer.MAX_VALUE也不行?因为最大值初始化为最大整数,会导致任何路径和都比它小,答案永远是初始值本身。这属于低级错误,但我确实见过有人这么写。

正确思路是:全局答案的初始值应该取一个"比所有可能答案都小"的值。路径和的最大值是所有节点值之和的上限,最小值理论上可以是节点数 * 最小节点值,但因为节点值范围是[-1000, 1000],用Integer.MIN_VALUE是绝对安全的。这一行初始化,看似不起眼,实际上直接决定了全负数用例能不能过。

5. 进阶扩展:面试官如果继续追问,你怎么接得住

5.1 如何打印出最大路径的节点序列

很多面试官在让你求完值之后,会追加一个问题:能不能把取得最大路径的那条路径打印出来?这时候只在递归中记录最大值是不够的,你还需要记录"哪条路径产生了这个最大值"。

一个可行的思路是:额外维护两个字段——bestLeftPoint和bestRightPoint,分别记录产生最大路径和的那个节点的左右端点。当currentPathSum更新maxSum时,同步记录当前节点以及它左、右贡献的来源端点。然后在递归结束后,从当前记录的端点出发,向左右子树回溯拼接路径。

这个方案实现起来细节比较多,核心在于递归函数在返回"单边贡献"时,同时返回这个贡献对应的末端节点。我建议你在纸上先把[1,2,3]这个例子画一遍,理解路径拼接的方式,再动手写代码。这里提供一个简化版的Java伪码思路:

private int maxGainWithPath(TreeNode node, List<TreeNode> path) { if (node == null) return 0; // 左子树、右子树同样递归 int leftGain = maxGainWithPath(node.left, leftPath); int rightGain = maxGainWithPath(node.right, rightPath); // 如果leftGain <= 0,就不拼左端点;rightGain同理 // 更新maxSum时,根据选中的左右贡献来源,记录端点节点 // 返回单边贡献时,根据左右谁更大,决定把哪个子路径延伸到当前节点 }

如果你只是希望先掌握本题的核心思想,那打印路径可以先放一放,把"贡献值"和"全局答案"这两个概念彻底吃透,再去做这个扩展。

5.2 不依赖全局变量的写法:用结果类替代

全局变量写法虽然简洁,但有些面试官不喜欢类里带一个可变成员。一个替代方案是用一个长度为1的数组或者自定义结果类来承载答案。Java代码可以这样写:

class Solution { public int maxPathSum(TreeNode root) { int[] maxSum = new int[]{Integer.MIN_VALUE}; maxGain(root, maxSum); return maxSum[0]; } private int maxGain(TreeNode node, int[] maxSum) { if (node == null) return 0; int leftGain = Math.max(maxGain(node.left, maxSum), 0); int rightGain = Math.max(maxGain(node.right, maxSum), 0); maxSum[0] = Math.max(maxSum[0], node.val + leftGain + rightGain); return node.val + Math.max(leftGain, rightGain); } }

用int[]而不是int的原因很简单:Java方法传参是值传递,直接传int进去,递归内部修改不会反映到外部。换成数组,实际上传的是数组引用,修改数组元素才能共享状态。这个技巧在很多递归题里都适用,比如"求二叉树最大深度"的迭代版本也可能用到类似思路。

另外,如果你用C++写,完全可以用int &ans作为引用参数传进去,会简洁不少。Python的话,可以写self.maxSum作为实例属性,或者把maxSum放进一个列表[0]。原理是相通的。

5.3 从二叉树到多叉树的推广

这道题的思路完全可以扩展成N叉树版本。在N叉树中,路径的定义依然不变,但"经过当前节点的完整路径"变成了"当前节点值 + 所有孩子中贡献最大的两个(记作top1和top2)"。也就是说,在N叉树里,你需要把孩子节点贡献值从大到小排序,选择最大的两项作为路径的左右分支。

如果孩子贡献值为负数,就相当于不选。这种问题在一些公司的面试里也会出现,核心逻辑跟二叉树的完全一致,唯一的差异是把Math.max(leftGain, rightGain)改成在所有孩子的贡献中选前两名。实现时可以用一个优先队列或者两次循环找最大和次大值。

int top1 = 0, top2 = 0; for (Node child : node.children) { int gain = Math.max(maxGain(child, maxSum), 0); if (gain >= top1) { top2 = top1; top1 = gain; } else if (gain > top2) { top2 = gain; } } maxSum[0] = Math.max(maxSum[0], node.val + top1 + top2); return node.val + top1;

这就是整个题目的完整延伸。实际写下来你会发现,掌握了二叉树的这个递归结构,N叉树的版本几乎不需要新的知识,只是在取舍上有略微的调整。反过来,如果你能把N叉树版本也写出来,面试官对你的印象会明显加分。

这道题我刷完最大的感受是:递归题的瓶颈不在于语法而在于语义。当你把一个节点在系统中的职责想清楚了(向上汇报什么、全局记录什么),代码就是按着职责一行一行翻译出来而已。后序遍历、全局变量、0门槛剪枝,这三个要素串起来,LeetCode-124就彻底通透。如果你现在正卡在"不知道递归该返回什么"的阶段,试着用我这个办法:先画出节点的两个角色,再填空式地写代码,会顺很多。

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

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

立即咨询