Solve a tree one node at a time. First decide what a call should return for the subtree under a node. Then trust the recursive calls on the children to return exactly that, and combine their answers. When the question is about levels or the nearest something, go level by level with a queue instead.
Think of it like this: a company org chart. To count everyone, the CEO asks each direct report “how many people are in your team, including you?” Each manager asks their own reports the same question, and the answers flow back up. That's depth-first. Reading the chart row by row is breadth-first: the CEO first, then every VP, then every manager.
How to spot it
Words and constraints in a problem that point to this pattern:
- The input is a
TreeNodecalledroot - “Depth”, “height”, “balanced”, “diameter”: an answer built from the children's answers
- “Level by level”, “each row”, “rightmost node of each level”, “nearest leaf”: breadth-first
- “Valid binary search tree” or “values in sorted order”: in-order traversal
- “Path from the root to a leaf”: carry information down as a parameter
Watch it work
Words you'll need:
- A binary tree is made of nodes. Each node has a value and up to two children,
leftandright. Either child may benull. - The top node is the root. A node with no children is a leaf. A node plus everything below it is a subtree.
- The depth of a node is how many nodes are on the path from the root down to it. The root has depth 1. The height of the tree is the largest depth. This is how "maximum depth" problems count it.
- Levels are counted from 0, like array indexes: the root is on level 0, its children on level 1. So a node on level 2 has depth 3. Breadth-first code usually stores level
kinresult[k]. - Depth-first search (DFS) goes as deep as possible down one branch before trying the next. Recursion does this for you.
- Breadth-first search (BFS) visits the tree level by level, using a queue: a list where items leave in the order they arrived (first in, first out).
The trace adds up every value in the tree below, first depth-first and then breadth-first. The recursive function is sum(node), and its job is: return the sum of the subtree under node. Its base case is null, an empty subtree, which sums to 0. A base case is an input small enough to answer without recursing. The row labelled “call stack” shows which calls are in progress, newest on the right.
The template
Three shapes. The depth-first one is the most common: write the base case, call on both children, combine.
The three orders of depth-first search differ only in when you look at the node itself:
- pre-order: the node, then its children
- in-order: the left child, the node, then the right child
- post-order: the children, then the node
In a binary search tree (BST), every value in a node's left subtree is smaller than the node. Every value in its right subtree is larger. So in-order visits the values in sorted order.
The breadth-first shape keeps the current level in one array and builds the next level in another. That avoids queue.shift(), which moves every element and gets slow on big trees.
class TreeNode {
val: number;
left: TreeNode | null;
right: TreeNode | null;
constructor(val: number, left: TreeNode | null = null, right: TreeNode | null = null) {
this.val = val;
this.left = left;
this.right = right;
}
}
// Depth-first. First write the contract: "dfs(node) returns ___ for the subtree under node".
function dfs(node: TreeNode | null): number {
if (node === null) return 0; // base case: the answer for an empty subtree
const fromLeft = dfs(node.left); // trust it: the answer for the left subtree
const fromRight = dfs(node.right); // and for the right subtree
return fromLeft + fromRight + node.val; // combine them with this node (here: a sum)
}
// The three orders: only the position of "look at node" changes.
function orders(node: TreeNode | null, pre: number[], inOrder: number[], post: number[]): void {
if (node === null) return;
pre.push(node.val); // pre-order: node first
orders(node.left, pre, inOrder, post);
inOrder.push(node.val); // in-order: between the children (sorted in a BST)
orders(node.right, pre, inOrder, post);
post.push(node.val); // post-order: after both children
}
// Breadth-first, one level per round: here, the largest value on each level.
function largestPerLevel(root: TreeNode | null): number[] {
const result: number[] = [];
if (root === null) return result;
let level: TreeNode[] = [root]; // every node on the current level
while (level.length > 0) {
const next: TreeNode[] = []; // the level below, filled as we go
let best = -Infinity;
for (const node of level) {
best = Math.max(best, node.val); // the per-level work goes here
if (node.left !== null) next.push(node.left);
if (node.right !== null) next.push(node.right);
}
result.push(best);
level = next; // move down one level
}
return result;
}
Worked example
Path sum: is there a path from the root down to a leaf whose values add up to target? In the trace's tree (5, 3, 8, 1, 4) with target = 12, the answer is yes: 5 + 3 + 4.
The first idea: collect every root-to-leaf path into an array of arrays, then add up each one. It works, but it builds and stores every path, which is a lot of copying for a big tree.
Notice that you never need the whole path, only how much is still missing. So carry a number down instead:
remaining, which shrinks by each node's value on the way down. Information that flows down goes in a parameter. Information that flows up is the return value.Write the contract:
hasPathSum(node, remaining)returns true if some path fromnodedown to a leaf adds up toremaining. Base cases: an empty subtree has no path, sofalse. A leaf succeeds exactly when its value equalsremaining.Combine: ask each child with what's left after this node, and succeed if either child does.
function hasPathSum(node: TreeNode | null, remaining: number): boolean { if (node === null) return false; // no node, no path if (node.left === null && node.right === null) { return node.val === remaining; // a leaf: does it finish the sum? } const rest = remaining - node.val; // what the children still need return hasPathSum(node.left, rest) || hasPathSum(node.right, rest); }Run it with 12: node 5 leaves 7 → node 3 leaves 4 → leaf 1: 1 === 4? no → leaf 4: 4 === 4, true. The
||stops there, so node 8 is never visited.Edge cases: an empty tree returns
false, even for target 0. A root 5 with only a left child 3 and target 5 returnsfalse, because 5 isn't a leaf. That's why the leaf check is separate from thenullcheck: a node with one child must not count as the end of a path.
Common mistakes
- Forgetting the base case
if (node === null): the recursion reaches a missing child and crashes with “Cannot read properties of null (reading 'left')”. - Treating a node with one child as a leaf: a leaf has no children. Checking only
node.left === nullaccepts half-finished paths. - Calling
dfs(node.left)anddfs(node.right)but never returning the combined value: every call returnsundefined, and the answer isNaNor wrong. - Using
queue.shift()on a big tree: each shift moves every element, so the search becomes O(n²). Use a level array plus anextarray, or an index pointer. - Pushing children into the same array you're looping over in BFS: the levels blur together, and per-level answers mix rows. Use a separate
nextarray or savequeue.lengthbefore the loop.
Cost
Depth-first and breadth-first both visit each node once: O(n) time. Depth-first keeps one call per node on the current path, so it uses O(h) space, where h is the height. h is about log₂ n for a bushy, balanced tree, and n for a tree that's one long chain. Breadth-first holds one level at a time, which can be about n / 2 nodes on the bottom level: O(n) space in the worst case.
In interviews
Before coding a recursive tree function, say its contract in one sentence: “dfs(node) returns the sum of the subtree under node.” The base case and the combine step then follow from it. Say whether you're going depth-first or breadth-first, and why. Levels or “nearest” point to breadth-first. Answers built from the children's answers point to depth-first.