Tree LCA, Distance & Path Problems
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(not0) for “not found”, because0is 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
# 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
// 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 rootonly 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]
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
rootwhenever either side is non-null:p = 6, q = 4makesdfs(3)return3instead of passing up the5it got from the left. - The early return assumes both targets exist. With
qabsent (LC 1644),p = 5, q = 99still returns5; count the hits instead — the LC 1644 code below. - Comparing
root.val == p.valinstead 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
pandqfrom a root? If yes → recurse down (236 / 1644 / 1676). If I am standing onpandqwithparentpointers → 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.
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
# 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
qvisits 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, notnode.val—valonly 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
# 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
// 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):
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 thenullstep. Consumingnullis 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.valinstead ofa != b— fine here, but it is the identity of the node you want, and it breaks the moment values repeat. - ❌ Assuming
pandqshare a root. If they sit in different trees both pointers land onnullon the same step,a == b == null, and the loop exits returningnull— the two-pointer form terminates for free, the naive lock-step one does not. - ❌ Reaching for the post-order template. There is no
rootparameter 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
# 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
# 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
# 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.
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:
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.
# 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
nodein 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
// 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
# 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:
[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:
"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
# 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
// 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
visitedlets the BFS enqueue each node at most once. - Space O(N): the parent map and
visitedeach 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
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
visitedset: from5the BFS climbs to3and comes straight back down to5, so5is 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 theif nxtguard aNoneis enqueued and the next pop fails onNone.left. k = 0must return[target.val], so the loop condition isdist < 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
# 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
// 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_lcais one pass over the tree, and eachget_distwalks 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)
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
0forNone: “not found” and “found at the start node” look the same, and a dead-end left subtree reports a distance.p = 5, q = 0no longer returns 3. - Measuring from the root instead of the LCA:
p = 6, q = 4gives2 + 3 = 5; the real path6 - 5 - 2 - 4is 3 edges, measured from LCA5. - Adding
+ 1on the way back up in the depth-carrying version counts every edge twice; the depth was already incremented going down. p == q: the answer is0. Return it before searching rather than relying on the early return insideget_lcato make both distances0.
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):
- Hit
None→ return-1(this path is a dead end). - Hit the target → return the accumulated
dist(an edge count ≥ 0). - Otherwise recurse
left/rightwithdist + 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-1if both sides failed).
Why dist_p + dist_q is always correct — starting from the LCA there are only 2 shapes:
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.
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)
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:
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:
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:
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
# 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):
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
# 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:
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
getDistanceuses) - 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
0instead of-1forNone→ cannot distinguish “found at this node” from “not found” - ❌ forgetting the
if left != -1: return leftguard → returns the right subtree’s-1and loses the found distance - ❌ adding
+1in the depth-passing version → double counts (depth is already incremented on the way down) - ❌ calling
get_distfromrootinstead of from thelca→ 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
# 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
// 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
pathboth 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
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
Noneinstead of at a leaf: a node with one child counts as a path end, so LC 112’sroot = [1,2],targetSum = 1returnstrue;1is not a leaf. - Dropping the
pop: the right subtree inherits the left’s nodes —[1,2,3],targetSum = 4records[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
# 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
// 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
# 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
// 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
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 = 2returns 1, not 0. - Inserting
curbefore the lookup: withtargetSum = 0every node then counts itself as an empty path. - Java
intsums: LC 437 values reach ±10⁹, so the running sum overflows; key the map bylong.
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.valto 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
# 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
// 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
Noneinstead of at a leaf:root = [1,2],targetSum = 1returnstrue, but node 1 has a child, so it is not a leaf. - An empty tree with
targetSum = 0isfalse: 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 anint, 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
# 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
numberatNonecounts every half-leaf twice:[1,2,3]stays 25 only if the number is returned at a leaf andNonereturns 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-infbecause 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
# 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
// 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]
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
bestat0: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.valand reset to0otherwise, before the bendleft + rightis recorded. The answer counts edges.
Reduces to: binary_tree § Template 5 — with an arm that is zeroed across an edge whose values differ.
# 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 the4-4-4under 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 fromkreaches 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
# 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 = 2must 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
graphafter 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:
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
0instead of-1for “not found” —0is a valid distance - forgetting the
visitedset once parent edges exist — the BFS loops forever - calling
get_distfromrootinstead of from thelca— measures the wrong path