01 Β· DSA β€” file 04

Trees & BSTs

Trees are where recursion stops being scary and starts being obvious β€” every tree problem is "do something with the root, trust recursion for the subtrees." And BSTs β†’ balanced trees β†’ B-trees is the straight line from interview question to "why Postgres indexes are fast," which you can and should say out loud.

01Why hierarchy at all?

Arrays and lists are linear β€” great for sequences, terrible for representing "contains" / "reports to" / "is a subdirectory of." Trees exist because a huge amount of real data is hierarchical: file systems, JSON documents, the DOM, org charts, ASTs (every LLM-generated code snippet you've ever parsed), Kubernetes object ownership. A tree is the honest shape of that data.

But there's a second, sneakier reason: a balanced tree turns O(n) into O(log n). If each step down the tree discards half the remaining data, n items are only logβ‚‚(n) levels deep. That "hierarchy as a search accelerator" idea is the entire reason BSTs, heaps, and database indexes exist.

Vocabulary in 10 seconds: root (top) Β· leaf (no children) Β· depth (distance from root) Β· height (longest rootβ†’leaf path) Β· subtree (any node + its descendants β€” itself a tree, which is why recursion fits so naturally). A binary tree: ≀ 2 children per node.

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right
            [8]              height 2, 7 nodes
           /   \             every subtree is a tree β†’
        [3]     [10]         recursion is not a trick here,
        / \        \         it's the structure talking
     [1]  [6]      [14]

02Traversals β€” the four ways to visit everything

"Traversal" = visit every node once. The only question is order. Three are DFS (go deep first), one is BFS (go wide first). How to remember which is which: "pre / in / post" says where the root goes relative to its children.

OrderRuleOn the demo tree belowNatural use
Preorderroot, L, R8 3 1 6 4 10 14serialization, copying, prefix expressions β€” process parent before children
InorderL, root, R1 3 4 6 8 10 14on a BST β†’ sorted order β€” powers validate-BST, kth-smallest
PostorderL, R, root1 4 6 3 14 10 8children before parent: subtree height/size, safe deletion, expression eval
Level-orderBFS by layers8 3 10 1 6 14 4anything phrased "by level": averages, right-side view, zigzag, min depth

Any time a node's answer depends on its children's answers, you're doing postorder whether you call it that or not.

Traversal stepper β€” watch the visit order happen

BST built by inserting 8, 3, 10, 1, 6, 14, 4. Pick an order, then Step (or Auto).

8 3 10 1 6 14 4

output:

Recursive DFS β€” the 4-line version

def inorder(node, out):
    if not node:
        return
    inorder(node.left, out)
    out.append(node.val)          # move this line up/down for pre/post
    inorder(node.right, out)

That comment is the whole story: the three DFS orders are one function with the "visit" line in three positions. Before the recursions = preorder; between = inorder; after = postorder.

Iterative versions (know at least these two)

Recursion uses the call stack; iterative versions use an explicit stack. Interviewers ask for them to (a) test stack understanding, (b) dodge Python's ~1000-frame recursion limit.

Iterative preorder β€” the easy one:

def preorder_iter(root):
    out, stack = [], [root] if root else []
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.right: stack.append(node.right)   # right first...
        if node.left:  stack.append(node.left)    # ...so left pops first
    return out

Iterative inorder β€” the important one (it's how you build a BST iterator):

def inorder_iter(root):
    out, stack, cur = [], [], root
    while cur or stack:
        while cur:                 # slide as far left as possible
            stack.append(cur)
            cur = cur.left
        cur = stack.pop()          # leftmost unvisited
        out.append(cur.val)
        cur = cur.right            # then explore its right subtree
    return out

Level-order (BFS) β€” a queue, and a per-level size counter:

from collections import deque
def level_order(root):
    if not root: return []
    out, q = [], deque([root])
    while q:
        level = []
        for _ in range(len(q)):        # freeze current level size
            node = q.popleft()
            level.append(node.val)
            if node.left:  q.append(node.left)
            if node.right: q.append(node.right)
        out.append(level)
    return out

The for _ in range(len(q)) trick β€” snapshotting the queue length to delimit a level β€” is the detail that separates "knows BFS" from "has written BFS."

Complexities

TraversalTimeExtra spaceWorst case
Any DFS (recursive or stack)O(n)O(h) β€” costs heightO(n) on a degenerate stick
BFS level-orderO(n)O(w) β€” costs width~n/2 at the bottom of a full tree

"DFS costs height, BFS costs width" is a tidy sentence to deploy. h = log n balanced, n degenerate.

The universal recursive template. Nearly every tree problem is this shape:

def solve(node):
    if node is None:
        return BASE                 # the empty tree's answer
    left  = solve(node.left)        # trust the recursion (leap of faith)
    right = solve(node.right)
    return combine(left, right, node)

Height: 1 + max(left, right). Size: 1 + left + right. Path sums, diameter, balance-checking β€” all instances. Learn the template, not the problems.

03Binary Search Trees

The BST invariant: for every node, everything in the left subtree < node.val < everything in the right subtree. Not just the children β€” the entire subtrees.

Why that invariant is money: at each node you can discard half the tree. Search, insert, delete β€” all O(h), which is O(log n) if the tree is balanced.

def search(node, target):
    while node:
        if target == node.val: return node
        node = node.left if target < node.val else node.right
    return None

It's binary search with pointers β€” same halving, but insertions don't require shifting an array. That's the point of a BST: a sorted collection that stays cheap to modify.

Sorted array

O(log n) search but O(n) insert β€” everything shifts.

Hashmap

O(1) lookup but no order β€” no range queries, no "next largest," no sorted iteration.

BST

Order + dynamism: O(log n) search, insert, delete, min/max, predecessor/successor, range queries.

Validate BST β€” the trap question

The classic wrong answer checks each node against only its children. This tree passes that check and is invalid:

        [5]
       /   \
     [3]   [8]
           /
         [4]    ← 4 < its parent 8, fine? No! 4 sits in 5's RIGHT
                  subtree, so it must be > 5. Local checks miss
                  violations of ancestor bounds.

Two correct approaches:

# 1. Pass down valid (lo, hi) bounds β€” each edge tightens one bound
def is_valid(node, lo=float('-inf'), hi=float('inf')):
    if not node:
        return True
    if not (lo < node.val < hi):
        return False
    return (is_valid(node.left,  lo, node.val) and
            is_valid(node.right, node.val, hi))

# 2. Inorder traversal must be strictly increasing (check on the fly, O(h) space)

Going left tightens the upper bound to the parent's value; going right tightens the lower bound. A node is valid only inside the window every ancestor has carved out for it.

04Lowest Common Ancestor β€” both variants

In a BST β€” the invariant does all the work. Walk from the root: if both targets are smaller, go left; both bigger, go right; otherwise you're standing on the split point β€” that's the LCA:

def lca_bst(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: return node        # p and q diverge here (or one equals node)
    return None

In a general binary tree β€” no ordering to steer by, so recurse both sides: "the LCA is the first node where p and q appear in different subtrees (or the node itself is one of them)":

def lca(root, p, q):
    if root is None or root is p or root is q:
        return root
    left  = lca(root.left, p, q)
    right = lca(root.right, p, q)
    if left and right:
        return root              # p and q split here
    return left or right         # both are on one side (or neither found)

One sentence for the interview: the BST version is O(h) and iterative because ordering tells you which way to walk; the general version must explore both subtrees and returns the node where both searches come back non-empty.

05Serialize / Deserialize

"Turn a tree into a string and back" β€” i.e., write a codec. The clean answer: preorder with explicit null markers. The nulls are what make the shape unambiguous (without them, one value sequence maps to many trees).

def serialize(root):
    out = []
    def dfs(node):
        if not node:
            out.append("#")
            return
        out.append(str(node.val))
        dfs(node.left)
        dfs(node.right)
    dfs(root)
    return ",".join(out)          # e.g. "8,3,1,#,#,6,#,#,10,#,14,#,#"

def deserialize(data):
    vals = iter(data.split(","))
    def build():
        v = next(vals)
        if v == "#":
            return None
        node = TreeNode(int(v))
        node.left = build()       # preorder: consume tokens in the same order
        node.right = build()
        return node
    return build()

The elegant bit worth narrating: deserialization works because preorder + null markers means the token stream is self-describing β€” each recursive call consumes exactly its own subtree's tokens and leaves the iterator positioned for the next. This is a tiny recursive-descent parser β€” the same shape as parsing nested JSON.

06Balance: the BST's Achilles heel, and the fix

Insert 1, 2, 3, 4, 5 into an empty BST in order and every "left" is empty. Height = n. Search is now O(n) β€” you built an expensive linked list. Sorted (or nearly sorted) input is common in real systems β€” auto-increment IDs, timestamps β€” so this isn't a corner case.

insert 1,2,3,4,5 in order 12345 height = n β†’ O(n) search same keys, balanced 32514 height = ⌈logβ‚‚ nβŒ‰ β†’ O(log n) search
Same five keys, two shapes. The degenerate "stick" (left) is what naive insertion of sorted input produces β€” every operation degrades to O(n). Self-balancing keeps the right-hand shape: O(log n).

Self-balancing trees fix it by doing small local repairs β€” rotations β€” on insert/delete to keep height O(log n). A rotation, in one picture β€” an O(1) pointer swap that shifts height between siblings while preserving the BST ordering:

      [3]                     [2]
      /        rotate         /  \
    [2]       ───────►      [1]  [3]
    /          right
  [1]

AVL trees β€” the strict ones

Invariant: every node's subtree heights differ by ≀ 1. Rebalance eagerly on every insert/delete. Result: tightest height (fastest lookups), more rotation work on writes. Read-heavy bias.

Red-black trees β€” the relaxed ones

Nodes colored red/black with rules (no red-red parent-child; equal black-count on every rootβ†’leaf path) guaranteeing longest path ≀ 2Γ— shortest β€” height still O(log n), fewer and cheaper fix-ups per write. Write-friendlier; the industrial default: Linux kernel (schedulers, epoll), Java's TreeMap, C++ std::map, Nginx timers.

Interview depth required: intuition-level. You will almost never rotate on a whiteboard. You should be able to say: why balance matters (sorted inserts β†’ O(n)), what a rotation is (local O(1) restructuring preserving order), and the AVL-vs-red-black trade (stricter balance & faster reads vs cheaper writes).

07B-trees β€” why databases don't use binary trees

Here's the part that connects to your day job. Postgres indexes are B-trees (B+ trees, precisely). Why not a red-black tree?

Because the bottleneck isn't comparisons β€” it's I/O. A disk (or even a page cache miss) hands you data in fixed-size pages (Postgres: 8 KB). Fetching a page costs the same whether you use 16 bytes of it or all 8 KB. A binary tree node uses one key per hop β‡’ one page fetch per level β‡’ ~30 fetches for a billion rows. Wasteful.

A B-tree node fills the whole page with keys β€” hundreds of them β€” so each fetch narrows the search by a factor of hundreds, not 2:

Binary tree (fanout 2):              B-tree (fanout ~300):
depth for 10⁹ keys β‰ˆ 30              depth for 10⁹ keys β‰ˆ log₃₀₀(10⁹) β‰ˆ 3-4
30 page reads                        3-4 page reads (root usually cached β†’ ~2)

B-tree node = one disk page:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ k₁ kβ‚‚ k₃ ... k₂₉₉        (sorted keys)       β”‚
β”‚ ↓  ↓  ↓  ...  ↓   ↓      (300 child ptrs)    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  within a node: binary search the keys (in RAM β€” comparisons are free now)
  • Nodes stay between half-full and full; when a node overflows, it splits and promotes its middle key upward β€” trees grow up from the leaves, staying perfectly balanced by construction.
  • B+ tree refinement: all values live in the leaves; leaves are chained left-to-right in a linked list β€” which makes range scans (WHERE created_at BETWEEN ..., ORDER BY ... LIMIT) a sequential walk along the leaf level. That leaf chain is why your indexed range queries are fast.

The one-liner for interviews: "Binary trees minimize comparisons; B-trees minimize page fetches. On disk, fetches are what cost β€” so databases use short, extremely wide trees." Same reasoning applies to why LSM-tree stores (Cassandra, RocksDB) exist β€” different workload (write-heavy), different structure β€” worth one sentence if the conversation goes there.

08Common interview questions

Q1. Max depth of a binary tree?

1 + max(depth(l), depth(r)), base 0. The universal template in its purest form.

Q2. Invert a binary tree?

Swap children, recurse. 4 lines. (Yes, the famous one.)

Q3. Same tree / subtree check?

Recursive structural equality: both None β†’ True; one None or values differ β†’ False; else recurse both sides.

Q4. Diameter of a binary tree?

Postorder computing height; at each node, candidate diameter = left_height + right_height; track a global max. Classic "return one thing, record another" pattern.

Q5. Kth smallest in a BST?

Inorder traversal is sorted; stop at the kth visit (iterative inorder makes early-exit clean). O(h + k).

Q6. Level-order zigzag / right-side view?

Plain BFS with per-level lists; reverse alternate levels / take the last of each level. If you can write Β§02's BFS, these are free.

Q7. Why is inorder traversal of a BST sorted?

Induction on the invariant: everything left < root < everything right, and each subtree is sorted by the same argument.

Q8. BST delete β€” the hard case?

Node with two children: replace its value with its inorder successor (min of the right subtree), then delete that successor (which has ≀ 1 child). Keeps the invariant.

Q9. Why does Postgres use B-trees, not hashmaps, for default indexes?

Hash indexes only answer equality; B-trees answer equality and range/order (<, BETWEEN, ORDER BY) in O(log n) with I/O-optimal fanout, plus the B+ leaf chain for scans.

Q10. Construct a binary tree from preorder + inorder?

Preorder's first element is the root; find it in inorder — left of it is the left subtree's inorder, right is the right's; recurse, with a hashmap from value→inorder index for O(n).

09Self-test

10 questions β€” answer out loud before peeking
  1. Write the recursive inorder traversal, then say which single line moves to produce preorder and postorder.
  2. Iterative inorder with an explicit stack β€” from memory. What's the invariant of the "slide left" inner loop?
  3. In BFS level-order, why snapshot len(q) before the inner loop?
  4. DFS space vs BFS space β€” which costs height, which costs width, and worst cases of each?
  5. Why is "check each node against its children" insufficient for BST validation? Draw the counterexample.
  6. Write LCA for a BST (iterative). Now say, in one sentence, how the general-tree version differs and why.
  7. Serialize/deserialize: why are null markers necessary, and why does the preorder token stream deserialize unambiguously?
  8. What input pattern degenerates a naive BST, and what's the name of the O(1) repair operation balanced trees use?
  9. AVL vs red-black in one sentence each. Which does std::map use?
  10. Explain to a junior engineer, in ≀ 3 sentences, why databases use B-trees instead of binary search trees.
Spot-checks
  1. The out.append line: before recursions = preorder, between = inorder, after = postorder.
  2. Push while going left; invariant: the stack holds ancestors whose left side is exhausted, pending their visit.
  3. Children enqueued during the loop must belong to the next level.
  4. DFS O(h) (stack; worst n), BFS O(w) (queue; worst n/2).
  5. Local checks miss deep violations of ancestor bounds β€” the 5/3/8/4 tree.
  6. The general version recurses both sides and returns where left + right both found something; no ordering to steer by.
  7. Nulls encode shape; each recursive build consumes exactly its subtree's tokens, leaving the iterator aligned.
  8. Sorted/monotonic inserts; rotation.
  9. AVL: strict height balance, read-optimized; RB: relaxed, cheaper writes. std::map uses red-black.
  10. Disk reads whole pages; B-tree nodes fill a page with hundreds of keys so each read divides the search by hundreds β€” 3-4 reads for a billion rows instead of ~30.