243 lines
10 KiB
YAML
243 lines
10 KiB
YAML
title: Binary Tree Maximum Path Sum
|
|
slug: binary-tree-maximum-path-sum
|
|
difficulty: hard
|
|
leetcode_id: 124
|
|
leetcode_url: https://leetcode.com/problems/binary-tree-maximum-path-sum/
|
|
categories:
|
|
- trees
|
|
- dynamic-programming
|
|
- recursion
|
|
patterns:
|
|
- dfs
|
|
- dynamic-programming
|
|
|
|
function_signature: "def max_path_sum(root: TreeNode) -> int:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { root: [1, 2, 3] }
|
|
expected: 6
|
|
- input: { root: [-10, 9, 20, null, null, 15, 7] }
|
|
expected: 42
|
|
hidden:
|
|
- input: { root: [1] }
|
|
expected: 1
|
|
- input: { root: [-3] }
|
|
expected: -3
|
|
- input: { root: [2, -1] }
|
|
expected: 2
|
|
- input: { root: [-2, -1] }
|
|
expected: -1
|
|
- input: { root: [5, 4, 8, 11, null, 13, 4, 7, 2, null, null, null, 1] }
|
|
expected: 48
|
|
- input: { root: [1, -2, -3, 1, 3, -2, null, -1] }
|
|
expected: 3
|
|
|
|
description: |
|
|
A **path** in a binary tree is a sequence of nodes where each pair of adjacent nodes in the sequence has an edge connecting them. A node can only appear in the sequence **at most once**. Note that the path does not need to pass through the root.
|
|
|
|
The **path sum** of a path is the sum of the node's values in the path.
|
|
|
|
Given the `root` of a binary tree, return *the maximum **path sum** of any **non-empty** path*.
|
|
|
|
constraints: |
|
|
- The number of nodes in the tree is in the range `[1, 3 * 10^4]`
|
|
- `-1000 <= Node.val <= 1000`
|
|
|
|
examples:
|
|
- input: "root = [1,2,3]"
|
|
output: "6"
|
|
explanation: "The optimal path is 2 -> 1 -> 3 with a path sum of 2 + 1 + 3 = 6."
|
|
- input: "root = [-10,9,20,null,null,15,7]"
|
|
output: "42"
|
|
explanation: "The optimal path is 15 -> 20 -> 7 with a path sum of 15 + 20 + 7 = 42."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine each node in the tree as a potential **turning point** or **apex** of a path. A path can go through a node in three ways:
|
|
|
|
1. Enter from the left subtree, stop at the node
|
|
2. Enter from the right subtree, stop at the node
|
|
3. Enter from the left, pass through the node, and exit through the right (forming an "inverted V" shape)
|
|
|
|
The key insight is that **every valid path has exactly one apex node** — the topmost node where the path changes direction or ends. At this apex, the path can include contributions from both the left and right subtrees.
|
|
|
|
Think of it like finding the best hiking trail in a mountain range: at each peak (node), you consider the best trail coming up from the left valley, the best trail from the right valley, and whether combining them through this peak creates the overall best route.
|
|
|
|
The challenge is that we need to track two things simultaneously:
|
|
- The **best complete path** found anywhere in the tree (can turn at any node)
|
|
- The **best "arm"** extending from each node upward (for its parent to potentially use)
|
|
|
|
approach: |
|
|
We solve this using **DFS with Post-order Traversal**, computing the maximum gain from each subtree while tracking the global maximum path sum.
|
|
|
|
**Step 1: Define the recursive return value**
|
|
|
|
- The helper function returns the **maximum gain** that can be obtained by extending a path from the current node upward to its parent
|
|
- This means the path can only go in one direction (either left or right child, not both)
|
|
- We return `0` if including any child would decrease the sum (negative contribution)
|
|
|
|
|
|
|
|
**Step 2: Compute gains from children**
|
|
|
|
- Recursively get the maximum gain from the left subtree: `left_gain = max(0, dfs(node.left))`
|
|
- Recursively get the maximum gain from the right subtree: `right_gain = max(0, dfs(node.right))`
|
|
- Taking `max(0, ...)` means we only include a subtree if it adds positive value
|
|
|
|
|
|
|
|
**Step 3: Update the global maximum**
|
|
|
|
- At each node, compute the path sum if this node is the apex: `node.val + left_gain + right_gain`
|
|
- This path goes left-child → node → right-child (the full "V" shape)
|
|
- Update the global maximum if this path is better than any previously found
|
|
|
|
|
|
|
|
**Step 4: Return the maximum single-direction gain**
|
|
|
|
- Return `node.val + max(left_gain, right_gain)` to the parent
|
|
- We can only extend in one direction since the parent will connect from above
|
|
- This value represents the best "arm" starting at this node and going downward
|
|
|
|
|
|
|
|
**Step 5: Handle the base case**
|
|
|
|
- If the node is `None`, return `0` (no contribution to the path)
|
|
|
|
common_pitfalls:
|
|
- title: Confusing the Two Path Types
|
|
description: |
|
|
There are two distinct concepts in this problem:
|
|
|
|
1. **Complete path** — can turn at any node, used to update the global maximum
|
|
2. **Extendable arm** — can only go one direction, returned to the parent
|
|
|
|
A common mistake is returning `node.val + left_gain + right_gain` to the parent. This would be invalid because the parent can't connect to a path that already branches both ways — paths don't fork.
|
|
wrong_approach: "Return the complete path sum to parent"
|
|
correct_approach: "Return only the best single-direction gain to parent"
|
|
|
|
- title: Forgetting Negative Values
|
|
description: |
|
|
Node values can be negative (`-1000 <= Node.val <= 1000`). This means:
|
|
|
|
- The maximum path might consist of a single node
|
|
- We should not force including children if they decrease the sum
|
|
- Taking `max(0, child_gain)` lets us "prune" negative contributions
|
|
|
|
For example, in a tree `[-3]`, the answer is `-3`, not `0`. But if a child returns `-5`, we take `max(0, -5) = 0` to exclude it.
|
|
wrong_approach: "Always include child contributions"
|
|
correct_approach: "Use max(0, child_gain) to optionally exclude negative paths"
|
|
|
|
- title: Not Initializing Global Maximum Correctly
|
|
description: |
|
|
Since all node values could be negative, initializing `max_sum = 0` is wrong.
|
|
|
|
With `root = [-3, -2, -1]`, the answer should be `-1` (the single node with the least negative value), not `0`.
|
|
|
|
Initialize with negative infinity or the root's value.
|
|
wrong_approach: "Initialize max_sum = 0"
|
|
correct_approach: "Initialize max_sum = float('-inf')"
|
|
|
|
key_takeaways:
|
|
- "**Post-order DFS pattern**: Process children before the parent — essential when the parent's computation depends on children's results"
|
|
- "**Two-value tracking**: Maintain a global maximum for the answer while returning a different value (single-direction gain) for recursion"
|
|
- "**Optional inclusion**: Use `max(0, value)` to optionally exclude negative contributions without special-casing"
|
|
- "**Path apex insight**: Every tree path has exactly one 'top' node where direction changes — iterate over all possible apex positions"
|
|
|
|
time_complexity: "O(n). We visit each node exactly once during the DFS traversal."
|
|
space_complexity: "O(h) where h is the height of the tree. The recursion stack can go as deep as the tree height, which is O(log n) for balanced trees and O(n) for skewed trees."
|
|
|
|
solutions:
|
|
- approach_name: DFS with Post-order Traversal
|
|
is_optimal: true
|
|
code: |
|
|
class TreeNode:
|
|
def __init__(self, val=0, left=None, right=None):
|
|
self.val = val
|
|
self.left = left
|
|
self.right = right
|
|
|
|
def max_path_sum(root: TreeNode) -> int:
|
|
# Track the global maximum path sum
|
|
max_sum = float('-inf')
|
|
|
|
def dfs(node: TreeNode) -> int:
|
|
nonlocal max_sum
|
|
|
|
# Base case: null nodes contribute nothing
|
|
if not node:
|
|
return 0
|
|
|
|
# Get max gain from left and right subtrees
|
|
# Use max(0, ...) to ignore negative contributions
|
|
left_gain = max(0, dfs(node.left))
|
|
right_gain = max(0, dfs(node.right))
|
|
|
|
# Path sum if current node is the apex (turning point)
|
|
# This path goes: left subtree -> node -> right subtree
|
|
path_through_node = node.val + left_gain + right_gain
|
|
|
|
# Update global maximum if this path is better
|
|
max_sum = max(max_sum, path_through_node)
|
|
|
|
# Return max gain if we extend path to parent
|
|
# Can only go one direction (left OR right, not both)
|
|
return node.val + max(left_gain, right_gain)
|
|
|
|
dfs(root)
|
|
return max_sum
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Each node is visited exactly once.
|
|
|
|
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
|
|
|
|
The DFS function serves a dual purpose: it updates the global maximum considering the current node as an apex, while returning the maximum single-direction gain for the parent to use. This elegant design handles all path configurations in a single traversal.
|
|
|
|
- approach_name: DFS with Tuple Return
|
|
is_optimal: false
|
|
code: |
|
|
class TreeNode:
|
|
def __init__(self, val=0, left=None, right=None):
|
|
self.val = val
|
|
self.left = left
|
|
self.right = right
|
|
|
|
def max_path_sum(root: TreeNode) -> int:
|
|
def dfs(node: TreeNode) -> tuple[int, int]:
|
|
"""
|
|
Returns (max_path_in_subtree, max_extendable_arm)
|
|
- max_path_in_subtree: best complete path found in this subtree
|
|
- max_extendable_arm: best path starting at node going downward
|
|
"""
|
|
if not node:
|
|
# No path possible, arm contributes nothing
|
|
return float('-inf'), 0
|
|
|
|
# Recurse on children
|
|
left_max_path, left_arm = dfs(node.left)
|
|
right_max_path, right_arm = dfs(node.right)
|
|
|
|
# Best arm extending from this node (can skip children if negative)
|
|
best_arm = node.val + max(0, left_arm, right_arm)
|
|
|
|
# Best path with this node as apex
|
|
path_through_node = node.val + max(0, left_arm) + max(0, right_arm)
|
|
|
|
# Best path in entire subtree rooted here
|
|
best_path = max(left_max_path, right_max_path, path_through_node)
|
|
|
|
return best_path, max(0, best_arm)
|
|
|
|
max_path, _ = dfs(root)
|
|
return max_path
|
|
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Each node is visited exactly once.
|
|
|
|
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
|
|
|
|
This variant avoids using a global variable by returning two values: the best complete path found in the subtree and the best extendable arm. It's functionally equivalent to the optimal solution but makes the dual-tracking more explicit. Some prefer this for avoiding nonlocal variables.
|