LeetCode 437 - Path Sum III
The problem gives us the root of a binary tree and an integer targetSum. We need to count how many downward paths in the tree have values that add up exactly to targetSum. A path can begin at any node and end at any node, as long as it always moves downward from parent to child.
Difficulty: 🟡 Medium
Topics: Tree, Depth-First Search, Binary Tree
Solution
Problem Understanding
The problem gives us the root of a binary tree and an integer targetSum. We need to count how many downward paths in the tree have values that add up exactly to targetSum.
A path can begin at any node and end at any node, as long as it always moves downward from parent to child. This is an important distinction because we are not restricted to paths that start at the root or end at a leaf. Every node in the tree can potentially serve as the starting point of a valid path.
For example, consider a path like:
5 -> 3
If the sum of those values equals the target, then it counts, even if the path does not start at the root.
The input is a standard binary tree where each node contains:
- An integer value
- A left child
- A right child
The output is a single integer representing the total number of valid downward paths whose sum equals targetSum.
The constraints are important:
- The tree contains at most 1000 nodes
- Node values can be negative
- Target values can also be negative
The presence of negative numbers means we cannot use techniques like sliding windows or greedy pruning. A path that currently exceeds the target might later become valid after adding negative values. Because of this, we need a more general approach.
Several edge cases immediately stand out:
- An empty tree should return
0 - Trees containing negative numbers may create many overlapping valid paths
- A single node may itself equal the target
- Multiple paths can overlap and share nodes
- Large positive and negative values mean we must rely on exact arithmetic rather than heuristics
These characteristics strongly suggest that this is fundamentally a prefix sum and DFS problem.
Approaches
Brute Force Approach
The brute-force strategy is to treat every node as a potential starting point. For each node, we recursively explore all downward paths beginning at that node and count how many produce the target sum.
The algorithm works like this:
- Start DFS from every node in the tree.
- During each DFS traversal, keep track of the remaining sum needed.
- If the remaining sum becomes zero at any node, increment the answer.
- Continue exploring left and right children.
This approach is correct because it explicitly examines every possible downward path in the tree.
However, it is inefficient because many paths are recomputed repeatedly. If the tree has N nodes, then for each node we may traverse almost the entire subtree below it.
In the worst case, such as a skewed tree, this leads to O(N^2) time complexity.
Optimal Approach
The key insight is that this problem resembles the classic "subarray sum equals k" problem for arrays.
Instead of recomputing every path separately, we can track prefix sums while traversing the tree.
Suppose:
currentSumis the sum from the root to the current node- We previously encountered a prefix sum
currentSum - targetSum
Then the values between those two points must sum to targetSum.
This works because:
currentSum - previousPrefixSum = targetSum
Rearranging:
previousPrefixSum = currentSum - targetSum
So during DFS, we maintain a hash map that stores how many times each prefix sum has appeared along the current traversal path.
At every node:
- Compute the current prefix sum
- Check how many times
currentSum - targetSumhas occurred - Add that count to the answer
- Store the current prefix sum before exploring children
- Remove it when backtracking
This allows us to count all valid paths in a single DFS traversal.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(N²) | O(H) | Starts a DFS from every node |
| Optimal | O(N) | O(H) average, O(N) worst | Uses prefix sums with a hash map |
H represents the height of the tree.
Algorithm Walkthrough
Optimal Prefix Sum DFS Algorithm
- Initialize a hash map called
prefixCount.
This map stores how many times a particular prefix sum has appeared along the current root-to-node path.
We begin with:
prefixCount[0] = 1
This is important because a path from the root itself may equal targetSum.
2. Start a depth-first traversal from the root.
During traversal, maintain a variable called currentSum, which represents the sum of all node values from the root to the current node.
3. At each node, update the running prefix sum.
currentSum += node.val
- Determine how many valid paths end at the current node.
If we previously saw a prefix sum equal to:
currentSum - targetSum
then the segment between that earlier point and the current node sums to targetSum.
We add:
prefixCount[currentSum - targetSum]
to the answer. 5. Store the current prefix sum in the hash map.
This allows child nodes to use the current node as part of their path calculations. 6. Recursively explore the left and right subtrees.
Both children inherit the updated prefix sum information. 7. Backtrack after processing children.
Remove the current prefix sum from the map:
prefixCount[currentSum] -= 1
This step is crucial because paths must remain within the current traversal branch. Without backtracking, paths from unrelated branches could incorrectly combine. 8. Continue until all nodes are processed.
The accumulated count is the final answer.
Why it works
The algorithm maintains the invariant that prefixCount contains prefix sums only along the current root-to-node traversal path.
For every node, we check how many earlier prefix sums satisfy:
currentSum - previousPrefixSum = targetSum
Every such occurrence corresponds to a unique downward path ending at the current node whose sum equals the target.
Because every node is visited exactly once and every valid path is counted precisely when its endpoint is processed, the algorithm is both complete and correct.
Python Solution
from collections import defaultdict
from typing import Optional
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int:
prefix_count = defaultdict(int)
prefix_count[0] = 1
def dfs(node: Optional[TreeNode], current_sum: int) -> int:
if not node:
return 0
current_sum += node.val
paths_found = prefix_count[current_sum - targetSum]
prefix_count[current_sum] += 1
paths_found += dfs(node.left, current_sum)
paths_found += dfs(node.right, current_sum)
prefix_count[current_sum] -= 1
return paths_found
return dfs(root, 0)
The implementation begins by creating a hash map called prefix_count. This stores the number of times each prefix sum appears along the current DFS path.
The line:
prefix_count[0] = 1
handles paths that start directly from the root.
The nested dfs function performs recursive traversal. Each call receives:
- The current node
- The running prefix sum
At each node:
current_sum += node.val
updates the running total.
We then determine how many valid paths end at the current node:
paths_found = prefix_count[current_sum - targetSum]
This works because any earlier prefix sum equal to current_sum - targetSum creates a valid path segment.
Next, we record the current prefix sum before traversing children:
prefix_count[current_sum] += 1
After recursively processing both subtrees, we backtrack:
prefix_count[current_sum] -= 1
This ensures sibling branches do not interfere with each other.
Finally, the DFS returns the total number of valid paths found in the subtree.
Go Solution
package main
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func pathSum(root *TreeNode, targetSum int) int {
prefixCount := map[int]int{
0: 1,
}
var dfs func(node *TreeNode, currentSum int) int
dfs = func(node *TreeNode, currentSum int) int {
if node == nil {
return 0
}
currentSum += node.Val
pathsFound := prefixCount[currentSum-targetSum]
prefixCount[currentSum]++
pathsFound += dfs(node.Left, currentSum)
pathsFound += dfs(node.Right, currentSum)
prefixCount[currentSum]--
return pathsFound
}
return dfs(root, 0)
}
The Go implementation follows the exact same algorithmic structure as the Python solution.
A Go map[int]int is used instead of Python's defaultdict(int). Missing keys automatically return the zero value for integers, which makes the logic straightforward.
Nil nodes are checked explicitly:
if node == nil
Go integers are sufficient here because the constraints stay well within 64-bit limits on modern systems, though some implementations may choose int64 for extra safety.
The recursive DFS closure captures prefixCount and targetSum, allowing clean state management without global variables.
Worked Examples
Example 1
Input:
root = [10,5,-3,3,2,null,11,3,-2,null,1]
targetSum = 8
The valid paths are:
5 -> 3
5 -> 2 -> 1
-3 -> 11
Traversal Trace
| Node | Current Sum | Needed Prefix | Matching Paths | Prefix Map After Insert |
|---|---|---|---|---|
| 10 | 10 | 2 | 0 | {0:1, 10:1} |
| 5 | 15 | 7 | 0 | {0:1, 10:1, 15:1} |
| 3 | 18 | 10 | 1 | {0:1, 10:1, 15:1, 18:1} |
| 3 | 21 | 13 | 0 | ... |
| -2 | 16 | 8 | 0 | ... |
| 2 | 17 | 9 | 0 | ... |
| 1 | 18 | 10 | 1 | ... |
| -3 | 7 | -1 | 0 | ... |
| 11 | 18 | 10 | 1 | ... |
At every node where currentSum - targetSum exists in the map, a valid path is found.
The final answer is:
3
Example 2
Input:
root = [5,4,8,11,null,13,4,7,2,null,null,5,1]
targetSum = 22
Valid paths:
5 -> 4 -> 11 -> 2
4 -> 11 -> 7
5 -> 8 -> 4 -> 5
Traversal Highlights
| Node | Current Sum | Needed Prefix | Matches |
|---|---|---|---|
| 5 | 5 | -17 | 0 |
| 4 | 9 | -13 | 0 |
| 11 | 20 | -2 | 0 |
| 7 | 27 | 5 | 1 |
| 2 | 22 | 0 | 1 |
| 8 | 13 | -9 | 0 |
| 4 | 17 | -5 | 0 |
| 5 | 22 | 0 | 1 |
Total valid paths:
3
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(N) | Every node is visited exactly once |
| Space | O(H) average, O(N) worst | Hash map stores prefix sums along the current path |
The DFS traversal processes each node exactly one time, and every hash map operation is O(1) on average.
The recursion depth equals the height of the tree. In a balanced tree this is O(log N), while in a skewed tree it becomes O(N).
The prefix sum map only stores sums along the active recursion path, so its size is bounded by the tree height.
Test Cases
from typing import Optional
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def build_example_1():
return TreeNode(
10,
TreeNode(
5,
TreeNode(3, TreeNode(3), TreeNode(-2)),
TreeNode(2, None, TreeNode(1)),
),
TreeNode(-3, None, TreeNode(11)),
)
def build_example_2():
return TreeNode(
5,
TreeNode(4, TreeNode(11, TreeNode(7), TreeNode(2))),
TreeNode(8, TreeNode(13), TreeNode(4, TreeNode(5), TreeNode(1))),
)
solution = Solution()
# Example 1 from problem statement
assert solution.pathSum(build_example_1(), 8) == 3
# Example 2 from problem statement
assert solution.pathSum(build_example_2(), 22) == 3
# Empty tree
assert solution.pathSum(None, 5) == 0
# Single node equals target
assert solution.pathSum(TreeNode(5), 5) == 1
# Single node does not equal target
assert solution.pathSum(TreeNode(5), 3) == 0
# Negative values
root = TreeNode(-2, None, TreeNode(-3))
assert solution.pathSum(root, -5) == 1
# Multiple overlapping paths
root = TreeNode(
1,
TreeNode(1, TreeNode(1), TreeNode(1)),
TreeNode(1)
)
assert solution.pathSum(root, 2) == 4
# Path does not need to start at root
root = TreeNode(1, TreeNode(2, TreeNode(3)))
assert solution.pathSum(root, 5) == 1 # 2 -> 3
# Deep skewed tree
root = TreeNode(1)
current = root
for _ in range(4):
current.right = TreeNode(1)
current = current.right
assert solution.pathSum(root, 3) == 3
# Zero target with zero values
root = TreeNode(0, TreeNode(0), TreeNode(0))
assert solution.pathSum(root, 0) == 5
Test Summary
| Test | Why |
|---|---|
| Example 1 | Validates standard mixed-value tree |
| Example 2 | Validates multiple valid paths |
| Empty tree | Ensures base case works |
| Single matching node | Tests minimal valid input |
| Single non-matching node | Tests minimal invalid input |
| Negative values | Confirms support for negatives |
| Overlapping paths | Ensures all valid paths are counted |
| Non-root starting path | Verifies paths can start anywhere |
| Skewed tree | Tests worst-case recursion depth |
| Zero target with zeros | Validates repeated prefix sums |
Edge Cases
Empty Tree
An empty tree should immediately return 0 because there are no paths at all.
This case can easily cause null reference errors if recursion assumes every node exists. The implementation avoids this by checking:
if not node:
return 0
before doing any processing.
Negative Numbers
Negative values are especially important because they invalidate pruning strategies.
For example, a path sum may temporarily exceed the target and later decrease due to a negative node. Algorithms that stop early when sums become too large would fail here.
The prefix sum approach works correctly regardless of positive or negative values because it relies purely on arithmetic differences between cumulative sums.
Paths Starting in the Middle of the Tree
A common mistake is assuming paths must begin at the root.
Consider:
1
\
2
\
3
with target 5.
The valid path is:
2 -> 3
The prefix sum technique naturally handles this because it detects valid subpaths between any two prefix sums along the current traversal path.
Duplicate Prefix Sums
When multiple nodes produce the same prefix sum, every occurrence matters.
For example, trees containing many zeros can generate repeated prefix sums. If the implementation stored only a boolean instead of a count, it would miss valid paths.
Using a frequency map ensures all matching paths are counted correctly.