Problem
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.
Examples
Example 1:
|
|
|
|
Example 2:
|
|
|
|
Solution
Video explanation
Here is the video explaining this method in detail. Please check it out:
Method 1 - DFS but Naive
Intuition
The most straightforward way to think about this is to test every possible path. A simpler (but still slow) way to do this is to consider every single node in the tree as the potential “peak” or the highest point of a path. If we calculate the best possible “V”-shaped path (upside down) that is centered at each node, the largest one we find must be the answer.
Approach
- Iterate Through All Nodes: We traverse the entire tree, and for each
node
, we treat it as the “peak” of a potential maximum path. - Calculate Downward Paths: For each
peak
node, we need to find the longest possible path going downwards from its left child (left_sum
) and its right child (right_sum
). This requires a separate helper function that does a downward traversal from each child. - Handle Negative Branches: If the longest downward path from a child has a negative sum, it will only decrease our total. Therefore, we should ignore it. We calculate this as
max(0, left_sum)
andmax(0, right_sum)
. - Calculate Peak Path: The best path centered at the current
peak
isnode.val + max(0, left_sum) + max(0, right_sum)
. - Track Global Maximum: We keep a global maximum variable and compare it with the peak path sum we just calculated for every node in the tree.
- Bottleneck: This approach is inefficient (
O(n^2)
) because for every node we select as a peak, we start new traversals to find itsleft_sum
andright_sum
. This means we re-calculate the downward paths for the same subtrees over and over again.
Code
|
|
|
|
Complexity
- ⏰ Time complexity:
O(n^2)
, as for each of then
nodes in the tree, we potentially traverse all its descendant nodes to find the longest left and right paths. In the worst case of a skewed tree, this results in anO(n)
operation for each of then
nodes, leading to a quadratic time complexity. - 🧺 Space complexity:
O(h)
. The space is dominated by the recursion stack for the traversal. In the worst case of a completely unbalanced tree, the recursion depth can ben
, leading to a linear space complexity.
Method 2 - Optimal Postorder DFS
The bottleneck in the naive approach was re-calculation. We can eliminate this by getting all the information we need in a single pass. The key insight is to use a post-order traversal and design a recursive function that does two jobs at once:
- Calculate a potential answer with itself as the peak.
- Provide its parent with the best straight path it can contribute.
This avoids recalculating subproblems.
For each node, there are four possible paths for the maximum path sum:
- Only the node itself
- The maximum path through the left child plus the node
- The maximum path through the right child plus the node
- The maximum path through the left child, the node, and the right child
The key is to track these four paths and select the highest value at the end.
Approach
We design a recursive dfs(node)
function that performs these two jobs:
-
Base Case: If the node is
null
, it can’t contribute to any path, so we return 0. -
Post-Order Traversal: Recursively call
dfs
on the left and right children.left_gain = max(0, dfs(node.left))
right_gain = max(0, dfs(node.right))
- We use
max(0, ...)
here for a critical reason: if a child returns a negative path contribution, we are better off not including that branch at all. This is how the algorithm “cuts” a path and decides where a new one should begin.
-
Job 1: Update Global Maximum: With the gains from the children calculated, we now check for a new maximum path with the current
node
as the peak.max_sum = max(max_sum, node.val + left_gain + right_gain)
- This
max_sum
is a global (or class-level) variable that tracks the best path found so far anywhere in the tree.
-
Job 2: Return Value for Parent: The function must return the best possible straight path it can offer its parent. A path cannot split, so it can only include the node and one of its children’s paths.
return node.val + max(left_gain, right_gain)
Note
It is important to understand that each subtree’s root must return the maximum path sum that includes at most one child. This is necessary for the call to the parent function. In the code below, this sum is called maxOne
and it is what the recursive function returns.
Why don’t we return the sum of node.val + node.left + node.right
(lets name it - pathSumWithCurrNodeAsPeak
) in the last line? Including pathSumWithCurrNodeAsPeak
in the maxPathSum
would violate the principle that each node can be traversed only once. Since it is impossible to traverse from node->left->right->node’s parent without revisiting the node itself, we cannot include currTwo
in the return value.
Code
|
|
|
|
|
|
|
|
Dry Run Examples
Example 1
Our goal is to find the maximum path sum. Now consider the following example.
|
|
In this simple case we know that the max sum would be just the root node itself and the answer would be 10. So fo all leaf
nodes the max sum path is the value of the node itself.
Example 2
Now let’s consider the following example.
|
|
Here there are multiple possibilities and we need to take care of the following FOUR PATHS that could be our max.
- The root iself :
20
- The root with the maximum from it’s left subTree : = 10 + 20
|
|
- The root with the maximum from it’s right subTree : = 30 + 20
|
|
- The root with it’s left, right and itself = 60
|
|
In case you are wondering why we did not choose the root.left (10) or root.right(30) alone in the calculation ( like I wondered ), that’s because we would have already computed the result of it as a node in our recursion separately.