Pattern 10 of 24 · Trees · 35 min

Tree Depth-First Search

Solve a tree problem by solving it for the left and right subtrees and combining the answers on the way back up.

Spot it:binary tree or BSTdepth, height, diameterroot-to-leaf pathsvalidate a BSTJavaScript you'll use: Recursion, the call stack and memoization Linked list and tree nodes Functions, arrow functions and closures
Say it in 30 seconds
  • O(n)Time: each node is entered once
  • O(h)Stack: log n balanced, n skewed
  • ~10,000 framesRough V8 limit; a 10^5 chain overflows
Show what to say

“For a tree problem I assume the recursive call works on each subtree, then write only the base case and the combine step at the node. Answers return up or are carried down, and paths that bend keep a global best. That is O(n) time and O(h) stack.”

Trade-offs

  • Recursive vs iterative DFS: recursion is shorter and easier to get right; an explicit stack has no depth limit but is more code.
  • Return heights vs recompute them: one post-order pass is O(n); recomputing height at every node is O(n²) on a skewed tree.
  • DFS vs BFS on a tree: DFS holds one path, BFS holds one level and can stop at the first leaf; BFS suits level questions.

Bring it up before they ask

A leaf has both children null (a one-child node is not a leaf), start maximums at -Infinity when values can be negative, and copy a shared path array before storing it.

0 of 0 sections ticked
30-second answer

A tree is a root plus two smaller trees, so solve both subtrees, then combine at the node. Answers return up or are carried down; paths that bend keep a global best. O(n) time, O(h) stack.

When to reach for it

Spot it in the problem0 of 4

Tap the signals you can see in the problem.

Rememberbinary tree or BSTRoot-to-leaf pathslongest path anywhere

Pick your variant
  • Return a value up

    Information flowschildren → parent

    The node workspost-order, after both children

  • Carry state down

    Information flowsparent → children, as parameters

    The node workspre-order, before the children

  • Global best

    Information flowsreturn one value, record another

    The node workspost-order, plus an outer variable

  • BST walk

    Information flowscompare, then one branch only

    The node workstop-down

  • Explicit stack

    Information flowsan array replaces the call stack

    The node worksany order

The core idea: trust the recursive call

Picture a manager with two teams.

Read more · 165 words

Ask each team lead for their team's answer, then add your own part. You never ask how the leads got theirs. That is trusting the recursive call: assume f(node.left) and f(node.right) already work. You write only:

  • the base case: the answer for an empty tree;
  • the combine step: this node's answer, from the two below.

Each node is entered once (O(n)); one call waits per level (O(h) stack).

The three depth-first orders differ in when the node works. For the BST [8, 3, 10, 1, 6, null, 14]:

OrderWork happensVisitsUse it to
Pre-orderbefore the children8, 3, 1, 6, 10, 14pass depth, paths or bounds down; copy a tree
In-orderbetween left and right1, 3, 6, 8, 10, 14read a BST in sorted order
Post-orderafter both children1, 6, 3, 14, 10, 8combine children's answers; delete a tree

In post-order, each node returns its height (the badge) to its parent:

A stack is a pile of books: you can only put one on top, or take the top one off.
// The three depth-first orders differ only in WHEN a node does its work relative to its children.
export function traversals(root) {
  const pre = [];
  const inorder = [];
  const post = [];
  const dfs = (node) => {
    if (node === null) return;
    pre.push(node.val);       // before the children: top-down work (pass something down)
    dfs(node.left);
    inorder.push(node.val);   // between them: on a BST this visits values in sorted order
    dfs(node.right);
    post.push(node.val);      // after both: bottom-up work (combine the children's answers)
  };
  dfs(root);
  return { pre, inorder, post };
}

Return a value up: the height of every subtree

tree
8316471014
call stack (0)
empty
node
"8"

Call height(8) on the root. A node can't know its height until both subtrees have answered, so the work happens on the way back up (post-order).

function height(node) {
  if (node === null) return 0;
  const left = height(node.left);
  const right = height(node.right);
  return 1 + Math.max(left, right);
}
All steps as text
  1. Call height(8) on the root. A node can't know its height until both subtrees have answered, so the work happens on the way back up (post-order).
  2. height(8) first asks its left subtree, rooted at 3, and waits on the call stack.
  3. height(3) first asks its left subtree, rooted at 1, and waits on the call stack.
  4. 1 is a leaf. Both of its children are null, and the base case says an empty subtree has height 0.
  5. Both answers are in: 1 + max(0, 0) = 1. height(1) returns 1 to height(3) and leaves the call stack.
  6. The left side answered 1. Now height(3) asks its right subtree, rooted at 6.
  7. height(6) first asks its left subtree, rooted at 4, and waits on the call stack.
  8. 4 is a leaf. Both of its children are null, and the base case says an empty subtree has height 0.
  9. Both answers are in: 1 + max(0, 0) = 1. height(4) returns 1 to height(6) and leaves the call stack.
  10. The left side answered 1. Now height(6) asks its right subtree, rooted at 7.
  11. 7 is a leaf. Both of its children are null, and the base case says an empty subtree has height 0.
  12. Both answers are in: 1 + max(0, 0) = 1. height(7) returns 1 to height(6) and leaves the call stack.
  13. Both answers are in: 1 + max(1, 1) = 2. height(6) returns 2 to height(3) and leaves the call stack.
  14. Both answers are in: 1 + max(1, 2) = 3. height(3) returns 3 to height(8) and leaves the call stack.
  15. The left side answered 3. Now height(8) asks its right subtree, rooted at 10.
  16. 10 has no left child: height(null) returns 0 straight away.
  17. The left side answered 0. Now height(10) asks its right subtree, rooted at 14.
  18. 14 is a leaf. Both of its children are null, and the base case says an empty subtree has height 0.
  19. Both answers are in: 1 + max(0, 0) = 1. height(14) returns 1 to height(10) and leaves the call stack.
  20. Both answers are in: 1 + max(0, 1) = 2. height(10) returns 2 to height(8) and leaves the call stack.
  21. Both answers are in: 1 + max(3, 2) = 4. height(8) returns 4 to the caller and leaves the call stack.
  22. Every node was visited once and the root returned 4: the longest root-to-leaf path, 8 → 3 → 6 → 4, has 4 nodes. O(n) time, O(h) stack.

Play with it

Watch the walker dive while the call stack grows and shrinks. Then pick Predict and click the nodes in order yourself.

Read more · 78 words
  1. Say what the function returns for one subtree

    One sentence, like "the height of the subtree at node".

  2. Write the base case

    if (node === null) return …: 0 for sums, -Infinity for maximums.

  3. Decide what flows down and what flows up

    Ancestors' info goes down as parameters; descendants' info comes back as return values.

  4. Write the combine step

    Build the answer from left, right and node.val.

  5. Check the shapes that break trees

    Empty, one node, skewed, negative values.

Interactive

Depth-first walker

Watch the recursion dive and return, with the call stack growing and shrinking. Then predict the order yourself, or hunt for a path sum.

8310161447
Visit order:
Visited 0/8Stack depth 0 (at most the height, 4: O(h) space)

Pre-order walk from the root. Pre-order visits a node first, then its left subtree, then its right.

Edit the tree

Templates

Template 1: return a value up

Ask both subtrees, then combine: 1 + Math.max(left, right) for height.

That's Maximum Depth of Binary TreeEasy. Invert Binary TreeEasy swaps the children in the combine. Lowest Common Ancestor of a Binary TreeMedium: the first node to hear from both sides is the answer.

// Template 1: return a value up (post-order). Ask both subtrees for their answer, then combine at the node.
// Use it for height, size, sums, "is it balanced?": anything a node can compute from its children's answers.
export function height(root) {
  if (root === null) return 0;          // an empty subtree has height 0, so a leaf gets 1
  const left = height(root.left);
  const right = height(root.right);
  return 1 + Math.max(left, right);     // combine on the way back up
}

// Same shape, different combine. -1 means "unbalanced somewhere below", so one number carries both answers
// and the whole check stays O(n) instead of recomputing heights at every node.
export function isBalanced(root) {
  const check = (node) => {
    if (node === null) return 0;
    const left = check(node.left);
    if (left === -1) return -1;
    const right = check(node.right);
    if (right === -1 || Math.abs(left - right) > 1) return -1;
    return 1 + Math.max(left, right);
  };
  return check(root) !== -1;
}

Template 2: carry state down

Use what the ancestors passed in, then call the children with an updated copy.

Read more · 30 words

A shared array must be popped after the call, and copied when stored.

Path SumEasy carries the remaining sum down and checks it at each leaf. Validate Binary Search TreeMedium carries the allowed range.

Carry state down: count the good nodes

tree
53672879
maxSoFar
"-∞"
good
0

Start at the root with maxSoFar = -∞: nothing is above the root, so it is good whatever its value. Each badge shows the max a node receives from its ancestors.

function goodNodes(root) {
  const dfs = (node, maxSoFar) => {
    if (node === null) return 0;
    const good = node.val >= maxSoFar ? 1 : 0;
    const nextMax = Math.max(maxSoFar, node.val);
    return good + dfs(node.left, nextMax) + dfs(node.right, nextMax);
  };
  return dfs(root, -Infinity);
}
All steps as text
  1. Start at the root with maxSoFar = -∞: nothing is above the root, so it is good whatever its value. Each badge shows the max a node receives from its ancestors.
  2. 5 ≥ -∞, the largest value above it, so 5 is good (count 1).
  3. Hand max(-∞, 5) = 5 down to the children. Each child gets its own copy, so nothing has to be undone afterwards.
  4. 3 is smaller than 5, which sits somewhere above it on the path, so it is not good.
  5. Hand max(5, 3) = 5 down to the children. Each child gets its own copy, so nothing has to be undone afterwards.
  6. 6 ≥ 5, the largest value above it, so 6 is good (count 2).
  7. Hand max(5, 6) = 6 down to the children. Each child gets its own copy, so nothing has to be undone afterwards.
  8. 7 ≥ 6, the largest value above it, so 7 is good (count 3).
  9. 2 is smaller than 5, which sits somewhere above it on the path, so it is not good.
  10. 8 ≥ 5, the largest value above it, so 8 is good (count 4).
  11. Hand max(5, 8) = 8 down to the children. Each child gets its own copy, so nothing has to be undone afterwards.
  12. 7 is smaller than 8, which sits somewhere above it on the path, so it is not good.
  13. 9 ≥ 8, the largest value above it, so 9 is good (count 5).
  14. Every call added its own 1 or 0 to what its children returned, and the root's call returns 5. Information flowed down (the max) and the count flowed back up.
// Template 2: carry state down (pre-order). Each call receives what its ancestors know (the max so far, a running
// sum, the allowed range) and hands an updated value to its children. Primitives are copied, so nothing to undo.
export function goodNodes(root) {
  // A node is "good" when nothing on the path from the root down to it is larger than it.
  const dfs = (node, maxSoFar) => {
    if (node === null) return 0;
    const good = node.val >= maxSoFar ? 1 : 0;
    const nextMax = Math.max(maxSoFar, node.val);
    return good + dfs(node.left, nextMax) + dfs(node.right, nextMax);
  };
  return dfs(root, -Infinity);
}

// Carrying a whole path down: push before recursing, pop after (backtracking), copy it only at a leaf.
export function rootToLeafPaths(root) {
  const paths = [];
  const path = [];
  const dfs = (node) => {
    if (node === null) return;
    path.push(node.val);
    if (node.left === null && node.right === null) paths.push([...path]); // copy: path keeps changing
    dfs(node.left);
    dfs(node.right);
    path.pop();                                                           // undo before returning to the parent
  };
  dfs(root);
  return paths;
}

Template 3: a global best updated at every node

Some paths bend: up one subtree, through a node, down the other.

Read more · 49 words

The answer is the best bend over all nodes, but a parent can only extend a straight arm. So at each node:

  • record leftArm + rightArm in an outer best;
  • return only the longer arm.

Diameter of Binary TreeEasy uses heights as arms. Binary Tree Maximum Path SumHard uses sums, and treats a negative arm as 0.

// Template 3: return one thing up, record another in a variable outside the recursion.
// Longest path whose nodes all share one value, counted in edges. A path may bend at a node (left arm + right arm),
// but a parent can extend only ONE arm, so the helper returns the longer arm and the bent path goes into `best`.
export function longestUnivaluePath(root) {
  let best = 0;
  const arm = (node) => {
    if (node === null) return 0;
    const left = arm(node.left);
    const right = arm(node.right);
    const leftArm = node.left !== null && node.left.val === node.val ? left + 1 : 0;
    const rightArm = node.right !== null && node.right.val === node.val ? right + 1 : 0;
    best = Math.max(best, leftArm + rightArm); // the best path that bends exactly here
    return Math.max(leftArm, rightArm);        // what the parent is allowed to extend
  };
  arm(root);
  return best;
}

Template 4: BST bounds and ordering

In a BST, one comparison rules out a whole subtree: search and insert walk one branch, O(h).

Read more · 34 words

Validation is subtle. In [5, 4, 6, null, null, 3, 7] each node suits its parent, yet 3 sits right of 5. Carry (low, high) down, or check that the in-order walk strictly increases.

// BST template: in a binary search tree every value in a left subtree is smaller than the node and every value in a
// right subtree is larger. So one comparison tells you which whole subtree can't contain what you want: skip it.
export function rangeSum(root, low, high) {
  if (root === null) return 0;
  if (root.val < low) return rangeSum(root.right, low, high);  // root and its entire left subtree are too small
  if (root.val > high) return rangeSum(root.left, low, high);  // root and its entire right subtree are too large
  return root.val + rangeSum(root.left, low, high) + rangeSum(root.right, low, high);
}

// Walking down one branch needs no recursion at all: O(height).
export function searchBST(root, target) {
  let node = root;
  while (node !== null && node.val !== target) node = target < node.val ? node.left : node.right;
  return node;
}

Template 5: iterative DFS with an explicit stack

An array stack avoids call-stack overflow on very deep trees.

Read more · 33 words
  • Pre-order: pop, visit, push the right child, then the left.
  • In-order: slide left pushing every node, pop and visit one, then step right.
  • Post-order: pre-order with the pushes swapped, then reverse the output.

Iterative DFS: in-order with an explicit stack

tree
63149810▲ node
stack (0)
empty
out (0)
empty
node
6

Start with node at the root and an empty stack. The stack will hold the ancestors whose left side we are still exploring.

function inorder(root) {
  const out = [], stack = [];
  let node = root;
  while (node !== null || stack.length) {
    while (node !== null) {
      stack.push(node);
      node = node.left;
    }
    node = stack.pop();
    out.push(node.val);
    node = node.right;
  }
  return out;
}
All steps as text
  1. Start with node at the root and an empty stack. The stack will hold the ancestors whose left side we are still exploring.
  2. Push 6: it must wait until everything smaller (its left subtree) is done. Slide left to 3.
  3. Push 3: it must wait until everything smaller (its left subtree) is done. Slide left to 1.
  4. Push 1. It has no left child, so nothing smaller is left to explore below it.
  5. Pop 1 and visit it: its whole left subtree is already in out, so it is the smallest value not yet output.
  6. 1 has no right subtree, so the next value to output is the one on top of the stack (3).
  7. Pop 3 and visit it: its whole left subtree is already in out, so it is the smallest value not yet output.
  8. Move into 3's right subtree (4): those values come after 3 and before anything still on the stack.
  9. Push 4. It has no left child, so nothing smaller is left to explore below it.
  10. Pop 4 and visit it: its whole left subtree is already in out, so it is the smallest value not yet output.
  11. 4 has no right subtree, so the next value to output is the one on top of the stack (6).
  12. Pop 6 and visit it: its whole left subtree is already in out, so it is the smallest value not yet output.
  13. Move into 6's right subtree (9): those values come after 6 and before anything still on the stack.
  14. Push 9: it must wait until everything smaller (its left subtree) is done. Slide left to 8.
  15. Push 8. It has no left child, so nothing smaller is left to explore below it.
  16. Pop 8 and visit it: its whole left subtree is already in out, so it is the smallest value not yet output.
  17. 8 has no right subtree, so the next value to output is the one on top of the stack (9).
  18. Pop 9 and visit it: its whole left subtree is already in out, so it is the smallest value not yet output.
  19. Move into 9's right subtree (10): those values come after 9 and before anything still on the stack.
  20. Push 10. It has no left child, so nothing smaller is left to explore below it.
  21. Pop 10 and visit it: its whole left subtree is already in out, so it is the smallest value not yet output.
  22. 10 has no right subtree and the stack is empty: there is nothing left to visit.
  23. node is null and the stack is empty, so every node was pushed once and popped once. out is sorted: [1, 3, 4, 6, 8, 9, 10].
// Iterative DFS: an explicit stack replaces the call stack. Same visiting order, no recursion depth limit.
export function preorderIterative(root) {
  const out = [];
  const stack = root ? [root] : [];
  while (stack.length) {
    const node = stack.pop();
    out.push(node.val);
    if (node.right) stack.push(node.right); // pushed first, so it is popped AFTER the left subtree
    if (node.left) stack.push(node.left);
  }
  return out;
}

// In-order: slide left as far as possible, visit, then step into the right subtree. Sorted order on a BST.
export function inorderIterative(root) {
  const out = [];
  const stack = [];
  let node = root;
  while (node !== null || stack.length) {
    while (node !== null) {
      stack.push(node);
      node = node.left;
    }
    node = stack.pop();
    out.push(node.val);
    node = node.right;
  }
  return out;
}

// Post-order: run pre-order with the children swapped (node, right, left), then reverse it (left, right, node).
export function postorderIterative(root) {
  const out = [];
  const stack = root ? [root] : [];
  while (stack.length) {
    const node = stack.pop();
    out.push(node.val);
    if (node.left) stack.push(node.left);
    if (node.right) stack.push(node.right);
  }
  return out.reverse();
}

Try it

try-it.js
// Edit the trees (level order, null = missing child) and press Run. The functions are the templates above.
const root = fromArray([8, 3, 10, 1, 6, null, 14, null, null, 4, 7]);
console.log(traversals(root));                 // { pre: [8, 3, 1, 6, 4, 7, 10, 14], inorder: [1, 3, 4, 6, 7, 8, 10, 14], post: [1, 4, 7, 6, 3, 14, 10, 8] }
console.log(height(root));                     // 4
console.log(goodNodes(fromArray([5, 3, 8, 6, 2, 7, 9])));               // 4
console.log(longestUnivaluePath(fromArray([1, 4, 5, 4, 4, 5])));        // 2
console.log(inorderIterative(fromArray([6, 3, 9, 1, 4, 8, 10])));       // [1, 3, 4, 6, 8, 9, 10]

Complexity and JavaScript you'll use

Complexity

Read more · 134 words
VariantTimeExtra space
Recursive DFS, any orderO(n): each node entered onceO(h): O(log n) balanced, O(n) skewed
Global best (diameter, path sum)O(n): one post-order passO(h)
Recomputing height at every nodeO(n²) on a skewed treeO(h): return heights instead
BST search or insertO(h): one branchO(1) iterative, O(h) recursive
BST range queryO(h + k) for k values in rangeO(h)
Iterative DFS with a stackO(n)O(h) on the heap, no depth limit

JavaScript you'll use

JavaScript refresher: Recursion, the call stack and memoization Linked list and tree nodes Functions, arrow functions and closures
const dfs = (node, depth) => {               // nested arrow function: sees outer variables
  if (node === null) return 0;               // base case first, always
  return 1 + Math.max(dfs(node.left, depth + 1), dfs(node.right, depth + 1));
};
let best = -Infinity;                        // a global best lives in the closure, not in a parameter
const isLeaf = !node.left && !node.right;    // a leaf has NO children, not "at least one missing"
[node.left, node.right] = [node.right, node.left]; // swap children in one line

Interview questions

Recursive or iterative DFS?

Recursive first. Then name the risk: a skewed tree of 10^5 nodes overflows the stack. An explicit stack fixes it.

How does the lowest common ancestor change for a BST?

Go left while both are smaller, right while both are larger. The split is the answer.

Why isn't the diameter always the path through the root?

The longest path may bend deep inside one subtree.

Can you traverse a tree in order with O(1) extra space?

Yes, with Morris traversal: temporary links back up the tree.

When should you switch from DFS to BFS on a tree?

For levels or the nearest match: see Tree BFS.

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. For the diameter of a tree, the helper returns the subtree height, yet the answer at a node is leftHeight + rightHeight. Why not return leftHeight + rightHeight from the helper?

Practice problems

Solve these in the browser editor. Each runs against hidden tests; the step-by-step solution unlocks after a wrong submission.

Open these in the problem list →

In real systems

This pattern is not only for coding rounds. Here is where it does the work in a design interview.

Next: When the question is about levels, go breadth-first instead.