Path Sum in a Binary Tree: Remainders and Prefix Sums
8 min readBytePatterns
Path sum in a binary tree, three ways: carry the remainder to a leaf, collect every path with backtracking, and count any downward path with prefix sums.
"Path sum" is not one question but a family of three, and they are asked in sequence. Does a root-to-leaf path add up to the target? List every such path. Count the paths that add up to it when a path may start and end at any node, as long as it goes downwards. The tree is the same each time. What changes is what you carry down the recursion, and choosing that well is the whole skill.
The problem it solves
Take the lesson's tree: 5 at the root, with 4 on the left (and 11 below it) and 8 on the right (and 3 below it).
- Path sum I: is there a root-to-leaf path summing to 20? Yes,
5 + 4 + 11. - Path sum II: return every root-to-leaf path that sums to the target, as lists of values.
- Path sum III: count downward paths anywhere in the tree summing to 11. There are two: the single node
11, and8 + 3.
A brute force for the third question starts a walk from every node and follows every downward path, which is O(n²) on a degenerate tree. The goal is to share work between those walks.
The intuition
Version I: carry the remainder. Instead of a running total that you compare with the target at the end, subtract each node's value from the target on the way down. A leaf then only has to check for zero. It is the same arithmetic, but it means every call gets one number and answers one question: "does a path below me sum to this?"
The rule that trips people up is the leaf test. A remainder of zero halfway down is not an answer, because the question is about complete root-to-leaf paths. Only a node with no children may report a hit.
Version II: carry the path. Keep one list of the values from the root to the current node. Append on the way in, pop on the way out, and copy the list when a leaf hits zero. That is backtracking: a single shared list, restored after each child.
Version III: carry prefix sums. A downward path from node u to node v sums to the prefix sum at v minus the prefix sum just above u. So at each node, with current prefix cur, the number of paths ending here that sum to the target is the number of ancestors, or the empty prefix, whose prefix equals cur - target. Keep a hash map of prefix counts along the current root-to-node path, adding on the way in and removing on the way out. It is the tree version of subarray sum equals k, with the path from the root playing the role of the array.
The lesson's version of III keeps a list of every open path's running sum instead. It is simpler to reason about and costs O(h) per node for height h; the hash map cuts that to O(1).
Watch it run
The animation asks whether any root-to-leaf path adds up to 20 and carries the remainder down instead of a running total. At the root it subtracts 5, so both children are asked for 15, not for 20. At 4 it subtracts again, and the child is asked for 11. At leaf 11 there is nothing left to find: that path sums to 20. On the other branch, 8 leaves 7 for its child, and leaf 3 still needs 4. Too late: that path is over. One branch landed on zero and the other did not, and a remainder of zero halfway down would not have counted. Only at a leaf.
Path Sum Variants
Step 1 of 7
Does any root-to-leaf path add up to 20? Carry the remainder down instead of a running total.
The same interactive animation as the lesson — step through it with the controls.
The code
The three versions on the lesson's tree:
from collections import defaultdict
class Node:
def __init__(self, v, l=None, r=None):
self.val, self.left, self.right = v, l, r
def has_path(n, target): # I: carry the remainder down
if not n:
return False
rest = target - n.val
if not n.left and not n.right:
return rest == 0 # only a leaf may say yes
return has_path(n.left, rest) or has_path(n.right, rest)
def all_paths(root, target): # II: one shared list, backtracking
out, path = [], []
def walk(n, rest):
if not n:
return
path.append(n.val)
rest -= n.val
if not n.left and not n.right and rest == 0:
out.append(list(path)) # copy: the list keeps changing
walk(n.left, rest)
walk(n.right, rest)
path.pop() # undo before returning
walk(root, target)
return out
def count_paths(root, target): # III: prefix sums in a hash map
seen = defaultdict(int)
seen[0] = 1 # the empty prefix above the root
def walk(n, cur):
if not n:
return 0
cur += n.val
hits = seen[cur - target] # paths ending here
seen[cur] += 1
hits += walk(n.left, cur) + walk(n.right, cur)
seen[cur] -= 1 # leave the path: forget this prefix
return hits
return walk(root, 0)
root = Node(5, Node(4, Node(11)), Node(8, Node(3)))
print(has_path(root, 20), has_path(root, 9)) # True False
print(all_paths(root, 20), all_paths(root, 16)) # [[5, 4, 11]] [[5, 8, 3]]
print(count_paths(root, 11)) # 2
The remainder each node is asked for, in the order the animation visits them:
def remainders(n, target, log):
if n:
log.append((n.val, target - n.val))
remainders(n.left, target - n.val, log)
remainders(n.right, target - n.val, log)
return log
print(remainders(root, 20, []))
# [(5, 15), (4, 11), (11, 0), (8, 7), (3, 4)]
All three against brute force on 2,000 random trees with negative values allowed, where the brute force lists every root-to-leaf path and every downward path explicitly:
import random
def random_tree(n):
if n == 0:
return None
k = random.randint(0, n - 1)
return Node(random.randint(-3, 5), random_tree(k), random_tree(n - 1 - k))
def root_to_leaf(n):
if not n:
return []
if not n.left and not n.right:
return [[n.val]]
return [[n.val] + p for p in root_to_leaf(n.left) + root_to_leaf(n.right)]
def downward_sums(n): # every path starting at n, going down
if not n:
return []
return [n.val] + [n.val + s for s in downward_sums(n.left) + downward_sums(n.right)]
def all_nodes(n):
return [n] + all_nodes(n.left) + all_nodes(n.right) if n else []
random.seed(20)
ok = True
for _ in range(2000):
t = random_tree(random.randint(0, 12))
target = random.randint(-3, 10)
full = [p for p in root_to_leaf(t) if sum(p) == target]
ok &= has_path(t, target) == bool(full)
ok &= all_paths(t, target) == full
ok &= count_paths(t, target) == sum(downward_sums(u).count(target) for u in all_nodes(t))
print(ok) # True
The complexity
- Version I:
O(n)time, each node visited at most once, andO(h)stack space for tree heighth. - Version II:
O(n)visits, plus the cost of copying hits. In the worst case that isO(n · h)output, because a copy is as long as the path. - Version III with a hash map:
O(n)time andO(h)extra space for the map and the stack. - Version III with a list of open sums, as in the lesson:
O(n · h)time, which isO(n log n)on a balanced tree andO(n²)on a chain.
Where it goes wrong
- Accepting a zero remainder at an inner node. Test for a leaf first;
has_path(root, 9)is false even though5 + 4 = 9. - Treating an empty tree as a zero-sum path. With no root there is no path, so the answer is false, even for a target of 0.
- Appending the shared list itself in version II. Every stored "path" then points at the same list, which ends empty. Copy it.
- Forgetting to undo in version III. If the prefix count is not decremented on the way out, a sibling subtree sees prefixes from a branch it is not on.
- Stopping early with negative values. Values can be negative, so exceeding the target does not mean the path is dead.
When it shows up in interviews
The three versions are a common ladder in tree rounds: I checks recursion, II checks backtracking, and III checks whether you spot the prefix-sum trick from arrays. Expect a question about why the leaf test matters and about the complexity on a skewed tree. The same "what do I pass down, what do I return up?" choice drives diameter of a binary tree and validate a BST.
How to say it in an interview
"For the root-to-leaf version I carry the remainder: subtract each node's value on the way down, and only a leaf reports a hit when the remainder is zero. That is linear time and height-sized stack. To list the paths I keep one path list, append on entry, pop on exit, and copy it at a matching leaf. For paths that can start anywhere, a downward path is a difference of two prefix sums on the same root-to-node path, so I keep a hash map of prefix counts along the current path. At each node I add the count of cur - target, recurse, then decrement my prefix on the way out. That is O(n) instead of O(n · h)."