Tree DFS Paths and Sums
Tree BFS answers questions about levels. Tree DFS answers questions about paths and subtrees — and the reason is structural: recursion naturally gives you two channels of information that BFS does not.
Down the recursion you carry context: the path so far, the remaining target, the accumulated number. Up the recursion you return summaries: a height, a subtree sum, a best-so-far.
Almost every tree DFS problem is a question of which channel you need. Get that right and the code writes itself.
What you’ll learn
Section titled “What you’ll learn”- The two information channels, and how to decide which a problem wants.
- The choose / recurse / un-choose discipline for collecting paths, and why you must copy the path when you record it.
- The split-brain trick: when the value you return differs from the value you record. This is the single most reusable idea on the page.
- Why a node with one child is not a leaf, and where that bites.
- Three real LeetCode problems solved in the browser: 112, 113, 543.
The cue
Section titled “The cue”Visual intuition
Section titled “Visual intuition”The two channels, one per trace. First downward state: the target shrinks on the way down, and the leaf test is a comparison against what is left.
Watch the call stack panel: each frame holds a different remaining target. The leaf 7 fails with remaining 2, the recursion unwinds one frame, and the sibling 2 succeeds against the same remaining 2 -- which is only possible because the state is a parameter, not a shared variable.
Now upward summaries, and the split-brain structure that solves every “any path, not necessarily through the root” problem:
At each node, left + right is a finished path that bends here (recorded), while 1 + max(left, right) is the extendable downward path (returned). Node 2 records 2 and returns 2; the root records 3 and returns 3. Returning the bend value instead is the classic bug -- a parent cannot extend a path that already turned.
Channel 1 — carry state downward
Section titled “Channel 1 — carry state downward”Pass what the child needs as an argument. For path problems that is usually the remaining target, which is cleaner than accumulating a running total and comparing at the bottom.
def has_path_sum(root, target):
if not root:
return False
if not root.left and not root.right: # a real leaf
return target == root.val
remaining = target - root.val
return (has_path_sum(root.left, remaining)
or has_path_sum(root.right, remaining))Channel 2 — return summaries upward
Section titled “Channel 2 — return summaries upward”When the answer depends on children, compute both sides first, then combine.
def max_depth(root):
if not root:
return 0
return 1 + max(max_depth(root.left), max_depth(root.right))That is the whole shape: base case for the empty tree, recurse both sides,
combine. 98 (validate BST), 110 (balanced), and 104 are all this with a
different combine step.
Collecting paths — choose / recurse / un-choose
Section titled “Collecting paths — choose / recurse / un-choose”To record paths rather than just test them, maintain one shared list and undo your change on the way out. This is backtracking applied to a tree.
def path_sum(root, target):
out, path = [], []
def dfs(node, remaining):
if not node:
return
path.append(node.val) # choose
if not node.left and not node.right and remaining == node.val:
out.append(list(path)) # COPY, not path
else:
dfs(node.left, remaining - node.val)
dfs(node.right, remaining - node.val)
path.pop() # un-choose
dfs(root, target)
return outThe split-brain trick
Section titled “The split-brain trick”Here is the idea worth taking away. Some problems ask about paths that do not pass through the root — diameter, maximum path sum. The trick is that the recursion returns one thing while a side channel records another:
- Return the best path that goes downward from this node, because that is all a parent can extend.
- Record the best path that bends at this node (left side + right side), because that path is complete and cannot be extended upward.
def diameter(root):
best = 0
def height(node):
nonlocal best
if not node:
return 0
left = height(node.left)
right = height(node.right)
best = max(best, left + right) # RECORD: path bending here
return 1 + max(left, right) # RETURN: extendable downward path
height(root)
return bestThose two lines are different on purpose. left + right is a path through
this node that is finished. 1 + max(left, right) is what the parent can
build on. Conflating them — returning left + right — produces nonsense,
because a bent path cannot be extended by a parent.
This exact structure solves LC 543, LC 124, LC 687, LC 1522 and more. Once you recognise “any path, not necessarily through the root”, you write these two lines and fill in the details.
Dry run
Section titled “Dry run”Channel 1 — has_path_sum on [5,4,8,11,null,13,4,7,2,null,null,null,1],
target = 22. Only the left spine is shown, because that is where the answer is
found.
| node | target on entry | leaf? | action |
|---|---|---|---|
| 5 | 22 | no | remaining = 22 − 5 = 17, recurse left |
| 4 | 17 | no | remaining = 17 − 4 = 13, recurse left |
| 11 | 13 | no | remaining = 13 − 11 = 2, recurse left |
| 7 | 2 | yes | 2 == 7? False — unwind one frame |
| 2 | 2 | yes | 2 == 2? True — the or short-circuits all the way up |
Path 5 → 4 → 11 → 2 sums to 22. ✓
- Two leaves see the same
remaining = 2and disagree. That only works because the state is a parameter: the failing call at leaf 7 cannot corrupt what leaf 2 receives. A sharedself.running_totalwould need an explicit undo, which is the bug this channel exists to avoid. - The
oris the pruning. Once leaf 2 returnsTrue,has_path_sum(root.right, …)is never called — the entire right subtree, node 8 and below, goes unvisited. - The leaf test is what makes it correct. Try
root = [1, 2],target = 1: the only root-to-leaf path is1 → 2= 3, so the answer isFalse. With aif not root: return target == 0base case, node 1’s missing right child returnsTrueand the function wrongly succeeds.
Channel 2 — diameter on [1,2,3,4,5]. Post-order, so children resolve first.
best is the side channel.
| node | left | right | records left + right | best after | returns 1 + max |
|---|---|---|---|---|---|
| 4 | 0 | 0 | 0 | 0 | 1 |
| 5 | 0 | 0 | 0 | 0 | 1 |
| 2 | 1 | 1 | 2 | 2 | 2 |
| 3 | 0 | 0 | 0 | 2 | 1 |
| 1 | 2 | 1 | 3 | 3 | 3 |
Diameter 3 — the path 4 → 2 → 1 → 3, which has 3 edges and does not pass
through any single node as an endpoint.
- At node 2 the recorded and returned values coincide (both 2) and at the root they
do not (records 3, returns 3 — equal here by coincidence of shape, but the
meanings differ: 3 edges bending at the root versus a 3-node downward chain).
Node 2 is the one to check when debugging:
left + right = 2is the finished path4 → 2 → 5, while1 + max(1,1) = 2is what node 1 may extend. - The answer is never the return value.
height(root)is discarded;bestis returned. Every problem in this family has that shape, and returning the recursion’s value instead is the fastest way to a wrong answer that still compiles.
Complexity
Section titled “Complexity”| Approach | Time | Space |
|---|---|---|
| Any single-pass tree DFS | recursion, h = height | |
| Collecting all root-to-leaf paths | for the output | |
| Iterative with an explicit stack | on the heap, no frame limit |
Space is for a balanced tree and for a degenerate one — the mirror image of BFS, which is in the widest level.
The for collecting paths is worth stating separately, because it is a different bound from testing one: each of up to leaves copies a path of length up to . The copy is mandatory (see the caution above), so this cost is unavoidable — but it is the output size, not wasted work.
The variant map
Section titled “The variant map”| Variant | Channel used | Canonical problem |
|---|---|---|
| Does a path exist? | Down (remaining target) | 112 Path Sum |
| Collect all paths | Down + backtracking | 113 · 257 |
| Build a number down the path | Down (total * 10 + val) | 129 Sum Root to Leaf |
| Height / depth / balanced | Up (summary) | 104 · 110 |
| Any path, may bend | Split-brain | 543 · 124 · 687 |
| Count paths summing to k | Down, with a prefix-sum map | 437 |
| Validate a subtree property | Up, or down with bounds | 98 · 100 · 572 |
Practice — real LeetCode problems
Section titled “Practice — real LeetCode problems”LC 112 — Path Sum · Easy
Section titled “LC 112 — Path Sum · Easy”Problem. Given the root of a binary tree and an integer targetSum,
return True if there is a root-to-leaf path whose values sum to
targetSum.
Constraints. 0 <= number of nodes <= 5000,
-1000 <= Node.val <= 1000.
Examples. [5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22
gives True (5 -> 4 -> 11 -> 2) · [1,2,3], targetSum = 5 gives False
· [], targetSum = 0 gives False
Editorial — approach, complexity, follow-ups
Subtract as you descend and test at the leaves. Short-circuiting or means
the search stops at the first success.
Time . Space .
The two test cases that matter:
([], 0)givesFalse. An empty tree has no root-to-leaf path at all, so even a target of0fails. A solution returningTruefortarget == 0gets this wrong.([1, 2], 1)givesFalse. Node 1 has only a left child, so the sole path is1 -> 2summing to 3. This is the one-child trap described above.
Follow-ups you should expect: “Return all such paths?” — LC 113, next.
“Count paths that need not start at the root?” — LC 437: carry a prefix-sum
Counter down and decrement it on the way back up, so only the current
root-to-node path is in scope. “Do it iteratively?” — an explicit stack of
(node, remaining) pairs. “What about negative values?” — fully supported
here; note that they rule out any early-exit pruning like “remaining is
already negative, give up”.
LC 113 — Path Sum II · Medium
Section titled “LC 113 — Path Sum II · Medium”Problem. Return all root-to-leaf paths whose values sum to
targetSum. Each path is returned as a list of node values.
Constraints. 0 <= number of nodes <= 5000,
-1000 <= Node.val <= 1000.
Examples. [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22
gives [[5,4,11,2],[5,8,4,5]] · [1,2,3], targetSum = 5 gives []
Editorial — approach, complexity, follow-ups
Backtracking on a tree. The shared path list always holds the route from the
root to the current node; appending on entry and popping on exit keeps that
invariant automatically.
Time in the worst case — to visit every node, plus
to copy each recorded path. Space for the recursion and
path, plus the output.
Two disciplines make this correct:
- Copy on record.
list(path)— see the caution above. - One
popperappend, on every branch. Here thepop()is after theif/else, so it executes whether or not a path was recorded. Putting areturninside theifbranch without popping first is a classic leak that corrupts every later path.
Note the else: once a matching leaf is found there are no children to
recurse into, so the branch is genuinely exclusive. Recursing anyway would
still work (both children are None) but the else states the intent.
Follow-ups you should expect: “Return the paths as strings (LC 257)?” —
same structure, join with "->". “Paths that need not end at a leaf?” —
record at every node instead of only at leaves. “Count instead of collect
(LC 437)?” — prefix-sum map with an undo on the way back up; far better than
re-walking. “Why not build a new list per recursive call?” — you can
(dfs(node.left, remaining, path + [node.val])) and it removes the copy bug,
but it allocates per node instead of once per result.
LC 543 — Diameter of Binary Tree · Easy
Section titled “LC 543 — Diameter of Binary Tree · Easy”Problem. The diameter is the length of the longest path between any two nodes, measured in edges. The path may or may not pass through the root.
Constraints. 1 <= number of nodes <= 10^4,
-100 <= Node.val <= 100.
Examples. [1,2,3,4,5] gives 3 (the path 4 -> 2 -> 1 -> 3) ·
[1,2] gives 1 · [1] gives 0
Editorial — approach, complexity, follow-ups
The longest path in a tree bends at exactly one node — its highest point. So
consider each node as that apex: the best path bending there is
height(left) + height(right) edges. Take the maximum over all nodes.
Meanwhile the recursion must return 1 + max(left, right), because a parent
can only extend a path that runs straight down.
Time — one visit per node. Space .
The naive alternative — compute height separately at every node — is
because heights get recomputed. Threading the recording through the
same single traversal is what makes it linear, and explaining that is the
point of the problem.
Follow-ups you should expect: “Maximum path sum (LC 124)?” — same
skeleton, but clamp negative contributions with
max(0, height(child)) since you may drop a negative branch entirely, and
record node.val + left + right. “Longest path with equal values (LC 687)?”
— only extend into a child whose value matches. “Return the path itself?” —
track the apex node and re-descend from it. “Nodes up to in a chain?” —
raise the recursion limit or go iterative.
LeetCode problem set
Section titled “LeetCode problem set”Generated from the problem database, so each entry carries its sheet membership and reported companies. Tick them off as you go — progress is saved in this browser, and the Export button writes it to a file you can keep.
Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.
- 94Binary Tree Inorder Traversaleasy
- 104Maximum Depth of Binary TreeeasyThe plain upward summary: `1 + max(left, right)`
- 110Balanced Binary TreeeasyReturn the height, or a sentinel `-1` to abort early
- 112Path SumeasyCarry the remaining target down; a one-child node is not a leaf
- 543Diameter of Binary TreeeasySplit-brain: return the height, record `left + right`
- 113Path Sum IImediumBacktracking; copy the path when recording
- 114Flatten Binary Tree to Linked Listmedium