二叉树中的最大路径和 图解题解
路径可以在树里任意拐弯,但每个节点只能选一侧往上传。
送快递时每个中转站可以「接左边来的包裹」或「接右边来的」,但不能同时把两边的货都转给上家——拐过弯的路不能再往上延伸。树形 DP 用后序遍历:先从叶子算起,每个节点向上只能返回「自己 + 左或右其中一侧」;但在当前节点处可以把左贡献、自身、右贡献全部加在一起更新全局最大值,负数贡献直接当零丢弃。
这道题到底在问什么
- 输入
- 根为 5 的 11 节点树(含负值节点)
- 输出
- 57
最优解:为什么这么做
一句话答案:LeetCode 124 二叉树中的最大路径和用树形 DP 一趟后序遍历解决:每个节点用 node.val + L + R 这个「穿过自己」的路径和更新全局答案,但返回给父亲的只有单边贡献 node.val + max(L, R),且孩子的负贡献先用 max(0, …) 裁成 0。时间 O(n),空间 O(h)。
这道题的路径和直觉里的不一样
这里的「路径」是树中任意一串相邻相连的节点:不必经过根、不必到叶子、可以在某个节点处拐弯(一头来自左子树、一头来自右子树),但不能分叉成三条。节点值可正可负,路径至少含一个节点。要求所有合法路径里节点值之和的最大值。「可负」和「不必过根」这两点,决定了它比求直径(LeetCode 543)多一层裁剪逻辑。
为什么按最高拐点枚举就不重不漏
路径的形状有个不变的骨架:任何一条路径都存在唯一一个位置最高的节点,路径在它这里拐弯,两头分别沿左、右子树往下延伸(某一头可以为空)。于是把所有路径按「最高拐点是谁」分类——每个节点当一次拐点,算出以它为拐点能拼出的最大路径和,取全局最大,就必然覆盖所有路径且互不重复。以节点 x 为拐点的最优值是 x.val + L + R,其中 L、R 分别是左右孩子往下延伸的最大单边和。这类在树上自底向上递推的做法通常叫树形 DP。
为什么返回给父亲的是单边而不是左加右
递归函数 gain(node) 要给父亲一个数:从父亲走进 node 之后,往下最多还能捞多少分。路径进了 node 只能继续沿一条腿往下走——若左右都走就在 node 处拐了弯,而拐点已经是 node,这条路径不可能再向上延伸到父亲。所以返回值只能是 node.val + max(L, R),即挑更肥的那条腿。
而 node.val + L + R 是把两条腿都接上的「拐弯路径」,它是 node 作为拐点的完整答案,只能就地和全局最大值比较,不能上传。一个函数、两个口径——返回单边、全局收拐弯值——是这套套路的核心,混用任何一个都错。
负贡献为什么要裁成零
路径有权「不要」某条子分支:如果一条腿往下走的最大和是负数,接上它只会拖累总和,最优选择就是不走这条腿,等价于让它贡献 0。所以代码里读孩子返回值时先做 L = max(0, gain(node.left))、R 同理。这一步是本题与求直径最大的差别——直径里边数不会为负,不需要裁剪;本题节点值可负,不裁剪就会把明明可以舍弃的负分强行算进来。
答案初值为什么必须是负无穷
全局答案 ans 的初值要设成负无穷而不是 0。因为路径至少要含一个节点,当整棵树全是负数时(比如只有一个值为 -3 的节点),正确答案是 -3;初值若是 0,它比任何真实路径和都大,会错误地输出 0。设成负无穷后,每个节点的拐弯值 node.val + L + R 至少会以「只含自己」的形式参与一次比较(此时 L、R 被裁成 0),全负树也能得到正确的最大单点值。
复杂度与同构题
时间 O(n):后序遍历中每个节点恰好访问一次,每次只做常数次比较与加法。空间 O(h):递归栈深度等于树高,最坏退化成链是 O(n)。
「返回单边链、全局更新拼接值」这套骨架能直接套到一批题上:二叉树的直径(LeetCode 543)是边数版,最长同值路径(LeetCode 687)是加约束版;本题是在骨架上叠加 max(0, …) 处理负值。骨架吃透,这一族题只剩口径差异。
▶ 动画逐步走查(共 25 步)——想跟着动画一帧帧对照就展开
- 3记住:返回给父亲的值(单边贡献)和更新答案用的值(左+右穿过值)不是同一个数;而且负贡献都要先裁成 0——这是本题比「求直径」多出来的一步。
- 4后序遍历:先把左子树彻底算完,再右子树,最后才轮到父亲。每个节点会留下记录:返回给父亲的「单边贡献」、穿过自己的「路径和」,以及当前的全局最大答案。
- 5轮到节点 9(橙色)。它是叶子,左右孩子都空 → 左贡献 = 0、右贡献 = 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 6节点 9 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 9 + 0 + 0 = 9(刷新了全局最大 → 9)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 9 + max(0, 0) = 9——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 7轮到节点 7(橙色)。先读两个孩子已经返回好的单边贡献,并各做一次 max(0, …) 裁剪:左孩子返回 9 → max(0, 9) = 9;右孩子为空 → 右贡献 = 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 8节点 7 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 7 + 9 + 0 = 16(刷新了全局最大 → 16)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 7 + max(9, 0) = 16——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 9轮到节点 2(橙色)。它是叶子,左右孩子都空 → 左贡献 = 0、右贡献 = 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 10节点 2 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 2 + 0 + 0 = 2(没超过当前最大 16)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 2 + max(0, 0) = 2——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 11轮到节点 11(橙色)。先读两个孩子已经返回好的单边贡献,并各做一次 max(0, …) 裁剪:左孩子返回 16 → max(0, 16) = 16;右孩子返回 2 → max(0, 2) = 2。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 12节点 11 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 11 + 16 + 2 = 29(刷新了全局最大 → 29)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 11 + max(16, 2) = 27——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 13轮到节点 -6(橙色)。它是叶子,左右孩子都空 → 左贡献 = 0、右贡献 = 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 14节点 -6 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = -6 + 0 + 0 = -6(没超过当前最大 29)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = -6 + max(0, 0) = -6——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 15轮到节点 4(橙色)。先读两个孩子已经返回好的单边贡献,并各做一次 max(0, …) 裁剪:左孩子返回 27 → max(0, 27) = 27;右孩子返回 -6 → 是负的,max(0, -6) 裁成 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 16节点 4 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 4 + 27 + 0 = 31(刷新了全局最大 → 31)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 4 + max(27, 0) = 31——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 17轮到节点 -4(橙色)。它是叶子,左右孩子都空 → 左贡献 = 0、右贡献 = 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 18节点 -4 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = -4 + 0 + 0 = -4(没超过当前最大 31)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = -4 + max(0, 0) = -4——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 19轮到节点 13(橙色)。先读两个孩子已经返回好的单边贡献,并各做一次 max(0, …) 裁剪:左孩子返回 -4 → 是负的,max(0, -4) 裁成 0(这条分支宁可不走);右孩子为空 → 右贡献 = 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 20节点 13 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 13 + 0 + 0 = 13(没超过当前最大 31)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 13 + max(0, 0) = 13——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 21轮到节点 -1(橙色)。它是叶子,左右孩子都空 → 左贡献 = 0、右贡献 = 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 22节点 -1 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = -1 + 0 + 0 = -1(没超过当前最大 31)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = -1 + max(0, 0) = -1——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 23轮到节点 8(橙色)。先读两个孩子已经返回好的单边贡献,并各做一次 max(0, …) 裁剪:左孩子返回 13 → max(0, 13) = 13;右孩子返回 -1 → 是负的,max(0, -1) 裁成 0。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 24节点 8 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 8 + 13 + 0 = 21(没超过当前最大 31)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 8 + max(13, 0) = 21——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 25轮到节点 5(橙色)。先读两个孩子已经返回好的单边贡献,并各做一次 max(0, …) 裁剪:左孩子返回 31 → max(0, 31) = 31;右孩子返回 21 → max(0, 21) = 21。 这两个贡献都是孩子在「自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 26节点 5 算完(转绿)。① 穿过它的路径和 = 节点值 + 左贡献 + 右贡献 = 5 + 31 + 21 = 57(刷新了全局最大 → 57)。② 返回给父亲的单边贡献 = 节点值 + max(左贡献, 右贡献) = 5 + max(31, 21) = 36——注意这两个数不同:返回的只挑一条腿(单边),更新答案用的是左右两条腿一起拼。
- 27全部算完。所有节点里最大的「穿过值」出现在拐点节点 5 上,等于 57。绿色加亮的就是这条最大路径——它在节点 5 处把左右两条「贡献为正」的最深链拼起来;那些负贡献的分支(被 max(0,…) 裁掉的)一律没被选进路径。这说明答案要靠「每个节点各算一次 val+左+右」逐个比较,而不是看某一条到底的链。
⚠️ 容易写错的地方
✗ 错:把返回值当成答案(返回 val+L+R 当作返回给父亲的值)
✓ 对:返回的是单边贡献 val+max(L,R),答案要单独用 val+L+R 在全局变量里更新
父亲只能从你这接「一条腿」往上拼;返回 val+L+R 会让父亲算出根本走不通的「分叉路径」
✗ 错:忘了 max(0, …),直接用孩子的负贡献
✓ 对:左右贡献都先 max(0, gain(child))
某条分支是负的就宁可不接它(贡献当 0),否则会把答案拉低
✗ 错:ans 初值设成 0
✓ 对:ans 初值要设成负无穷
全是负数的树(如只有 [-3])答案应是 -3,初值 0 会错误地返回 0
完整代码(Python / C++ / Java)
Python
class Solution:
def maxPathSum(self, root):
self.ans = float("-inf")
def gain(node):
if not node: return 0
L = max(0, gain(node.left)) # 负贡献裁成 0
R = max(0, gain(node.right))
self.ans = max(self.ans, node.val + L + R) # 穿过本节点
return node.val + max(L, R) # 返回单边贡献
gain(root)
return self.ansC++
class Solution {
int ans = INT_MIN;
int gain(TreeNode* node){
if(!node) return 0;
int L = max(0, gain(node->left)); // 负贡献裁成 0
int R = max(0, gain(node->right));
ans = max(ans, node->val + L + R); // 穿过本节点
return node->val + max(L, R); // 返回单边贡献
}
public:
int maxPathSum(TreeNode* root){
gain(root);
return ans;
}
};Java
class Solution {
private int ans = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
gain(root);
return ans;
}
private int gain(TreeNode node) {
if (node == null) return 0;
int L = Math.max(0, gain(node.left)); // 负贡献裁成 0
int R = Math.max(0, gain(node.right));
ans = Math.max(ans, node.val + L + R); // 穿过本节点的路径
return node.val + Math.max(L, R); // 返回给父亲的单边贡献
}
}复杂度
时间
O(n)
每个节点只在后序里访问一次
空间
O(h)
递归栈深度 = 树高 h,最坏(退化成链)O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 二叉树中的最大路径和 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么左右贡献要 max(0, …)?+
路径可以「不要」某条子分支。如果一条腿的贡献是负的,接上它只会让和变小,那就当它贡献 0(即不走这条分支)。这一步是和「求直径」最大的不同。
为什么返回给父亲的是 max(L,R) 而不是 L+R?+
父亲接到你这条腿后还要继续往上走,路径不能在你这分叉,所以你只能给父亲「往下一条腿最大」的那个值;L+R 是两条腿拐弯,那是你自己内部的事,不能传给父亲。
这套「递归返回单边、全局更新拐弯值」的套路还能解什么?+
二叉树的直径(LC543)、最长同值路径(LC687)等都是同构:返回单边链,全局更新左右拼接的值。本题多了一层 max(0,…) 处理负贡献。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 二叉树中的最大路径和 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。