Skip to content

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

  • 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

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.

has_path_sum.py
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))
has_path_sum.py
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

When the answer depends on children, compute both sides first, then combine.

max_depth.py
def max_depth(root):
    if not root:
        return 0
    return 1 + max(max_depth(root.left), max_depth(root.right))
max_depth.py
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. 9898 (validate BST), 110110 (balanced), and 104104 are all this with a different combine step.

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.

collect_paths.py
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 out
collect_paths.py
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 out

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.
diameter.py
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 best
diameter.py
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 best

Those two lines are different on purpose. left + rightleft + right is a path through this node that is finished. 1 + max(left, right)1 + max(left, right) is what the parent can build on. Conflating them — returning left + rightleft + 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.

TimeSpace
Any single-pass tree DFSO(n)O(n)O(h)O(h) recursion, hh = height

Space is O(logn)O(\log n) for a balanced tree and O(n)O(n) for a degenerate one — the mirror image of BFS, which is O(w)O(w) in the widest level.

The variant map

VariantChannel usedCanonical problem
Does a path exist?Down (remaining target)112 Path Sum
Collect all pathsDown + backtracking113 · 257
Build a number down the pathDown (total * 10 + valtotal * 10 + val)129 Sum Root to Leaf
Height / depth / balancedUp (summary)104 · 110
Any path, may bendSplit-brain543 · 124 · 687
Count paths summing to kDown, with a prefix-sum map437
Validate a subtree propertyUp, or down with bounds98 · 100 · 572

Practice — real LeetCode problems

LC 112 — Path Sum · Easy

Problem. Given the rootroot of a binary tree and an integer targetSumtargetSum, return TrueTrue if there is a root-to-leaf path whose values sum to targetSumtargetSum.

Constraints. 0 <= number of nodes <= 50000 <= number of nodes <= 5000, -1000 <= Node.val <= 1000-1000 <= Node.val <= 1000.

Examples. [5,4,8,11,null,13,4,7,2,null,null,null,1][5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22targetSum = 22 gives TrueTrue (5 -> 4 -> 11 -> 25 -> 4 -> 11 -> 2) · [1,2,3][1,2,3], targetSum = 5targetSum = 5 gives FalseFalse · [][], targetSum = 0targetSum = 0 gives FalseFalse

Editorial — approach, complexity, follow-ups

Subtract as you descend and test at the leaves. Short-circuiting oror means the search stops at the first success.

Time O(n)O(n). Space O(h)O(h).

The two test cases that matter:

  • ([], 0)([], 0) gives FalseFalse. An empty tree has no root-to-leaf path at all, so even a target of 00 fails. A solution returning TrueTrue for target == 0target == 0 gets this wrong.
  • ([1, 2], 1)([1, 2], 1) gives FalseFalse. Node 1 has only a left child, so the sole path is 1 -> 21 -> 2 summing 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 CounterCounter 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)(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

Problem. Return all root-to-leaf paths whose values sum to targetSumtargetSum. Each path is returned as a list of node values.

Constraints. 0 <= number of nodes <= 50000 <= number of nodes <= 5000, -1000 <= Node.val <= 1000-1000 <= Node.val <= 1000.

Examples. [5,4,8,11,null,13,4,7,2,null,null,5,1][5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22targetSum = 22 gives [[5,4,11,2],[5,8,4,5]][[5,4,11,2],[5,8,4,5]] · [1,2,3][1,2,3], targetSum = 5targetSum = 5 gives [][]

Editorial — approach, complexity, follow-ups

Backtracking on a tree. The shared pathpath list always holds the route from the root to the current node; appending on entry and popping on exit keeps that invariant automatically.

Time O(nh)O(n \cdot h) in the worst case — O(n)O(n) to visit every node, plus O(h)O(h) to copy each recorded path. Space O(h)O(h) for the recursion and pathpath, plus the output.

Two disciplines make this correct:

  1. Copy on record. list(path)list(path) — see the caution above.
  2. One poppop per appendappend, on every branch. Here the pop()pop() is after the if/elseif/else, so it executes whether or not a path was recorded. Putting a returnreturn inside the ifif branch without popping first is a classic leak that corrupts every later path.

Note the elseelse: 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 NoneNone) but the elseelse 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 O(n2)O(n^2) re-walking. “Why not build a new list per recursive call?” — you can (dfs(node.left, remaining, path + [node.val])dfs(node.left, remaining, path + [node.val])) and it removes the copy bug, but it allocates O(h)O(h) per node instead of once per result.

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^41 <= number of nodes <= 10^4, -100 <= Node.val <= 100-100 <= Node.val <= 100.

Examples. [1,2,3,4,5][1,2,3,4,5] gives 33 (the path 4 -> 2 -> 1 -> 34 -> 2 -> 1 -> 3) · [1,2][1,2] gives 11 · [1][1] gives 00

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)height(left) + height(right) edges. Take the maximum over all nodes.

Meanwhile the recursion must return 1 + max(left, right)1 + max(left, right), because a parent can only extend a path that runs straight down.

Time O(n)O(n) — one visit per node. Space O(h)O(h).

The naive alternative — compute heightheight separately at every node — is O(n2)O(n^2) 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))max(0, height(child)) since you may drop a negative branch entirely, and record node.val + left + rightnode.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 10410^4 in a chain?” — raise the recursion limit or go iterative.

LeetCode problem set

#ProblemDifficultyThe twist
104Maximum Depth of Binary TreeEasyThe plain upward summary: 1 + max(left, right)1 + max(left, right)
112Path SumEasyCarry the remaining target down; a one-child node is not a leaf
543Diameter of Binary TreeEasySplit-brain: return the height, record left + rightleft + right
110Balanced Binary TreeEasyReturn the height, or a sentinel -1-1 to abort early
113Path Sum IIMediumBacktracking; copy the path when recording
129Sum Root to Leaf NumbersMediumCarry total * 10 + valtotal * 10 + val downward
437Path Sum IIIMediumPrefix-sum map carried down, decremented on the way back up
124Binary Tree Maximum Path SumHardSplit-brain with negative branches clamped to 00

Interview follow-ups

They askWhat they’re checkingThe answer
“DFS or BFS here?”Deliberate choicePaths and subtree properties are DFS; per-level and nearest-thing are BFS
“Why return one value and record another?”The split-brain insightA parent can only extend a straight-down path; a bent path is already complete
“Why copy the path?”Reference vs. valuepathpath is mutated throughout; storing it stores an alias that later pops empty
“Space complexity?”PrecisionO(h)O(h)O(logn)O(\log n) balanced, O(n)O(n) degenerate; the mirror of BFS’s O(w)O(w)
“What if the tree is 10^4 deep?”Practical PythonRecursionErrorRecursionError at the default limit of 1000; raise it or use an explicit stack
“Count paths not starting at the root?”CompositionPrefix-sum CounterCounter down the path, decremented on unwind (LC 437)
“Can you avoid recomputing heights?”Complexity awarenessThread the recording through one traversal; separate height calls make it O(n2)O(n^2)

Edge-case checklist

  • Empty tree — LC 112 must return FalseFalse even for targetSum = 0targetSum = 0.
  • Single node — diameter 00; a path sum equal to that node’s value is TrueTrue.
  • One-child nodes[1,2][1,2] with target 11 is FalseFalse. The defining trap of this family.
  • Negative node values — legal; they forbid pruning on “the remaining target went negative”, and they force the max(0, ...)max(0, ...) clamp in LC 124.
  • Target sum of zero — valid; do not treat 00 as “no target”.
  • Deep degenerate tree — recursion limit; mention it before it bites.
  • Duplicate values — never identify nodes by value; use the node objects.
  • Path bending away from the root[1,2,3,4,null,null,5,6,null,null,7][1,2,3,4,null,null,5,6,null,null,7] has diameter 66, and no path through the root achieves it.

Recap

  • Tree DFS gives you two channels: carry context down as arguments, return summaries up. Deciding which the problem needs is most of the work.
  • A leaf has neither child. Using if not nodeif not node as the leaf test wrongly accepts the missing side of a one-child node — the single most common bug here.
  • To collect paths, choose / recurse / un-choose, and record list(path)list(path), never pathpath. Every appendappend needs exactly one poppop, on every branch.
  • The split-brain trick — return 1 + max(left, right)1 + max(left, right), record left + rightleft + right — solves the whole “any path, may bend” family in two lines.
  • O(n)O(n) time and O(h)O(h) space; watch Python’s 1000-frame recursion limit on degenerate trees.
  • DFS composes with other patterns: LC 437 is this page plus prefix sums and a hash map.

Next: BST Patterns — what changes once the tree is ordered, and why an in-order walk is often the entire solution.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did