Trees

Recursion is the native language of trees — every subtree is itself a tree, so you solve for the children and combine at the current node. The only real decision is which traversal order to use: post-order when children must report back first, pre-order when the parent decides first, in-order for BST sorted properties, and BFS when level membership matters.

Topic guide18 problems
The unlock

A tree DFS function returns the finished answer for the subtree rooted at a node. So you never trace the call stack — you assume the recursive calls on the children already worked, and your only job is to combine their two answers and handle the current node. Solve it for one node and the whole tree solves itself.

MENTAL MODEL Trust the recursion — you are only ever one node

The single biggest unlock in trees: stop imagining the entire stack of calls. When you write dfs(node), pretend you are standing on one node and you have a magic assistant. You call dfs(node.left) and it hands you back the correct, finished answer for the entire left subtree. Same for the right. You do not care how it got that answer — you trust it.

Now your whole job shrinks to three moves: take the left answer, take the right answer, and combine them with what this node contributes. That is the entire problem. The recursion handles all the depth for free.

              ( 1 )  ◄ YOU are here. You only do 3 things:
             /     \        1. ask left child for its answer
            /       \       2. ask right child for its answer
        ( 2 )       ( 3 )   3. combine + handle THIS node
        /   \
    ( 4 ) ( 5 )    The recursive call on ( 2 ) ALREADY returns the
                   correct answer for the whole left subtree.
                   Don't trace into it. Assume it works.
The reframe → don't ask “how does the recursion unwind?” Ask “if a genie gave me the answer for both children, what would I do at this node?”Answer that one local question and you're done — induction does the rest.

RECURSION SHAPE Every DFS is base case → recurse both → combine

Strip away the specific problem and every tree DFS is the same three moves. Read it as a sentence, not as code: null is the empty subtree (return the identity); recurse left and right and trust them; fold the two answers together with this node.

dfs(node):                      # RETURNS the answer for the subtree at `node`

    if node is null:            # 1. BASE CASE — the empty subtree.
        return identity         #    0 for heights/sums, true for checks,
                                #    null for node-builders, [] for lists.

    left  = dfs(node.left)      # 2. TRUST IT — this is the correct answer
    right = dfs(node.right)     #    for the entire left / right subtree.

    # (pre-order work would go ABOVE the two calls;
    #  post-order work goes HERE, after both returned.)

    return combine(left, right, node.val)   # 3. fold children + this node

The only things that change between problems are: what the function returns, what the null base case returns, and how you combine. For height it's 1 + max(left, right); for “count nodes” it's 1 + left + right; for “is balanced” it's a height-or-sentinel. Same skeleton, different filling.

Pre vs in vs post = WHEN you touch the node → the recursion shape is fixed; the traversal order is just whereyou slot the node's own work relative to the two recursive calls. Work before the calls = pre-order (parent decides first, push info down). Work between = in-order (sorted on a BST). Work after = post-order (children report up first).

SEE IT Pre, in, post — same walk, three moments

Here is one small tree visited three ways. Notice the walk is identical— depth-first, left before right. The only thing that moves is the moment you record (“visit”) the node itself:

Tree:            ( A )
                /     \
            ( B )     ( C )
            /   \
        ( D ) ( E )

PRE-ORDER  (self, left, right) :  A  B  D  E  C    ◄ touch node BEFORE children
IN-ORDER   (left, self, right) :  D  B  E  A  C    ◄ touch node BETWEEN children
POST-ORDER (left, right, self) :  D  E  B  C  A    ◄ touch node AFTER children

Same walk, same shape. The ONLY difference is the moment you "visit self".
The BST fact worth memorizing → in-order (left · self · right) on a BST emits values in ascending sorted order. That single fact powers kth-smallest, validate-BST, and range queries.

TWO TRAVERSALS BFS reads the tree in rings

When the question mentions levels, depth, right-side view, zigzag, or minimum depth, DFS is awkward — switch to BFS, which sweeps the tree one horizontal ring at a time. Picture concentric rows expanding from the root:

                ( A )            ── level 0   (queue: [A])
              /       \
          ( B )       ( C )      ── level 1   (queue: [B, C])
          /   \           \
      ( D ) ( E )        ( F )   ── level 2   (queue: [D, E, F])

BFS drains the queue level by level. Snapshot levelSize = queue.length
at the top of each ring, loop exactly that many times, enqueue children
for the NEXT ring. The for-loop boundary IS the level boundary.

The whole trick is the level-size snapshot. A queue mixes levels together, so before you start a ring you record how many nodes are in it (levelSize = queue.length) and loop exactly that many times. Children you enqueue during the loop belong to the next ring:

bfs(root):                      # process the tree level by level

    if root is null: return
    queue = [root]

    while queue is not empty:
        levelSize = length(queue)   # SNAPSHOT this ring before it grows
        for i in range(levelSize):  # loop EXACTLY one level's worth
            node = queue.pop_front()
            do something with node  # this whole loop = one level
            if node.left:  queue.push(node.left)    # enqueue next ring
            if node.right: queue.push(node.right)
DFS or BFS? → if the answer depends on a node's relationship to its subtree(height, sum, path), use DFS recursion. If it depends on a node's level / horizontal position, use BFS with the level-size loop.

HOW TO THINK The cold-start ladder — run this on any tree problem

Faced with an unfamiliar tree problem, don't start typing the recursion. Climb these rungs in order — the code falls out at the bottom:

  1. Decide what one call returns for a subtree. Finish the sentence “dfs(node) returns ___ for the subtree rooted at node.” A number? A boolean? A node? A (height, isBalanced) pair? This is the whole design decision — get it right and everything else is mechanical.
  2. Pin the null base case. What's the answer for the empty subtree? It must be the identity for your combine step: 0 for heights/sums, true for “all nodes satisfy…”, null for builders, -∞ for max-gain.
  3. Combine the children. Given left and right (which you trustare correct), write the one expression that produces this node's answer. This is usually a max, min, +, &&, or a comparison with node.val.
  4. Ask: does an answer span ACROSS both children? If the real answer can pass through a node using both subtrees (diameter, max path sum), the return value can only carry one side up — so update a global with the both-sides value, and return the one-sided value.
  5. Decide WHEN to touch the node. Need a constraint pushed down (BST bounds, max-on-path)? Do the node's work beforerecursing (pre-order). Need children's results first (almost everything else)? Do it after (post-order). Need BST sorted order? In-order.
The one question that unlocks trees → “What does this function return for a subtree?” Nail that single sentence and the base case, combine, and traversal order all follow.

SAY IT Say what the function returns, then test it on a leaf

The highest-leverage habit in trees: before coding, say the return contract out loud as one sentence, then sanity-check it on the two smallest inputs — a leaf and null. If those two agree with your combine step, the recursion is almost certainly correct (induction does the rest).

  • Max depth:dfs(node)returns the height of the subtree.” Null → 0; leaf → 1 + max(0, 0) = 1. ✓
  • Validate BST:check(node, low, high) returns whether the subtree is a valid BST entirely inside (low, high).” Null → true. ✓
  • Max path sum:dfs(node) returns the best path sum that starts at node and goes down one side.” Null → 0; the answer-through-a-node lives in a global.
Failure mode → the return value tries to do two jobs at once. The classic: max-path-sum returning left + right + node.valto the parent. That value bends back down into both children, so the parent can't extend it — it must return only max(left, right) + node.val (one side) and stash the both-sides total in the global. If your return sentence has the word “through,” that's your tell to split it into a global.

WHEN IT BREAKS Local checks lie — thread the constraint down

A whole class of tree bugs comes from checking a property using only a node and its immediate neighbors, when the property is actually about the whole path from the root. The fix is always the same: pass the accumulated constraint down as a pre-order argument.

Local-only (wrong)Validate BST by checking only left.val < node.val < right.val. A node deep in the right subtree can violate a far ancestor and still pass.
Threaded bounds (right)Pass (low, high) down: left tightens high = node.val, right tightens low = node.val. Every node is checked against its whole ancestry.
The cue →if the rule mentions “all ancestors,” “on the path from the root,” or “so far” (max value on path in count-good-nodes, BST bounds), it's a pre-order push-down, not a post-order report-up. Carry the running constraint as a parameter.

MNEMONIC Left · self · right = sorted.

Left · self · right = sorted. The three DFS orders differ only in where"visit self" sits between the two recursive calls. On a BST, in-order (left·self·right) spits out the values in ascending order — the single most useful tree fact to memorize. Flip between pre/in/post in the Visualize tab to feel it.

PATTERN Recursion is the native language

Every tree problem has the same skeleton: base case on null, recurse on left and right children, then combine at the current node. Because every subtree is itself a tree with the same structure, you never need to think globally — just answer the question for the two children and decide what to return upward.

The mental model: "What does my left child tell me? What does my right child tell me? What do I compute from those two answers and my own value?" That question answered cleanly gives you the recursion.

Key discipline → be explicit about what the function returns. A function that returns a height behaves differently from one that returns whether a condition holds. Mixing concerns in the return value leads to subtle bugs.

KEY IDEA DFS traversal orders — pick the right one

Three DFS orderings; each unlocks a different class of problems:

  • Pre-order (root → left → right). The parent decides before children are visited. Use for: serialize/deserialize, clone a tree, compare two trees, carry a constraint down (e.g. max value on path so far).
  • In-order (left → root → right). On a BST this yields nodes in ascending sorted order. Use for: kth smallest, validating sort order, converting BST to sorted array.
  • Post-order (left → right → root).Children report back before the parent acts — the standard bottom-up aggregate. Use for: height, diameter, max path sum, balanced check, lowest common ancestor, anything that needs both subtrees' results before computing the parent's answer.

KEY IDEA BFS / level-order — when levels matter

When the problem asks about levels, depth layers, right-side view, zigzag, or minimum depth, reach for BFS with an explicit queue. The trick is snapshotting the queue length at the start of each level so you know when one level ends and the next begins.

Level-size loop → before processing a level, record levelSize = queue.length, then loop exactly that many times. Children enqueued during the loop belong to the next level and are not counted in the current one.
DFS for level problems
O(n)
Works, but needs depth tracking — awkward.
BFS level-order
O(n)
Natural grouping; no depth bookkeeping needed.

KEY IDEA BST invariant — left < node < right

A Binary Search Tree satisfies a strict ordering at every node: all nodes in the left subtree are less than the current node, and all nodes in the right subtree are greater. This unlocks two things:

  • O(h) search / insert / delete — at each node you eliminate half the tree. On a balanced BST h = O(log n); on a degenerate (sorted-input) tree h = O(n).
  • In-order traversal yields sorted order — exploited by kth-smallest and range queries.
Validation trap → checking node.left.val < node.val is not sufficient. A right-subtree node can be smaller than an ancestor. You must thread valid (low, high) bounds down every recursive call.

RUN IT Left · self · right = sorted

step 0 / 8
STARTin-order DFS — visit pattern left → self → right. Watch only where"self" falls relative to the two child calls.
5324879
output
empty
visiting nowalready outputtree edge
slowfast

TRIGGERS When you see ___ → reach for ___

Reach for a tree traversal whenever the problem gives you a binary tree (or BST) node and asks for a property of the whole tree, a path, a level, or the structure itself. The traversal order is the decision — not whether to recurse.

"depth / height / diameter / balanced"post-order DFS — children report height first, parent computes from both
"level by level / right side view / level widths / zigzag"BFS with a queue and a level-size snapshot loop
"is it a BST / kth smallest / in-range / sorted sequence"in-order traversal (iterative or recursive) for BST sorted property
"serialize / clone / compare two trees / copy structure"pre-order DFS — root visited first so structure is recorded top-down
"lowest common ancestor (LCA)"BST: walk down using ordering; general tree: post-order checking left/right returns
"path sum / max path / gain along a path"post-order DFS returning best downward contribution, update a global for path-through
"build tree from preorder + inorder (or inorder + postorder)"divide & conquer: root from preorder, split inorder with an index map, recurse on halves

RED FLAGSWhen it's NOT this pattern

  • The graph has cycles or multiple parents. A binary tree is a DAG with exactly one path between any two nodes. If the problem mentions a general graph, shared nodes, or cycles, switch to the Graphs category with a visited set.
  • The problem is about prefix matching or word sets. A trie is a tree, but the pattern is distinct — each edge is a character, not a value. Reach for the Tries category instead.
  • You need a global ordering or ranking across subtrees. In-order gives BST sorted order, but you cannot compare nodes from different subtrees by position alone without threading extra state (rank counter, bounds) down the recursion.
  • The tree is completely skewed (degenerate linked list). Recursion on a skewed n-node tree hits O(n) stack depth. For very large inputs, prefer iterative traversal with an explicit stack or queue to avoid stack overflow.

TEMPLATE Post-order DFS (bottom-up aggregate)

When → Children's results must be known before the current node can be computed: height, diameter, max path sum, balanced check. Returns a value per subtree and updates a global for cross-subtree aggregates.

post-order-dfs-bottom-up-aggregate-.ts
// Shared definition (LeetCode provides this)
class TreeNode {
  val: number;
  left: TreeNode | null;
  right: TreeNode | null;
  constructor(val = 0, left: TreeNode | null = null, right: TreeNode | null = null) {
    this.val = val; this.left = left; this.right = right;
  }
}

// Pattern: post-order DFS returning a value per subtree, updating a global.
// Used for: height, diameter, max-path-sum, balanced check, etc.
let best = -Infinity;   // or 0, or false — problem-specific global

function dfs(node: TreeNode | null): number {
  if (!node) return 0;            // base case — null contributes 0 (or -Infinity, etc.)

  const left  = dfs(node.left);   // 1. recurse left
  const right = dfs(node.right);  // 2. recurse right

  // 3. combine at this node (post-order: children computed first)
  best = Math.max(best, left + right + node.val); // e.g. max path through node

  // 4. return the "best contribution upward" — only ONE branch goes up
  return Math.max(left, right) + node.val;
}
Two-return-value trick → when you need two things (e.g., is-balanced AND height), return -1as a sentinel for "unbalanced" so you propagate failure without a separate boolean.

TEMPLATE BFS level-order with queue

When → The problem cares about which level a node is on: level-order output, right-side view, minimum depth, level averages, zigzag traversal.

bfs-level-order-with-queue.ts
// Shared definition (LeetCode provides this)
class TreeNode {
  val: number;
  left: TreeNode | null;
  right: TreeNode | null;
  constructor(val = 0, left: TreeNode | null = null, right: TreeNode | null = null) {
    this.val = val; this.left = left; this.right = right;
  }
}

function levelOrder(root: TreeNode | null): number[][] {
  if (!root) return [];
  const result: number[][] = [];
  const queue: TreeNode[] = [root];

  while (queue.length) {
    const levelSize = queue.length;   // snapshot: how many nodes are in THIS level
    const level: number[] = [];

    for (let i = 0; i < levelSize; i++) {
      const node = queue.shift()!;
      level.push(node.val);
      if (node.left)  queue.push(node.left);
      if (node.right) queue.push(node.right);
    }
    result.push(level);
  }
  return result;
}
Right-side view shortcut → take the last element of each level array, or in DFS pass depth and record only the first node visited at each new depth when traversing right-first.

TEMPLATE Iterative in-order for BST

When → You need the BST's nodes in sorted order, or you want to stop early (kth smallest). The iterative form lets you pause mid-traversal without recursion.

iterative-in-order-for-bst.ts
// Shared definition (LeetCode provides this)
class TreeNode {
  val: number;
  left: TreeNode | null;
  right: TreeNode | null;
  constructor(val = 0, left: TreeNode | null = null, right: TreeNode | null = null) {
    this.val = val; this.left = left; this.right = right;
  }
}

// Iterative in-order: visits BST nodes in ascending order.
// Use for: kth-smallest, validate BST values, flattening to sorted array.
function inOrder(root: TreeNode | null): number[] {
  const result: number[] = [];
  const stack: TreeNode[] = [];
  let cur: TreeNode | null = root;

  while (cur || stack.length) {
    // Descend as far left as possible
    while (cur) { stack.push(cur); cur = cur.left; }
    cur = stack.pop()!;         // visit (left subtree exhausted)
    result.push(cur.val);
    cur = cur.right;            // move to right subtree
  }
  return result;
}

TEMPLATE Validate BST with (low, high) bounds

When → Confirming a tree satisfies the BST invariant. Thread the valid range down every call; never compare only to the immediate parent.

validate-bst-with-low-high-bounds.ts
// Shared definition (LeetCode provides this)
class TreeNode {
  val: number;
  left: TreeNode | null;
  right: TreeNode | null;
  constructor(val = 0, left: TreeNode | null = null, right: TreeNode | null = null) {
    this.val = val; this.left = left; this.right = right;
  }
}

// Correct approach: carry valid (low, high) bounds down the tree.
// Every node must satisfy  low < node.val < high.
function isValidBST(root: TreeNode | null): boolean {
  function check(
    node: TreeNode | null,
    low: number,
    high: number,
  ): boolean {
    if (!node) return true;
    if (node.val <= low || node.val >= high) return false;
    return check(node.left,  low,       node.val) &&
           check(node.right, node.val,  high);
  }
  return check(root, -Infinity, Infinity);
}
Bounds update rule → going left tightens the upper bound to node.val; going right tightens the lower bound to node.val.

PITFALL Omitting the null base case

Every recursive tree function must handle node === null before accessing node.left or node.right. Leaf nodes have null children — if you skip the null check the function crashes on the very first leaf it reaches. Return the appropriate identity value: 0 for heights/sums, true for structural checks, null for node-returning functions.

PITFALL BST validation by comparing to the immediate parent only

Checking node.left.val < node.val at every node is wrong — a node deep in the right subtree of an ancestor could be smaller than that ancestor and still pass local checks. Always carry explicit (low, high) bounds from the root down and reject any node whose value falls outside the inherited range.

PITFALL Returning the cross-subtree path value instead of the one-sided contribution

In max-path-sum, a global variable should be updated with left + right + node.val (the full path through the current node), but the function must return only max(left, right) + node.val. Returning the full path value causes the parent to add it again, counting nodes twice and producing wrong answers.

PITFALL Forgetting which traversal yields sorted order

In-order (left → root → right) on a BST gives sorted ascending output. Pre-order and post-order do not. A frequent interview slip is applying pre-order logic where in-order is needed (kth smallest, sorted merge) or using in-order on a general binary tree expecting sorted output.

PROBLEMS

#104Maximum Depth of Binary TreePost-order: return 1 + max(dfs(left), dfs(right)); null returns 0. Classic bottom-up height.#100Same TreeParallel DFS on both trees simultaneously — check node values match and recurse on both left/right pairs.#226Invert Binary TreeSwap left and right children at every node; works with either DFS or BFS since order of swapping does not matter.#124Binary Tree Maximum Path SumPost-order returning best downward gain; update a global with left + right + node.val for a path through the current node. Return only max(left, right) + node.val upward.#102Binary Tree Level Order TraversalBFS with level-size snapshot: collect exactly levelSize nodes per iteration into a sub-array.#297Serialize and Deserialize Binary TreePre-order with null markers: serialize writes root before children, deserialize rebuilds in the same order using an index pointer or queue of tokens.#572Subtree of Another TreeDFS over every node of the main tree; at each node call a sameTree helper. O(m·n) overall but clean and accepted.#105Construct Binary Tree from Preorder and Inorder TraversalDivide & conquer: preorder[0] is the root, find it in inorder to split left/right sizes, recurse on each half. Build an inorder index map for O(1) splits.#98Validate Binary Search TreeDFS carrying (low, high) bounds; going left updates high = node.val, going right updates low = node.val. Never compare only to the immediate parent.#230Kth Smallest Element in a BSTIn-order traversal (iterative is cleaner for early exit); decrement a counter at each visit and return when counter reaches 0.#235Lowest Common Ancestor of a Binary Search TreeWalk down using BST ordering: if both p and q are less than node go left; if both greater go right; otherwise current node is the LCA.#543Diameter of Binary TreePost-order height function; at each node update a global best with left_height + right_height (the diameter through that node). Return max(left, right) + 1 upward.#110Balanced Binary TreePost-order returning height or -1 as a sentinel for unbalanced. If either child returns -1, or |left - right| > 1, propagate -1 upward immediately.#199Binary Tree Right Side ViewBFS: take the last node of each level. Alternatively DFS right-first, recording only the first node encountered at each new depth.#1448Count Good Nodes in Binary TreePre-order DFS carrying the maximum value seen on the path from root. A node is good if node.val >= pathMax; count it and update pathMax for children.#236Lowest Common Ancestor of a Binary TreeFind the deepest node that has both p and q as descendants with a single post-order recursion: a node whose left and right subtrees each return a target is the LCA; otherwise bubble up whichever side found one. No BST property needed.#173Binary Search Tree IteratorStream a BST in sorted order with next()/hasNext() by simulating in-order traversal on an explicit stack: seed the leftmost spine, and after each pop push the left spine of the right child. Amortized O(1) per call, O(h) space.#337House Robber IIIMaximize loot on a binary tree where adjacent (parent–child) houses cannot both be robbed: a post-order DFS returns a [rob, skip] pair per node, the tree analog of the linear House Robber state machine. O(n).