Tree LCA, Distance & Path Problems

Trees & HeapsPriority 5 of 5 — Must know — expect it in almost every loopMust know Updated Nov 12, 2026
Section priorityPriority 5 of 5 — Must know — expect it in almost every loopMust knowPriority 4 of 5 — High value — a gap here costs you roundsHigh valuePriority 3 of 5 — Worth knowing — usually a variant of a must-know patternWorth knowingPriority 2 of 5 — Niche — read once, revisit only if a company is known to askNicheMarked on the sections that carry it — unmarked sections are background/reference.

Scope — Lowest common ancestor, node-to-node distance, parent-map (bidirectional) traversal and the root-to-leaf path templates — every tree problem whose answer is a path or a meeting point rather than a shape. See also: tree.md — the pattern catalogue and traversal templates these build on; tree_examples.md — the rest of the worked tree problems; tree_backtrack.md — root→leaf paths that undo state on the way back up; bst.md — LCA on an ordered tree (LC 235); binary_tree.md — depth (passed down) vs height (returned up), the two quantities every distance template here combines.

LeetCode Problem Lists

Overview

Three questions dominate this family: where do two nodes meet (LCA), how far apart are they (distance), and which root-to-leaf paths satisfy a property (path sum / path length). All three are one DFS — what changes is whether the number travels down as an argument or up as a return value, and whether you are allowed to walk upward to a parent.

Key Properties

  • Complexity: O(N) time for every template here; space O(H) for recursion, O(N) once a parent map or graph is materialised
  • Core Idea: the LCA is the split point — dist(p, q) = dist(lca, p) + dist(lca, q)
  • When to Use: the answer is a path, a distance, or a meeting point rather than a subtree property
  • Sentinel rule: a distance helper must return -1 (not 0) for “not found”, because 0 is a valid distance

Recognition

The constraint cues come from the ruler in complexity_cheatsheet.md; every one here asks for a single O(N) pass.

Signal in the statement Constraint cue Reach for Not this if
“lowest common ancestor of p and q”, a root is given nodes ≤ 10⁵ Template 1 post-order LCA the tree is a BST → walk down by value in O(h) (bst.md)
“LCA”, but the signature has no root and nodes carry parent nodes ≤ 10⁵ LC 1650 variant — two pointers up the parent chains a root is given → Template 1
“smallest subtree containing all the deepest nodes” nodes ≤ 500 LC 865 variant — return (depth, node) the targets are named → Template 1
“all nodes at distance k from target”, “closest leaf to node k” nodes ≤ 500 Template 2 parent map + BFS distance only downward from a known ancestor → Template 3
“number of edges between the nodes with values p and q” nodes ≤ 10⁴ Template 3 LCA + get_dist twice every node k away → Template 2
“all root-to-leaf paths whose sum is targetSum”, “return every path” nodes ≤ 5,000 Template 4 pre-order + backtracking the path may start below the root → Template 5
“number of paths that sum to targetSum”, path “need not start at the root or end at a leaf” nodes ≤ 1,000, negative values Template 5 prefix-sum map the same question on an array → prefix_sum_advanced.md
“longest path between any two nodes”, “maximum path sum” nodes ≤ 3 · 10⁴ post-order height + global max (LC 124 card, binary_tree.md) the path must run root → leaf → Template 4

Problem Categories

Category Question it answers Template Examples
LCA (root given) where do p and q meet? post-order DFS returning the first node that sees both LC 236, 1644, 1676, 865, 1123
LCA (no root) same, but I only hold p, q and parent pointers walk up both parent chains — LC 160 in disguise LC 1650
LCA (ordered tree) same, on a BST walk down while both targets are on one side LC 235
Distance (downward) how many edges from an ancestor to a target? pre-order DFS carrying depth, -1 sentinel LC 1740
Distance (any direction) which nodes are k away, in any direction? parent map → undirected graph → BFS LC 863, 742
Root-to-leaf paths which paths sum to / look like X? pre-order DFS + backtracking LC 112, 113, 257
Any-to-any downward paths how many sub-paths sum to X? pre-order DFS + prefix-sum HashMap LC 437
Longest path how long is the longest path through a node? post-order DFS returning height, global max LC 543, 687

Templates & Algorithms

Template 1: LCA by Returned Presence — LC 236

Recognise — “the lowest common ancestor of two nodes p and q”, “the deepest node that has both as descendants (a node may be a descendant of itself)”, a root is given and both targets are guaranteed to exist. Nodes ≤ 10⁵ → one O(N) pass.

Key point — dfs(node) returns p or q if one of them is in node’s subtree (or their LCA once both are), and None otherwise. The first node where both calls return non-null is the split point, so it is the LCA. When one target is an ancestor of the other, the early return root on meeting it is already the answer: the other target is somewhere below it.

python
# python
# LC 236 - Lowest Common Ancestor of a Binary Tree
# IDEA: post-order; each call reports "a target (or their LCA) is below me";
#       the first node that hears it from both sides is the LCA
# time = O(N), space = O(H)
class Solution(object):
    def lowestCommonAncestor(self, root, p, q):
        if not root or root == p or root == q:
            return root                      # a target, or fell off the tree
        left = self.lowestCommonAncestor(root.left, p, q)
        right = self.lowestCommonAncestor(root.right, p, q)
        if left and right:                   # p and q split here -> this node is the LCA
            return root
        return left if left else right       # both on one side: pass that side's answer up
java
// java
// LC 236 - Lowest Common Ancestor of a Binary Tree
// IDEA: post-order; return the target (or the LCA) found below me, else null
// time = O(N), space = O(H)
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    if (root == null || root == p || root == q) {
        return root;
    }
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
    if (left != null && right != null) {
        return root;                          // the split point
    }
    return left != null ? left : right;
}

Complexity

  • Time O(N): the two recursive calls visit each node at most once; the early return root only cuts the walk short.
  • Space O(H): the recursion stack, one frame per level — H = N on a skewed tree.

Dry run — root = [3,5,1,6,2,0,8,null,null,7,4]

text
p = 5, q = 1
dfs(3)   not a target
  dfs(5) -> 5 is p, return 5        (5's subtree is never searched)
  dfs(1) -> 1 is q, return 1
  left = 5, right = 1  -> both non-null -> LCA = 3

p = 5, q = 4
dfs(3)
  dfs(5) -> return 5 at once        (4 is below it, unseen)
  dfs(1) -> None
  left = 5, right = None -> pass 5 up -> LCA = 5

Traps

  • Returning root whenever either side is non-null: p = 6, q = 4 makes dfs(3) return 3 instead of passing up the 5 it got from the left.
  • The early return assumes both targets exist. With q absent (LC 1644), p = 5, q = 99 still returns 5; count the hits instead — the LC 1644 code below.
  • Comparing root.val == p.val instead of the nodes: fine while values are unique, wrong the moment they repeat. Compare node identity.

Not this if — the tree is a BST: walk down by value in O(h), never into both subtrees (bst.md, and the LC 235 code below). There is no root and every node has a parent: walk up instead (LC 1650 variant).

Drill — LC 236 · LC 235 (BST: walk down) · LC 1650 (no root: two pointers up) · LC 1644 (targets may be absent: count hits) · LC 1676 (a set of targets) · LC 865 (the deepest nodes are the implicit targets)

The LCA family — pick the template by what you are handed Priority 5 of 5 — Must know — expect it in almost every loop

All five problems say “lowest common ancestor”. The post-order template above only fits the first row; the others change one precondition each, and that precondition decides the shape.

LC What changes vs 236 Shape that fits Complexity
236 — (baseline: root given, p, q guaranteed present) post-order, return found-or-null O(N) / O(H)
235 the tree is a BST no recursion on both sides — walk down while p, q are on the same side O(H) / O(1)
1644 p, q may not exist in the tree same post-order, but also count how many targets were seen; answer only if count == 2 O(N) / O(H)
1650 no root — every node has a parent pointer walk up, not down → this is LC 160 (two linked lists) O(H) / O(1)
1676 N targets instead of 2 same post-order against a set of targets O(N) / O(H)

The one question that picks the template: can I reach p and q from a root? If yes → recurse down (236 / 1644 / 1676). If I am standing on p and q with parent pointers → walk up (1650); there is no tree to search at all.

LCA Variant — Parent Pointers, No Root (LC 1650) Priority 4 of 5 — High value — a gap here costs you rounds

1. Core Idea

Key Insight: lowestCommonAncestor(p, q) — note the missing root. You cannot search a tree you have no handle on. But p.parent.parent… is a linked list ending at the root, so two nodes give two lists that merge at exactly the LCA. This is LC 160, Intersection of Two Linked Lists, wearing a tree costume.

text
root = [3,5,1,6,2,0,8,null,null,7,4],  p = 4,  q = 1

  path(4) : 4 -> 2 -> 5 -> 3      (len 4)
  path(1) : 1 -> 3                (len 2)
                     ^
                     the lists merge here -> LCA = 3

So the two useful shapes are LC 160’s two shapes: a set of one path, or two pointers.

2. Template A — set of ancestors (the one to say first)
python
# python
# LC 1650 - Lowest Common Ancestor of a Binary Tree III
# IDEA: SET + walk UP (p has a parent chain, so collect it, then re-walk from q)
# time = O(h), space = O(h)
class Solution(object):
    def lowestCommonAncestor(self, p, q):
        # walk UP via `parent`, there is no root to walk down from
        ancestors = set()
        node = p
        while node:
            ancestors.add(node)
            node = node.parent

        # the FIRST hit going up from q is the LOWEST one
        node = q
        while node:
            if node in ancestors:
                return node
            node = node.parent

        return None

Why the first hit is the lowest: walking up from q visits its ancestors in increasing height order, so the earliest common node is the deepest common node. Same reason the insertion-ordered-dict variant of this solution works. Hash nodes, not node.val — val only happens to be unique here (the constraints say so); the node objects are always unique.

3. Template B — two pointers, “swap the tails” (O(1) space) ⭐⭐⭐⭐⭐

The two parent chains have different lengths, so walking both up in lock-step misses the meeting point. The fix is LC 160’s: when a pointer runs off the top, restart it at the other starting node. Each pointer then covers len(path(p)) + len(path(q)) steps, so both arrive at every node of the shared tail in the same iteration.

python
# python
# LC 1650 - Lowest Common Ancestor of a Binary Tree III
# IDEA: 2 POINTERS ON THE PARENT CHAIN ("swap the tails") -- same trick as LC 160
# time = O(h), space = O(1)
class Solution(object):
    def lowestCommonAncestor(self, p, q):
        # edge
        if not p or not q:
            return None

        a, b = p, q
        while a != b:
            # `if a else q`, NOT `a.parent or q`
            a = a.parent if a else q
            b = b.parent if b else p
        return a
java
// java
// LC 1650 - Lowest Common Ancestor of a Binary Tree III
// IDEA: 2 pointers on the parent chain; swap tails on hitting null
// time = O(h), space = O(1)
public Node lowestCommonAncestor(Node p, Node q) {
    // edge
    if (p == null || q == null) {
        return null;
    }

    Node a = p, b = q;
    while (a != b) {
        // consume the null step itself, so both pointers walk the same distance
        a = (a == null) ? q : a.parent;
        b = (b == null) ? p : b.parent;
    }
    return a;
}

Visual trace (p = 4, q = 1 from the tree above):

text
step :  0    1    2     3     4     5    6
a    :  4    2    5     3     null  1    3
b    :  1    3    null  4     2     5    3   -> a == b == 3  ✓

Common pitfalls:

  • ❌ a = a.parent or q / a = (a.parent != null) ? a.parent : q — this skips the null step. Consuming null is what equalises the two total distances; skip it and the pointers are permanently off by one and can pass each other forever.
  • ❌ Comparing a.val != b.val instead of a != b — fine here, but it is the identity of the node you want, and it breaks the moment values repeat.
  • ❌ Assuming p and q share a root. If they sit in different trees both pointers land on null on the same step, a == b == null, and the loop exits returning null — the two-pointer form terminates for free, the naive lock-step one does not.
  • ❌ Reaching for the post-order template. There is no root parameter to pass it.
4. The sibling variants in one place

LC 1644 — p / q may not be in the tree. The 236 template returns p whenever it meets p, so it happily reports an “LCA” for a q that does not exist. Fix: never short-circuit, and count the targets you actually saw.

python
# python
# LC 1644 - Lowest Common Ancestor of a Binary Tree II
# IDEA: 236's post-order, but do NOT return early -- count the hits, then validate
# time = O(N), space = O(H)
class Solution(object):
    def lowestCommonAncestor(self, root, p, q):
        self.found = 0

        def dfs(node):
            if not node:
                return None
            # recurse FIRST, even if node is p or q (no early return)
            left = dfs(node.left)
            right = dfs(node.right)
            if node == p or node == q:
                self.found += 1
                return node
            if left and right:
                return node
            return left if left else right

        lca = dfs(root)
        # only trust the answer if BOTH targets really exist
        return lca if self.found == 2 else None

LC 1676 — N targets instead of 2. Swap the two == checks for a set membership test; nothing else moves.

python
# python
# LC 1676 - Lowest Common Ancestor of a Binary Tree IV
# IDEA: 236's post-order against a SET of targets (all nodes guaranteed to exist)
# time = O(N), space = O(H + k)
class Solution(object):
    def lowestCommonAncestor(self, root, nodes):
        targets = set(nodes)

        def dfs(node):
            if not node or node in targets:
                return node
            left = dfs(node.left)
            right = dfs(node.right)
            if left and right:
                return node
            return left if left else right

        return dfs(root)

LC 235 — the tree is a BST. Ordering removes the need to look at both subtrees: while both targets sit on the same side of node.val, the LCA is on that side, so walk there.

python
# python
# LC 235 - Lowest Common Ancestor of a Binary Search Tree
# IDEA: walk DOWN while p, q are on the same side; the first split point is the LCA
# time = O(h), space = O(1)
class Solution(object):
    def lowestCommonAncestor(self, root, p, q):
        node = root
        while node:
            if p.val < node.val and q.val < node.val:
                node = node.left
            elif p.val > node.val and q.val > node.val:
                node = node.right
            else:
                # split point (or node IS p or q) -> LCA
                return node
        return None

LCA Variant — Smallest Subtree with All Deepest Nodes (LC 865 / LC 1123) Priority 4 of 5 — High value — a gap here costs you rounds

1. Core Idea

Key Insight: This is LCA in disguise. Instead of being given target nodes p and q, the targets are implicitly all nodes at the maximum depth.

text
Standard LCA (LC 236)                 Deepest Subtree LCA (LC 865)
-----------------------               --------------------------------
Targets p, q are GIVEN                Targets = nodes at max depth (discovered)
Find where p and q paths meet         Find where left/right deepest paths meet

The trick is that one post-order pass computes both things at once: you can’t know which nodes are deepest until you’ve walked the whole tree, but you also need the LCA of those nodes. So each recursive call returns a pair:

text
dfs(node) -> (depth, lca_candidate)
             ^^^^^  ^^^^^^^^^^^^^^^
             max depth      the answer for THIS subtree only
             below node

The depth half is just LC 104 (max depth). The lca_candidate half is carried upward alongside it, and gets re-decided at every node by comparing the two depths.

Three Cases (this comparison IS the whole algorithm):

Case Meaning Return
left.depth > right.depth all deepest nodes live on the left (left.depth + 1, left.node) — bubble left’s answer up
right.depth > left.depth all deepest nodes live on the right (right.depth + 1, right.node) — bubble right’s answer up
left.depth == right.depth deepest nodes exist on both sides (left.depth + 1, node) — current node becomes the LCA

Why case 3 is correct: if both sides bottom out at the same depth, the deepest set straddles the current node, so no child can contain all of them — the current node is the smallest subtree that does. And since the answer is overwritten on the way up whenever depths tie again, the final root call holds the lowest such node.

Why case 1/2 is correct: if one side is strictly deeper, the shallower side has no deepest nodes at all, so the answer is entirely inside the deeper subtree — pass it through untouched (do not replace it with the current node).

Base case: dfs(null) -> (0, None). Depth 0 for a null child makes a leaf return (1, leaf) via case 3 — a leaf is trivially the LCA of itself.

2. Pattern: Post-order DFS returning (metric, payload)

This is the generalized shape — a bottom-up aggregate carrying a candidate answer. Whenever a problem says “the smallest subtree such that …” or “the node where the extremes on both sides meet”, reach for this.

text
# pattern skeleton
def dfs(node):
    if not node:
        return (BASE_METRIC, None)

    l_metric, l_ans = dfs(node.left)      # post-order: children FIRST
    r_metric, r_ans = dfs(node.right)

    if l_metric > r_metric:               # one side dominates -> pass its answer up
        return (l_metric + 1, l_ans)
    if r_metric > l_metric:
        return (r_metric + 1, r_ans)
    return (l_metric + 1, node)           # tie -> current node is the meeting point

Pattern checklist:

  • Traversal: post-order (must know both children before deciding)
  • Return type: tuple / helper class — a scalar metric + a node reference
  • Decision: made by comparing the two children’s metrics, never by global state
  • No second pass: don’t compute max depth first and then re-scan; one pass is enough
  • time = O(N), space = O(H) — H = tree height (recursion stack)

Contrast with the “global variable” style (LC 543 / LC 124): those problems return only a scalar and stash the answer in a member field. Here we return the answer in the tuple because the answer must be selected on the way up, not maximized.

Common pitfalls:

  • ❌ Returning node in case 1/2 as well → you’d always get the root back
  • ❌ Using >= instead of == for the tie case → merges case 1 into case 3 wrongly
  • ❌ Two-pass (find max depth, then find LCA of all nodes at that depth) → works but O(N) extra space and much more code
java
// java
// LC 865 / LC 1123 — Smallest Subtree with All the Deepest Nodes
// IDEA: post-order DFS returning (lca candidate, depth below); equal depths -> this node
// Same as: LCA of the deepest leaves

// Helper class carries both the LCA candidate and its max depth below
class Result {
    TreeNode node;
    int dist;
    Result(TreeNode node, int dist) {
        this.node = node;
        this.dist = dist;
    }
}

/**
 * time = O(N)
 * space = O(H)  — recursion stack; O(log N) balanced, O(N) skewed
 */
public TreeNode subtreeWithAllDeepest(TreeNode root) {
    return dfs(root).node;
}

private Result dfs(TreeNode node) {
    if (node == null) {
        return new Result(null, 0);
    }

    Result left  = dfs(node.left);
    Result right = dfs(node.right);

    // Case 1: left subtree is deeper — LCA is buried there
    if (left.dist > right.dist) {
        return new Result(left.node, left.dist + 1);
    }

    // Case 2: right subtree is deeper — LCA is buried there
    if (right.dist > left.dist) {
        return new Result(right.node, right.dist + 1);
    }

    // Case 3: equal depth — current node is the LCA of all deepest nodes
    return new Result(node, left.dist + 1);
}
python
# python
# LC 865 / LC 1123 — Smallest Subtree with All the Deepest Nodes
# IDEA: post-order DFS returning (depth, lca_node)
# time = O(N), space = O(H)
class Solution(object):
    def subtreeWithAllDeepest(self, root):
        return self.helper(root)[1]

    def helper(self, node):
        # base case: null has depth 0 and no LCA
        if not node:
            return (0, None)

        # post-order — children resolved BEFORE the decision
        left_depth, left_node = self.helper(node.left)
        right_depth, right_node = self.helper(node.right)

        # case 1) left deeper -> all deepest nodes on left, keep left's answer
        if left_depth > right_depth:
            return (left_depth + 1, left_node)

        # case 2) right deeper -> all deepest nodes on right, keep right's answer
        if right_depth > left_depth:
            return (right_depth + 1, right_node)

        # case 3) SAME depth -> deepest nodes on both sides -> current node is LCA
        return (left_depth + 1, node)

Visualization:

text
        [3]          ← left.dist(3) == right.dist(2)? No → left wins
       /   \
     [5]   [1]       ← left.dist(2) == right.dist(1)? No → left wins
    /   \
  [6]  [2]           ← left.dist(0) == right.dist(1)? No → right wins
       /  \
      [7] [4]        ← both null, dist=0 → node [2] is LCA ✓

Pro Tip: Whenever a problem asks for the “smallest subtree containing [condition X]”, think Post-Order DFS + LCA logic.

3. Similar LC Problems

Identical problem (same code, different wording):

Problem LC # Note
Smallest Subtree with all the Deepest Nodes 865 this problem
Lowest Common Ancestor of Deepest Leaves 1123 literally the same — copy-paste the solution

Same pattern — post-order DFS returning (metric, node) / meeting-point logic:

Problem LC # Metric returned Key difference
Lowest Common Ancestor of a Binary Tree 236 node (found-or-null) targets p, q are given; tie case = both children non-null
LCA of a BST 235 — BST property lets you walk down in O(H), no post-order needed
LCA of a Binary Tree II 1644 (node, count) p/q may not exist → must also return a found-count
LCA of a Binary Tree III 1650 — parent pointers, no root → becomes “intersection of two linked lists”; see the variant above
LCA of a Binary Tree IV 1676 node N target nodes instead of 2
Find Distance in a Binary Tree 1740 depth find LCA first, then d(root,p) + d(root,q) - 2*d(root,lca)

Same “depth half” — the (depth, …) component alone:

Problem LC # What changes
Maximum Depth of Binary Tree 104 return only the depth — this problem minus the payload
Balanced Binary Tree 110 return depth + a bool; abs(l - r) > 1 short-circuits
Find Bottom Left Tree Value 513 deepest node again, but leftmost → BFS level-order is simpler
Find Leaves of Binary Tree 366 group nodes by height instead of picking one
Maximum Depth of N-ary Tree 559 loop over children instead of left/right

Same “combine left + right at each node” post-order shape (but global-variable style):

Problem LC # Combined value
Diameter of Binary Tree 543 left + right edges, answer kept in a member field
Binary Tree Maximum Path Sum 124 left + right + node.val, clamp negatives to 0
Longest Univalue Path 687 extend left/right only when child.val == node.val
Count Good Nodes in Binary Tree 1448 pre-order instead — info flows down, not up

Decision hint:

text
"smallest subtree containing X"      -> post-order (metric, node)   [LC 865]
"LCA of given nodes p, q"            -> post-order found-or-null     [LC 236]
"LCA but I have no root, only parent" -> walk up both chains (LC 160)  [LC 1650]
"longest/max path through any node"  -> post-order + global var      [LC 543, 124]
"deepest / leftmost / level info"    -> BFS level-order              [LC 513, 199]

Template 2: Parent Map + BFS — LC 863

Recognise — “all nodes at distance k from target”, “the leaf closest to node k”, “minutes until the infection reaches every node” — any answer that has to walk up to a parent as well as down. Nodes ≤ 500 (LC 863), ≤ 10⁵ (LC 2385) → O(N).

Key point — once every node knows its parent it has at most three neighbours (left, right, parent), so the tree is an undirected graph. BFS from target reaches nodes in order of edge distance, so after k rounds the queue holds exactly the nodes k edges away; visited is what stops a walk from going back the way it came.

python
# python
# LC 863 - All Nodes Distance K in Binary Tree
# IDEA: record every node's parent, then BFS outward from target over left / right / parent;
#       the queue after k rounds is the answer
# time = O(N), space = O(N)
from collections import deque

class Solution(object):
    def distanceK(self, root, target, k):
        parent = {root: None}                # node -> parent, keyed by node, not by value
        stack = [root]
        while stack:
            node = stack.pop()
            for child in (node.left, node.right):
                if child:
                    parent[child] = node
                    stack.append(child)

        visited = {target}
        queue = deque([target])
        dist = 0
        while queue and dist < k:            # one ring per round
            for _ in range(len(queue)):
                node = queue.popleft()
                for nxt in (node.left, node.right, parent[node]):
                    if nxt and nxt not in visited:
                        visited.add(nxt)
                        queue.append(nxt)
            dist += 1
        return [node.val for node in queue]  # empty if the tree ran out before k
java
// java
// LC 863 - All Nodes Distance K in Binary Tree
// IDEA: DFS builds node -> parent; BFS from target over left / right / parent, k rounds
// time = O(N), space = O(N)
public List<Integer> distanceK(TreeNode root, TreeNode target, int k) {
    Map<TreeNode, TreeNode> parent = new HashMap<>();
    buildParentMap(root, null, parent);

    Set<TreeNode> visited = new HashSet<>();
    Deque<TreeNode> queue = new ArrayDeque<>();
    visited.add(target);
    queue.offer(target);
    int dist = 0;
    while (!queue.isEmpty() && dist < k) {
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            TreeNode[] neighbours = {node.left, node.right, parent.get(node)};
            for (TreeNode next : neighbours) {
                if (next != null && visited.add(next)) {   // add() is false if already seen
                    queue.offer(next);
                }
            }
        }
        dist++;
    }
    List<Integer> res = new ArrayList<>();
    for (TreeNode node : queue) {
        res.add(node.val);
    }
    return res;
}

private void buildParentMap(TreeNode node, TreeNode par, Map<TreeNode, TreeNode> parent) {
    if (node == null) {
        return;
    }
    parent.put(node, par);                    // the root maps to null
    buildParentMap(node.left, node, parent);
    buildParentMap(node.right, node, parent);
}

Complexity

  • Time O(N): the parent-map pass visits each node once, and visited lets the BFS enqueue each node at most once.
  • Space O(N): the parent map and visited each hold every node; the queue holds one ring.

Dry run — root = [3,5,1,6,2,0,8,null,null,7,4], target = 5, k = 2

text
round 0   queue [5]
round 1   queue [6, 2, 3]     5's left, right and parent
round 2   queue [7, 4, 1]     6 adds nothing; 2 adds 7, 4; 3 adds 1 (5 is visited)
dist == k -> [7, 4, 1]

Traps

  • No visited set: from 5 the BFS climbs to 3 and comes straight back down to 5, so 5 is reported at distance 2 and the rings never stop growing.
  • Keying the map by node.val: correct only while values are unique; key it by the node.
  • The root’s parent is None: without the if nxt guard a None is enqueued and the next pop fails on None.left.
  • k = 0 must return [target.val], so the loop condition is dist < k, checked before a round.

Not this if — the distance is only downward from a node you already have (the root, or an LCA): no map is needed, use Template 3’s get_dist. Edges carry weights → it is a graph shortest-path problem, not this.

Drill — LC 863 · LC 742 (stop at the first leaf popped) · LC 2385 (count the rounds until the queue empties) · LC 1740 (a distance, but Template 3 needs no map)

Pattern Comparison: Standard Tree vs Move Parent

Aspect Standard Tree Traversal Move Parent Pattern
Direction Unidirectional (down only) Bidirectional (down + up)
Preprocessing None required Build parent map (O(N))
Space Complexity O(h) recursion stack O(N) parent map + visited
Visited Tracking Usually not needed Critical to prevent cycles
Traversal Method DFS recursive DFS (build) + BFS (explore)
Use Cases Standard tree problems Distance, path, multi-directional
Graph Conversion Tree remains tree Tree → Undirected graph

Template 3: Distance Between Nodes via LCA — LC 1740

Recognise — “the number of edges on the path between the nodes with values p and q”, a root is given, values are unique. Nodes ≤ 10⁴ → O(N).

Key point — every p … q path passes through their LCA, so dist(p, q) = dist(lca, p) + dist(lca, q). get_dist carries depth down as an argument and returns it unchanged on a hit; -1, never 0, means “not in this subtree”, because 0 is a real distance (the target is the start node).

python
# python
# LC 1740 - Find Distance in a Binary Tree
# IDEA: find the split point (LCA), then measure each target's depth below it;
#       get_dist is pre-order, carrying depth down, -1 = "not in this branch"
# time = O(N), space = O(H)
class Solution(object):
    def findDistance(self, root, p, q):
        if p == q:
            return 0
        lca = self.get_lca(root, p, q)
        return self.get_dist(lca, p, 0) + self.get_dist(lca, q, 0)

    def get_lca(self, node, p, q):
        if not node or node.val == p or node.val == q:   # p, q are VALUES here
            return node
        left = self.get_lca(node.left, p, q)
        right = self.get_lca(node.right, p, q)
        if left and right:
            return node
        return left if left else right

    def get_dist(self, node, target, depth):
        if not node:
            return -1                        # dead end: the target is not on this path
        if node.val == target:
            return depth                     # edges from the start node to here
        left = self.get_dist(node.left, target, depth + 1)
        if left != -1:
            return left                      # found on the left: skip the right subtree
        return self.get_dist(node.right, target, depth + 1)
java
// java
// LC 1740 - Find Distance in a Binary Tree
// IDEA: LCA by value, then depth of p and of q below it; -1 means "not in this subtree"
// time = O(N), space = O(H)
public int findDistance(TreeNode root, int p, int q) {
    if (p == q) {
        return 0;
    }
    TreeNode lca = getLca(root, p, q);
    return getDist(lca, p, 0) + getDist(lca, q, 0);
}

private TreeNode getLca(TreeNode node, int p, int q) {
    if (node == null || node.val == p || node.val == q) {
        return node;
    }
    TreeNode left = getLca(node.left, p, q);
    TreeNode right = getLca(node.right, p, q);
    if (left != null && right != null) {
        return node;
    }
    return left != null ? left : right;
}

private int getDist(TreeNode node, int target, int depth) {
    if (node == null) {
        return -1;
    }
    if (node.val == target) {
        return depth;
    }
    int left = getDist(node.left, target, depth + 1);
    if (left != -1) {
        return left;                          // short-circuit: the right side is never walked
    }
    return getDist(node.right, target, depth + 1);
}

Complexity

  • Time O(N): get_lca is one pass over the tree, and each get_dist walks at most the LCA’s subtree once.
  • Space O(H): recursion depth only; nothing is materialised.

Dry run — root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 0 (traced call by call in the visualization below)

text
get_lca          -> 3     5 is on the left, 0 on the right
get_dist(3, 5)   -> 1     3 -> 5
get_dist(3, 0)   -> 2     the left subtree returns -1; then 3 -> 1 -> 0
answer           1 + 2 = 3

Traps

  • Returning 0 for None: “not found” and “found at the start node” look the same, and a dead-end left subtree reports a distance. p = 5, q = 0 no longer returns 3.
  • Measuring from the root instead of the LCA: p = 6, q = 4 gives 2 + 3 = 5; the real path 6 - 5 - 2 - 4 is 3 edges, measured from LCA 5.
  • Adding + 1 on the way back up in the depth-carrying version counts every edge twice; the depth was already incremented going down.
  • p == q: the answer is 0. Return it before searching rather than relying on the early return inside get_lca to make both distances 0.

Not this if — you need every node k away, in any direction → Template 2. You hold the two nodes and they have parent pointers → no search at all, walk up (LC 1650 variant).

Drill — LC 1740 · LC 236 (the LCA half on its own) · LC 2096 (the path as U/L/R steps: LCA, then two root paths) · LC 863 (distance in every direction)

get_dist — why -1, and why the sum is always right

Why -1 and not 0 for “not found”?

Return value Meaning
0 VALID — target found exactly at the current node (0 edges away)
dist > 0 VALID — target found dist edges below the start node
-1 SENTINEL — target is NOT in this branch (dead end)

If we returned 0 for “not found”, we could not distinguish “found here, distance 0” from “not found”. So 0 is reserved as a real distance and -1 is the only safe “not found” flag.

How the valid distance bubbles up (pre-order DFS):

  1. Hit None → return -1 (this path is a dead end).
  2. Hit the target → return the accumulated dist (an edge count ≥ 0).
  3. Otherwise recurse left / right with dist + 1.
    • left != -1 → target lives in the left subtree, forward that distance up immediately (short-circuit, skip right).
    • else return right (either the right subtree’s valid distance, or -1 if both sides failed).

Why dist_p + dist_q is always correct — starting from the LCA there are only 2 shapes:

text
case 1: p and q are in different subtrees      case 2: one target IS the LCA
                                                        (ancestor of the other)
          LCA                                        p (= LCA)
         /   \                                          \
        p     q                                          ...
                                                           q
   dist = dist_p + dist_q                        dist_p = 0, so dist = dist_q

In both cases get_dist(lca, p) + get_dist(lca, q) gives the exact edge count on the path p … q.

Visualization — how get_dist actually walks the tree

Tree from the LC 1740 example: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 0 → answer 3.

text
                      3          <- depth 0  (this is also the LCA of 5 and 0)
                   /     \
                  5       1      <- depth 1
                /   \    /  \
               6     2  0    8   <- depth 2
                    / \
                   7   4         <- depth 3

Step 1 — get_lca(root, 5, 0) → node 3 (5 is in the left subtree, 0 is in the right → they split at 3).

Step 2 — get_dist(3, target=5, depth=0)

text
get_dist(3, 5, 0)                 3 != 5  -> recurse left with depth+1
└── get_dist(5, 5, 1)             5 == 5  -> RETURN 1  ✅
        (right subtree never visited — short-circuited by `if left != -1`)

=> dist_p = 1

Step 3 — get_dist(3, target=0, depth=0) — the interesting one, because the left half is a dead end:

text
get_dist(3, 0, 0)                       3 != 0
│
├── get_dist(5, 0, 1)                   5 != 0
│   ├── get_dist(6, 0, 2)               6 != 0
│   │   ├── get_dist(None, 0, 3) -> -1      ❌ dead end
│   │   └── get_dist(None, 0, 3) -> -1      ❌ dead end
│   │   RETURN -1                            ❌ bubbles up
│   └── get_dist(2, 0, 2)               2 != 0
│       ├── get_dist(7, 0, 3) -> -1 (both children None)   ❌
│       └── get_dist(4, 0, 3) -> -1 (both children None)   ❌
│       RETURN -1                            ❌
│   RETURN -1   <- whole LEFT subtree of 3 says "not here"
│
│   (left == -1, so we DO NOT short-circuit — we must try right)
│
└── get_dist(1, 0, 1)                   1 != 0
    └── get_dist(0, 0, 2)               0 == 0  -> RETURN 2  ✅
    RETURN 2  <- passed up UNCHANGED (no `+1`!)
RETURN 2

=> dist_q = 2

Answer: dist_p + dist_q = 1 + 2 = 3 ✅ (path 5 - 3 - 1 - 0, 3 edges)

The two things to notice in that trace:

text
1) depth grows going DOWN      2) the found value flows UP untouched
   (as an argument)               (no accumulation on the way back)

     get_dist(.., depth=0)          RETURN 2  ▲
            │  depth+1                        │  same 2
            ▼                        RETURN 2 ▲
     get_dist(.., depth=1)                    │  same 2
            │  depth+1              RETURN 2  ▲
            ▼                                 │
     get_dist(.., depth=2)  ==  target  ->  emit `depth` (= 2)

Because the target value is unique, at most one branch can return a non--1 value — so there is nothing to max() or add on the way up. That is the key difference from height/depth problems:

Direction of the number Combine step
get_height (LC 104) computed bottom-up 1 + max(left, right)
get_dist (this pattern) carried top-down, echoed back up none — just forward the non--1 value

Sentinel propagation cheat-sheet:

text
            left      right     ->  return        meaning
            ----      -----         ------        -------
            -1        -1        ->  -1            target in NEITHER subtree
            d≥0       (skipped) ->  d             found left (short-circuit)
            -1        d≥0       ->  d             found right

Variants of get_dist — where the counter lives Priority 5 of 5 — Must know — expect it in almost every loop

All of these return the same edge count (and -1 when the target isn’t in the subtree). They differ only in where the counter lives. The canonical top-down version is Template 3’s get_dist; only the two variants that are not merely a re-spelling of it are written out below.

# Variant Signature Counter lives Short-circuits? Notes
V1 Top-down + explicit guard (node, target, depth) passed down as arg ✅ yes (if left != -1) Template 3’s version — most explicit
V2 Top-down + max() trick (node, target, depth) passed down as arg ❌ no V1 with return max(left, right) in place of the != -1 guard: the target is unique, so at most one side returns >= 0 and max bubbles it up (and yields -1 when both fail). Shortest code, but it always scans both subtrees — strictly more work than V1, so prefer V1
V3 Bottom-up (+1 on return) (node, target) built up on the way back ✅ yes no depth param needed — written out below
V4 Iterative BFS (root, target) stored in the queue ✅ yes (early return) no recursion → no stack-overflow risk — written out below
V3 — Bottom-up DFS (no depth parameter)

Key Idea: instead of carrying a counter down, start at 0 when you hit the target and add 1 per edge on the way up. The function then only needs two arguments.

python
# python
# IDEA: bottom-up -- return 0 at the target, add 1 per edge on the way back up
# time = O(N), space = O(H)
def get_dist(self, node, target):
    if not node:
        return -1
    if node.val == target:
        return 0                 # distance to itself is 0

    _left  = self.get_dist(node.left,  target)
    _right = self.get_dist(node.right, target)

    # If found on the left, add 1 for the current edge and return it
    if _left >= 0:
        return _left + 1

    # If found on the right, add 1 for the current edge and return it
    if _right >= 0:
        return _right + 1

    return -1

Direction contrast (V1 vs V3) on get_dist(3, target=0):

text
        V1 (top-down)                      V3 (bottom-up)
        depth flows DOWN                   +1 flows UP

   3   get_dist(3, .., depth=0)       3   return 1 + 1 = 2   ▲
   |            │                     |                      │  +1
   1   get_dist(1, .., depth=1)       1   return 0 + 1 = 1   ▲
   |            │                     |                      │  +1
   0   match -> return depth = 2      0   match -> return 0  ▲

   answer emitted at the BOTTOM       answer assembled on the WAY BACK

⚠️ Pitfall: never write 1 + max(_left, _right) here — if both are -1 that yields 0, which falsely reports “found at this node”. Always gate the +1 behind a >= 0 check (or, like the LeetCode-CA version, check if left == right == -1: return -1 before the 1 + max(...)).

V4 — Iterative BFS (level-order, no recursion)

Key Idea: push (node, dist) pairs; the level a node sits on is its distance from the start. Useful when the tree is very deep and recursion would risk a stack overflow.

python
# python
# IDEA: BFS carrying (node, dist); the level a node is popped on is its distance
# time = O(N), space = O(W)  -- W = max tree width
from collections import deque

def get_dist(self, root, target):
    if not root:
        return -1

    q = deque([(root, 0)])   # [node, current_distance]

    while q:
        node, dist = q.popleft()

        if node.val == target:
            return dist      # first hit IS the answer (level == distance)

        if node.left:
            q.append((node.left, dist + 1))
        if node.right:
            q.append((node.right, dist + 1))

    return -1                # scanned everything, target not below `root`

Queue trace — get_dist(3, target=0) on the example tree:

text
                  3(0)
                /      \
             5(1)      1(1)
            /   \     /   \
          6(2) 2(2) 0(2)  8(2)      <- (n) = dist stored alongside the node

 pop        queue after push                        check
 ----       -------------------                     -----
 (3,0)      [(5,1), (1,1)]                          3 != 0
 (5,1)      [(1,1), (6,2), (2,2)]                   5 != 0
 (1,1)      [(6,2), (2,2), (0,2), (8,2)]            1 != 0
 (6,2)      [(2,2), (0,2), (8,2)]                   6 != 0
 (2,2)      [(0,2), (8,2), (7,3), (4,3)]            2 != 0
 (0,2)      -                                       0 == 0  -> RETURN 2  ✅

⚠️ Note: only None children are pushed-guarded here, so no -1 sentinel is needed inside the loop — the sentinel is the single return -1 after the queue drains.

Which one to use?
  • Interview / clarity → V1 (explicit sentinel handling is the thing interviewers want to see you reason about)
  • Code golf / clean one-liner → V2 (state the lost short-circuit out loud)
  • Fewer params, want the “returns 0 at target” convention → V3 (the convention a bottom-up getDistance uses)
  • Very deep / skewed tree, recursion depth is a real risk → V4

All are O(N) time. Space: O(H) for V1–V3 (recursion stack, H = N worst case for a skewed tree), O(W) for V4 (queue width).

Common mistakes:

  • ❌ returning 0 instead of -1 for None → cannot distinguish “found at this node” from “not found”
  • ❌ forgetting the if left != -1: return left guard → returns the right subtree’s -1 and loses the found distance
  • ❌ adding +1 in the depth-passing version → double counts (depth is already incremented on the way down)
  • ❌ calling get_dist from root instead of from the lca → measures the wrong path

Where this pattern shows up again:

Problem LC # How get_dist is used
Find Distance in a Binary Tree 1740 base pattern — get_lca + get_dist × 2
All Nodes Distance K 863 distance downward from target; upward handled by the parent map (Template 2)
Maximum Depth 104 same DFS shape, but bottom-up 1 + max(...) instead of a sentinel
Path Sum 112 identical top-down accumulation, carrying remaining_sum instead of depth
Smallest Subtree w/ Deepest Nodes 865/1123 LCA + depth combined into one (depth, node) return

Ref: leetcode_python/Tree/find-distance-in-a-binary-tree.py

Pattern Recognition — is this a get_dist problem?

Pattern Recognition:

  • ✅ Need an edge count, not a node count (a node at depth 0 is 0 edges away)
  • ✅ The target is unique in the tree (values are distinct)
  • ✅ Distance is measured downward from a known start node (root or LCA)
  • ❌ If both nodes may be in unrelated subtrees → find the LCA first (Template 1)
  • ❌ If you need distance in all directions (including upward) → use Template 2, the parent map

Template 4: Root-to-Leaf Path with Backtracking — LC 113

Recognise — “all root-to-leaf paths where the sum of the values equals targetSum”, “return every path”, “a leaf is a node with no children”. Nodes ≤ 5,000 → one O(N) walk, plus the cost of copying the paths you keep.

Key point — path always holds exactly the nodes from the root to the current node: a node is appended on entry and popped on exit, so the leaf check sees the whole root-to-leaf path and a sibling subtree never sees the other’s nodes.

python
# python
# LC 113 - Path Sum II
# IDEA: pre-order DFS; push the node, record a COPY at a matching leaf, pop on the way back
# time = O(N * H), space = O(H)   (output not counted; each kept path costs an O(H) copy)
class Solution(object):
    def pathSum(self, root, targetSum):
        result = []
        path = []

        def dfs(node, remaining):
            if not node:
                return
            path.append(node.val)
            remaining -= node.val
            if not node.left and not node.right and remaining == 0:
                result.append(list(path))    # a copy: `path` keeps changing
            else:
                dfs(node.left, remaining)
                dfs(node.right, remaining)
            path.pop()                       # backtrack: the parent's path again

        dfs(root, targetSum)
        return result
java
// java
// LC 113 - Path Sum II
// IDEA: pre-order DFS with one shared list; add on entry, copy at a matching leaf, remove on exit
// time = O(N * H), space = O(H)
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
    List<List<Integer>> result = new ArrayList<>();
    dfs(root, targetSum, new ArrayList<>(), result);
    return result;
}

private void dfs(TreeNode node, int remaining, List<Integer> path, List<List<Integer>> result) {
    if (node == null) {
        return;
    }
    path.add(node.val);
    remaining -= node.val;
    if (node.left == null && node.right == null && remaining == 0) {
        result.add(new ArrayList<>(path));    // save a COPY
    } else {
        dfs(node.left, remaining, path, result);
        dfs(node.right, remaining, path, result);
    }
    path.remove(path.size() - 1);             // backtrack
}

Complexity

  • Time O(N · H): the DFS visits each node once (O(N)); each matching leaf copies a path of at most H values. On a balanced tree that is O(N log N); a skewed one has a single leaf.
  • Space O(H): the recursion stack and path both grow to the depth; the output is not counted.

Dry run — root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22

text
path [5, 4, 11, 7]    remaining -5 at leaf 7     no
pop 7, push 2         [5, 4, 11, 2]  remaining 0 at a leaf  -> record
pop 2, 11, 4          [5]
...                   [5, 8, 4, 5]   remaining 0 at a leaf  -> record
answer [[5, 4, 11, 2], [5, 8, 4, 5]]

Traps

  • result.append(path) without a copy: every entry is the same list, empty once the walk ends — the example returns [[], []].
  • Testing the sum at None instead of at a leaf: a node with one child counts as a path end, so LC 112’s root = [1,2], targetSum = 1 returns true; 1 is not a leaf.
  • Dropping the pop: the right subtree inherits the left’s nodes — [1,2,3], targetSum = 4 records [1, 2, 3].
  • Stopping early when remaining < 0: values may be negative, so a later node can bring it back.

Not this if — the path may start and end anywhere on a downward line → Template 5. The path may bend through a node (LC 124, LC 543) → post-order height plus a global max, see LC 124 card. Only a yes / no is asked (LC 112) → no list at all, return left or right.

Drill — LC 113 · LC 112 (yes / no, no path list) · LC 257 (the path as a string) · LC 129 (carry a number down instead of a list) · LC 988 (compare the reversed paths)

Path Update Strategies: Immutable String vs. Mutable List + Backtrack

Two ways to track path state during DFS. Choosing the right one simplifies code significantly.

Strategy 1: Immutable String — pass the updated path in the DFS call (no backtrack needed)

When you pass path + "->" + node.val directly as an argument, each recursive call gets its own copy of the string. The parent’s path is never modified, so no explicit backtracking is needed.

python
# python
# LC 257 - Binary Tree Paths
# IDEA: immutable string path -- the update happens in the call, so nothing to undo
# time = O(N * H), space = O(H^2): each live frame holds its own prefix string (output not counted)
def binaryTreePaths(root):
    res = []

    def dfs(node, path):
        if not node.left and not node.right:
            res.append(path)
            return
        if node.left:
            dfs(node.left, path + "->" + str(node.left.val))    # a new string
        if node.right:
            dfs(node.right, path + "->" + str(node.right.val))

    if root:
        dfs(root, str(root.val))
    return res
java
// java
// LC 257 - Binary Tree Paths
// IDEA: String path, updated inside the DFS call -> each frame keeps its own copy, no backtrack
// time = O(N * H), space = O(H^2): each live frame holds its own prefix string (output not counted)
public List<String> binaryTreePaths(TreeNode root) {
    List<String> res = new ArrayList<>();
    if (root == null) {
        return res;
    }
    dfs(root, String.valueOf(root.val), res);
    return res;
}

private void dfs(TreeNode node, String path, List<String> res) {
    if (node.left == null && node.right == null) {
        res.add(path);                        // the path is complete
        return;
    }
    if (node.left != null) {
        dfs(node.left, path + "->" + node.left.val, res);    // `path` here is unchanged
    }
    if (node.right != null) {
        dfs(node.right, path + "->" + node.right.val, res);
    }
}

Strategy 2: Mutable List — modify in place, then backtrack

When the path is a mutable structure (a list, a List<Integer>, a StringBuilder), the same object is shared by every call, so each change must be undone when the call returns. That is Template 4’s code above: path.append / path.add before the recursion, pop / remove after.

Comparison:

Aspect Immutable String Mutable List + Backtrack
Path update location Inside DFS call argument Before DFS call
Backtrack needed? No (each call gets own copy) Yes (must undo mutation)
Memory O(N) new strings per path O(N) shared, reused list
Best for String paths (LC 257) Numeric paths (LC 113, 112)
Bug risk Low (no shared state) Medium (forget to backtrack)

Rule of thumb:

  • Immutable (String, int) → pass updated value in the call → no backtrack
  • Mutable (List, StringBuilder) → modify before call → backtrack after call

Template 5: Downward Path Count with a Prefix-Sum Map — LC 437

Recognise — “the number of paths whose values sum to targetSum; the path does not need to start at the root or end at a leaf, but it must go downward”. Nodes ≤ 1,000 and values may be negative, so a shrinking window cannot work → O(N) with a map.

Key point — prefix counts the root-to-node sums of the nodes on the current root path only. A downward path ending at this node sums to target exactly when cur - target is one of those sums — LC 560 on a tree — and the count is undone on the way back up, so a sibling branch never pairs with this one.

python
# python
# LC 437 - Path Sum III
# IDEA: pre-order DFS carrying the root-to-node sum; count earlier prefixes equal to cur - target,
#       add cur for the children, remove it on the way back
# time = O(N), space = O(N)
from collections import defaultdict

class Solution(object):
    def pathSum(self, root, targetSum):
        prefix = defaultdict(int)
        prefix[0] = 1                        # the empty prefix: paths that start at the root

        def dfs(node, cur):
            if not node:
                return 0
            cur += node.val
            count = prefix[cur - targetSum]  # downward paths that end here
            prefix[cur] += 1                 # visible to my descendants only
            count += dfs(node.left, cur) + dfs(node.right, cur)
            prefix[cur] -= 1                 # leave this branch
            return count

        return dfs(root, 0)
java
// java
// LC 437 - Path Sum III
// IDEA: 2-sum on the root path: curSum - targetSum seen before -> a sub-path ends here
// time = O(N), space = O(N)
private final Map<Long, Integer> prefix = new HashMap<>();

public int pathSum(TreeNode root, int targetSum) {
    prefix.put(0L, 1);                        // the empty path has sum 0
    return dfs(root, 0L, targetSum);
}

private int dfs(TreeNode node, long cur, int targetSum) {
    if (node == null) {
        return 0;
    }
    cur += node.val;                          // long: the sums overflow int
    int count = prefix.getOrDefault(cur - targetSum, 0);
    prefix.merge(cur, 1, Integer::sum);
    count += dfs(node.left, cur, targetSum) + dfs(node.right, cur, targetSum);
    prefix.merge(cur, -1, Integer::sum);      // backtrack
    return count;
}

Complexity

  • Time O(N): each node is visited once and does O(1) expected map work.
  • Space O(N): the map keeps a key per distinct prefix seen (only the current path’s counts are non-zero), plus O(H) recursion.

Dry run — the chain 1 → 2 → 3, targetSum = 3

text
node 1   cur 1   look up -2 -> 0              prefix {0:1, 1:1}
node 2   cur 3   look up  0 -> 1  (1 -> 2)    prefix {0:1, 1:1, 3:1}
node 3   cur 6   look up  3 -> 1  (3 alone)   answer 2

Traps

  • Missing prefix[0] = 1: paths that start at the root are never counted — the chain above returns 1.
  • Not decrementing on the way back: a prefix from the left subtree pairs with a node on the right, counting a path that turns at the parent. root = [0,1,3], targetSum = 2 returns 1, not 0.
  • Inserting cur before the lookup: with targetSum = 0 every node then counts itself as an empty path.
  • Java int sums: LC 437 values reach ±10⁹, so the running sum overflows; key the map by long.

Not this if — the path must start at the root and end at a leaf → Template 4, no map needed. The input is an array, not a tree → LC 560’s prefix sum, with nothing to undo. The longer write-up of this tree version, with both spellings of the base case, is prefix_sum_advanced.md Template 14.

Drill — LC 437 · LC 112 (root-to-leaf only: no map) · LC 560 (the same trick on an array) · LC 1171 (zero-sum runs in a linked list: the same prefix map)

Key differences vs. root-to-leaf backtracking:

Pattern Path constraint Data structure Backtrack what?
DFS + path list + backtrack Root → leaf only List<Integer> path Remove last element
DFS + prefix sum + backtrack Any node → any node ↓ Map<Long, Integer> Decrement map count

LC Examples

Problem cards for the path problems that are not a template’s anchor. LC 543 (the diameter) is binary_tree.md § Template 5’s anchor and is worked there; LC 124 and LC 687 below are that template with a different arm.

Path Sum — LC 112

Key point — the question is yes / no, so nothing has to be remembered on the way down except the sum still owed: pass targetSum - node.val to the children, and only a leaf may settle it.

Reduces to: Template 4 — with no path list to copy or undo, and left or right instead of collecting results.

python
# python
# LC 112 - Path Sum
# IDEA: pre-order DFS carrying the remaining sum; a leaf answers, an inner node ORs its children
# time = O(N), space = O(H)
def hasPathSum(root, targetSum):
    if not root:
        return False
    if not root.left and not root.right:          # only a leaf can end the path
        return root.val == targetSum
    remaining = targetSum - root.val
    return hasPathSum(root.left, remaining) or hasPathSum(root.right, remaining)
java
// java
// LC 112 - Path Sum
// IDEA: carry the remaining sum down; a leaf compares, an inner node ORs its children
// time = O(N), space = O(H)
public boolean hasPathSum(TreeNode root, int targetSum) {
    if (root == null) {
        return false;
    }
    if (root.left == null && root.right == null) {
        return root.val == targetSum;
    }
    int remaining = targetSum - root.val;
    return hasPathSum(root.left, remaining) || hasPathSum(root.right, remaining);
}

Traps

  • Answering at None instead of at a leaf: root = [1,2], targetSum = 1 returns true, but node 1 has a child, so it is not a leaf.
  • An empty tree with targetSum = 0 is false: there is no path at all.

Sum Root to Leaf Numbers — LC 129

Key point — the number spelled by the path so far is the state carried down: number * 10 + node.val. It is an int, so each call gets its own copy and there is nothing to undo; a leaf returns its number and every inner node returns the sum of its children’s.

Reduces to: Template 4 — carrying a number instead of a list, which is the immutable-state case of the path update strategies above.

python
# python
# LC 129 - Sum Root to Leaf Numbers
# IDEA: pre-order DFS carrying the number built so far; a leaf returns it, a parent adds both sides
# time = O(N), space = O(H)
def sumNumbers(root):
    def dfs(node, number):
        if not node:
            return 0
        number = number * 10 + node.val           # append this digit
        if not node.left and not node.right:
            return number
        return dfs(node.left, number) + dfs(node.right, number)

    return dfs(root, 0)

Traps

  • Returning number at None counts every half-leaf twice: [1,2,3] stays 25 only if the number is returned at a leaf and None returns 0.

Binary Tree Maximum Path Sum — LC 124

Key point — the same return-one-arm, record-the-bend shape as the diameter, with sums instead of heights. Values can be negative, so an arm that would lower the sum is clamped to 0 (left out), and the global starts at -inf because the best path may be a single negative node.

Reduces to: binary_tree § Template 5 — with max(0, arm) on each side and node.val added at every node.

python
# python
# LC 124 - Binary Tree Maximum Path Sum
# IDEA: return the best downward arm starting at me; record the path that bends at me in a global
# time = O(N), space = O(H)
def maxPathSum(root):
    best = float("-inf")

    def gain(node):
        nonlocal best
        if not node:
            return 0
        left = max(0, gain(node.left))            # a negative arm is dropped, not taken
        right = max(0, gain(node.right))
        best = max(best, node.val + left + right) # the path that bends at node
        return node.val + max(left, right)        # only one arm continues upward

    gain(root)
    return best
java
// java
// LC 124 - Binary Tree Maximum Path Sum
// IDEA: return the best arm, clamped at 0; record node + left + right in a field
// time = O(N), space = O(H)
private int best;

public int maxPathSum(TreeNode root) {
    best = Integer.MIN_VALUE;
    gain(root);
    return best;
}

private int gain(TreeNode node) {
    if (node == null) {
        return 0;
    }
    int left = Math.max(0, gain(node.left));
    int right = Math.max(0, gain(node.right));
    best = Math.max(best, node.val + left + right);
    return node.val + Math.max(left, right);
}

Dry run — root = [-10,9,20,null,null,15,7]

text
gain(9)    = 9                    best 9
gain(15)   = 15                   best 15
gain(7)    = 7                    best 15
gain(20)   = 20 + max(15, 7) = 35 best max(15, 20 + 15 + 7) = 42
gain(-10)  = -10 + 35 = 25        best max(42, -10 + 9 + 35) = 42   answer 42

Traps

  • Starting best at 0: root = [-3] returns 0, but the path must contain at least one node, so the answer is -3.
  • Returning node.val + left + right: the parent would extend a path that already bends.
  • Clamping the global instead of the arms: max(0, …) belongs on each arm only.

Longest Univalue Path — LC 687

Key point — an arm only continues through an edge whose two ends hold the same value. So each child’s arm is extended by one when child.val == node.val and reset to 0 otherwise, before the bend left + right is recorded. The answer counts edges.

Reduces to: binary_tree § Template 5 — with an arm that is zeroed across an edge whose values differ.

python
# python
# LC 687 - Longest Univalue Path
# IDEA: return the longest same-value arm going down from me (in edges); record left + right
# time = O(N), space = O(H)
def longestUnivaluePath(root):
    best = 0

    def arm(node):
        nonlocal best
        if not node:
            return 0
        left = arm(node.left)
        right = arm(node.right)
        left = left + 1 if node.left and node.left.val == node.val else 0
        right = right + 1 if node.right and node.right.val == node.val else 0
        best = max(best, left + right)
        return max(left, right)

    arm(root)
    return best

Traps

  • Skipping the recursion into a child whose value differs: that child’s subtree can still hold the answer. [1,4,5,4,4,null,5] is 2 from the 4-4-4 under the root, whose value is 1.
  • Counting nodes instead of edges: a single node is 0, not 1.

Closest Leaf in a Binary Tree — LC 742

Key point — the nearest leaf can be above node k (through a parent and down another branch), so the tree is turned into an undirected graph first. Then a BFS from k reaches nodes in order of distance, and the first leaf it pops is the answer.

Reduces to: Template 2 — the same parent edges and BFS, stopping at the first node with no children instead of after k rounds.

python
# python
# LC 742 - Closest Leaf in a Binary Tree
# IDEA: one DFS adds every child <-> parent edge and finds node k; BFS from k; first leaf popped wins
# time = O(N), space = O(N)
from collections import defaultdict, deque

def findClosestLeaf(root, k):
    graph = defaultdict(list)
    start = None
    stack = [(root, None)]
    while stack:                                  # build the undirected graph
        node, parent = stack.pop()
        if node.val == k:
            start = node
        if parent:
            graph[node].append(parent)
            graph[parent].append(node)
        for child in (node.left, node.right):
            if child:
                stack.append((child, node))

    queue = deque([start])                        # BFS from k, not from the root
    seen = {start}
    while queue:
        node = queue.popleft()
        if not node.left and not node.right:      # a leaf in the ORIGINAL tree
            return node.val
        for neighbour in graph[node]:
            if neighbour not in seen:
                seen.add(neighbour)
                queue.append(neighbour)

Traps

  • Starting the BFS at root: it finds the leaf closest to the root. [1,2,3,4,null,null,null,5,null,6], k = 2 must return 3 (two edges up and over), not the leaf under 2’s chain.
  • Testing “leaf” by graph degree: the root of a one-child tree has degree 1 but is not a leaf. Use the original left / right.
  • Rebuilding graph after the DFS has filled it leaves every node with no neighbours.

Similar — LC 863 (every node at distance k) · LC 2385 (minutes to infect the tree: BFS rounds from a start node)

Pattern Selection Strategy

Step 2 — Apply the pattern:

text
Root-to-leaf path problem?
  → Pre-order DFS + backtracking
  → Pattern: add node → check leaf → recurse → remove node (backtrack)

Path sum from ANY node to ANY node (downward)?
  → Pre-order DFS + prefix sum HashMap (2-sum trick)
  → Pattern: map.put(0,1) → curSum += val → check (curSum-target) in map
             → add to map → recurse → backtrack (decrement map)

Subtree computation (bottom-up)?
  → Post-order DFS
  → Pattern: recurse left, recurse right → combine at current node

Identify or compare subtrees by structure?
  → Post-order DFS + serialize "val,left,right" + HashMap
  → Pattern: serialize(left) + serialize(right) → build key "val,L,R"
             → map.getOrDefault(key,0) == 1 → duplicate! → add to result
             → map.put(key, count+1) → return key to parent

BST / sorted property?
  → In-order DFS
  → Pattern: recurse left → process node → recurse right

Drill Path

Stage Problems (easy → hard) What it adds
Core ⭐ LC 543 ok ⭐ · LC 235 ok ⭐ · LC 236 ok · LC 124 again ⭐ the longest bent path, LCA on a BST and on a plain tree, the bent path with a value sum
Practice LC 112 ok · LC 113 ok · LC 1650 ok · LC 863 ok · LC 1740 ok · LC 437 again Templates 4, 1 (no root), 2, 3 and 5, once each
Stretch LC 1644 again · LC 865 again · LC 2096 again · LC 2385 no verdict absent targets, implicit targets, the path spelled as moves, BFS rounds over a parent map

Summary

You are asked for… Reach for Section
the meeting point of two nodes post-order LCA Template 1
the deepest-nodes subtree post-order returning (depth, node) LC 865 variant
nodes k steps away in any direction parent map + BFS Template 2
edges between two arbitrary nodes LCA + get_dist twice Template 3
all root-to-leaf paths matching a rule pre-order DFS + backtracking Template 4
the count of any-to-any downward paths pre-order DFS + prefix-sum HashMap Template 5
the longest path through any node post-order height + global max LC 124 card

The three mistakes that cost the most here:

  • returning 0 instead of -1 for “not found” — 0 is a valid distance
  • forgetting the visited set once parent edges exist — the BFS loops forever
  • calling get_dist from root instead of from the lca — measures the wrong path