Search Tech Journey

Find topics, journeys and posts

6-month learning plan28 / 130
back to blog
pythonbeginner 50m read

S028 · Trees & BSTs — Traversal (BFS/DFS)

Trees are the recursive data structure that models everything hierarchical — DOM, filesystems, syntax, dependency graphs. Master traversal and balancing here, and every future algorithm gets easier.

🧩DSAM03 · Data Structures & Algorithms· Session 028 of 130 90 min

🎯 Walk any tree three ways (DFS pre/in/post, BFS), operate on a BST in O(log n) expected time, and know why real databases + filesystems use B-trees instead of BSTs.

Why this session exists

Trees are the shape of hierarchy — of files, DOM elements, syntax, decisions, org charts, git commits, family trees. A binary search tree adds ordering to give you O(log n) lookup, insert, delete on average. This session teaches the four traversal orders you'll use forever (pre, in, post, level), the classic BST operations, and the moment where balance stops being optional. Trees are also the recursive shape that makes recursion (S027) click — the code you write literally mirrors the definition.

You will be able to
  • Build a binary tree in Python and traverse it four ways (pre-order, in-order, post-order, level-order).
  • Insert into and search a BST in O(log n) expected — and explain why worst-case is O(n) without balancing.
  • Compute tree height and validate ‘is this a valid BST?’ with one recursion each.
  • Distinguish binary tree ↔ BST ↔ balanced BST ↔ B-tree, and name the right tool for each real system.
  • Write BFS iteratively with a deque (this is your gateway to graph traversal in S030).

Prerequisites

  • S027 · Recursion — tree code is recursion in its purest form.
  • S026 · Stacks & Queues — BFS uses a queue, iterative DFS uses a stack.
  • S024 · Hashmaps — you'll see them combined with trees for LRU and range queries.


(a) Intuition · 5 min

A tree is an org chart that also tells you where to find people fast
🌍 Real world

Think of an ordered filing cabinet where each drawer has TWO sub-drawers: ‘names A–M’ on the left, ‘N–Z’ on the right. Each sub-drawer splits its range again. To find ‘Nakamura’, you make one decision at each drawer — go left or right — and reach the file in about log₂(n) steps.

That's a binary search tree. Every node divides the remaining range in half. But: if you always insert names alphabetically, the tree becomes a straight line — every ‘drawer’ has an empty left side. Now finding anything takes n steps. Balance is not a nice-to-have; it's the entire point.

💻 Code world

A binary tree is a node with two children (each a subtree). A binary search tree adds the invariant: for every node, all values in the left subtree are smaller, all values in the right subtree are larger. Lookup = walk down while comparing. Insert = walk down, hang a new leaf.

Traversal comes in four flavours: DFS in three orders (pre-order = root before children, in-order = left, root, right, post-order = children before root), and BFS = level by level. Different problems need different orders — memorise the four and pick the fit.

Four orders, four purposes

When to use each traversal
  • Pre-order (root → left → right) — good for cloning a tree, serialising it, evaluating prefix expressions.
  • In-order (left → root → right) — on a BST, yields values in sorted order. Use for ‘print in order’ or ‘k-th smallest’.
  • Post-order (left → right → root) — process children before parent. Used for computing tree sizes, freeing memory, evaluating postfix expressions.
  • Level-order / BFS (top-down, level by level) — use for shortest path in a tree, right-view / left-view problems, tree width.

Timeline — the family of trees

  1. 1958
    Binary search tree (Windley, Booth)
    First formal treatment of the BST. Everyone immediately notices the balance problem.
  2. 1962
    AVL tree (Adelson-Velsky & Landis)
    First self-balancing BST. Height difference between subtrees never exceeds 1. Guaranteed O(log n).
  3. 1970
    B-tree (Bayer & McCreight)
    Wider fan-out for disk-based storage. Each node holds many keys → shallow tree → few disk seeks. Every database ever since.
  4. 1972
    Red-Black tree
    Slightly-less-balanced BST that's cheaper to maintain than AVL. Java TreeMap, C++ std::map, Linux CFS scheduler.
  5. 1985
    Splay tree
    Tarjan & Sleator: rotate accessed node to the root. Amortised O(log n) — recently-accessed items are close by.
  6. 2007
    LSM-tree (Bigtable, LevelDB)
    The tree structure of the write-optimised era. Cassandra, RocksDB, TiDB all use it.

(b) Visual walkthrough · 15 min

A tree and its four traversals

For the tree above:

Pre-order

root → left → right

  • 1, 2, 4, 5, 3, 6, 7
  • Copy / serialise a tree
  • Prefix expression eval
In-order

left → root → right

  • 4, 2, 5, 1, 6, 3, 7
  • On BST → sorted output
  • K-th smallest via early stop
Post-order

left → right → root

  • 4, 5, 2, 6, 7, 3, 1
  • Compute size / height
  • Delete tree bottom-up
Level-order (BFS)

row by row

  • 1, 2, 3, 4, 5, 6, 7
  • Right-view / left-view
  • Tree width, level averages

BST search — one decision per level

Every comparison eliminates half the remaining nodes. Search cost = tree height. Balanced BST → O(log n). Degenerate BST → O(n).

Balanced vs unbalanced — the difference is life or death

What ‘balanced’ actually means

Perfectly balanced
Every leaf at the same depth. Height = log₂(n). Rare in practice — insertion breaks it.
ideal
Height-balanced (AVL)
For every node, left subtree height and right subtree height differ by at most 1. Guaranteed O(log n). Rebalances via rotations on every insert/delete.
AVL
Weight-balanced (Red-Black)
Loose colour-based invariant that keeps height ≤ 2·log₂(n+1). Fewer rotations than AVL → cheaper writes, slightly deeper trees.
RB
B-tree
Each node holds K keys and K+1 children (K = 100+). Height is log_K(n) ≈ 3–4 for a billion rows. Perfect for disks: fewer seeks per lookup.
disk
Degenerate
Height = n. Effectively a linked list. What you get when you insert sorted data into an unbalanced BST. Always. Avoid.
bad

Why databases use B-trees, not BSTs

11
Disk seek is 10ms

A memory access is 100 ns. Disk is 100,000× slower. Every tree node visit that requires a seek costs real time.

22
BST depth = log₂(n) ≈ 30 for a billion rows

30 seeks × 10ms = 300ms per query. That's the p99 latency of your worst API.

33
B-tree depth = log₁₀₀(n) ≈ 5 for a billion rows

5 seeks × 10ms = 50ms. 6× faster with the same disk.

44
Wider nodes match disk block size

A B-tree node is one 8KB / 16KB disk page — one seek reads all keys in the node. Cache-friendly.

55
Every SQL index is a B-tree

PostgreSQL, MySQL, SQLite, Oracle. Redis uses skip lists (a probabilistic alternative). MongoDB uses B-trees.


Common misconception
✗ What most people think

"A binary search tree gives O(log n) lookup. That's what a BST is for."

✓ What is actually true

A plain BST gives O(h) lookup, where h is the height. O(log n) only holds if the tree is balanced, and nothing in the BST insert algorithm enforces that. Insert already-sorted data and you build a linked list with extra pointers — O(n) lookup, worst case, silently.

Why the myth is so sticky

Because every diagram you were shown was balanced, and every practice dataset was inserted in random order — and random insertion order does give expected O(log n) height. Real data is almost never random: it arrives sorted by timestamp, by autoincrement id, by ingestion order. That is precisely the adversarial case, and it is the default case in data engineering.

Prove it to yourself

Sorted input degenerates the tree completely:

class N:
    def __init__(s, v): s.v, s.l, s.r = v, None, None

def insert(root, v):
    if root is None: return N(v)
    if v < root.v: root.l = insert(root.l, v)
    else:          root.r = insert(root.r, v)
    return root

def height(n):
    return 0 if n is None else 1 + max(height(n.l), height(n.r))

import random
vals = list(range(1000))
sorted_tree = None
for v in vals: sorted_tree = insert(sorted_tree, v)

shuf = vals[:]; random.shuffle(shuf)
rand_tree = None
for v in shuf: rand_tree = insert(rand_tree, v)

print(height(sorted_tree))  # 1000 - a linked list
print(height(rand_tree))    # around 20 - what you expected
From first principles
Start with the question

Why is an in-order traversal of a BST guaranteed to emit values in sorted order? This is usually stated as a fact to memorise. It is a two-line proof.

  1. 1
    The BST invariant says: for every node, all keys in its left subtree are less than it, and all keys in its right subtree are greater.
    forced by · that invariant is the entire definition of a BST — it is what makes search able to discard half the tree at each step
  2. 2
    The invariant is stated recursively: it holds at every node, so each subtree is itself a valid BST.
    forced by · a local rule applied at every node is by construction a global structural property
  3. 3
    In-order traversal is defined as: visit left subtree fully, then the node, then the right subtree fully.
    forced by · that is the only ordering that respects "everything smaller, then me, then everything larger"
  4. 4
    By induction, if the left traversal emits its keys sorted and all are less than the node, and the right emits sorted and all greater, then concatenating them around the node is sorted.
    forced by · concatenating a sorted run, a value above all of it, and a sorted run above that value, yields one sorted run
⇒ Therefore

Therefore in-order traversal is sorted output, and it costs O(n) with no comparisons at all — the sorting work was paid at insert time.

And note what this predicts: a BST is an incremental sort. Building one from n items costs O(n log n) if balanced, then traversal is O(n) — exactly the cost of a comparison sort, which is no coincidence. It also predicts the cleanest BST validity check: run an in-order traversal and assert it is strictly increasing. That is O(n) and catches every violation, whereas the naive "check each node against its two children" check is wrong and passes invalid trees.

Mental modelTwenty questions on a number line

A BST is the game of twenty questions, frozen into a structure. Every node is a question — "is your value above or below me?" — and every answer throws away one whole side of the remaining range.

The power comes entirely from how much each question discards. A balanced tree discards half every time, so n items take log₂n questions. A degenerate tree discards one item per question, so it takes n. Same code, same invariant; the only variable is how good the questions are.

  • Cost is O(height), never O(log n) by right. Balance is what converts one into the other.
  • In-order = sorted. Pre-order = serialise/rebuild shape. Post-order = free children before parent (delete, evaluate expressions).
  • A BST beats a hash map exactly when you need order: range scans, min/max, predecessor/successor, top-k. Hash maps answer none of those.
  • Self-balancing variants (red-black, AVL) do extra work on write to guarantee the height bound on read. The guarantee is bought, not free.
🔔 Fires when you see

Fire this model the moment you see: a range query ("all events between two timestamps") · "nearest value below X" · an ordered iteration requirement · a database index (B-trees are this idea widened for disk) · an interval or scheduling problem · anything where a hash map almost works but you also need sorting.

The tradeoff

You need keyed lookup over a large collection. Hash map, self-balancing BST, or a sorted array?

Hash map
+ you gain average O(1) lookup, insert and delete — the best constant factors of the three by a wide margin
− you pay no order at all: no range queries, no min/max, no successor, no sorted iteration; worst case O(n); resize causes latency spikes; keys must be hashable
pick when every query is an exact-match point lookup and you will never need order — the common case for joins, dedup and caches
Self-balancing BST
+ you gain O(log n) worst-case guarantee for point lookup, insert, delete and range/successor/min-max; sorted iteration is free; no resize spike, so latency is predictable
− you pay slower constant factor than hashing, pointer-chasing hurts cache, and rebalancing (rotations) makes writes more expensive and the implementation nontrivial
pick when you need ordered operations, or you need a hard worst-case latency bound rather than a good average
Sorted array + binary search
+ you gain the best cache behaviour of the three — contiguous memory, minimal overhead per element — plus O(log n) search and trivially fast range scans
− you pay insert and delete are O(n) because everything shifts; effectively a read-only structure once built
pick when the data is static or rebuilt in bulk — which describes almost every analytical dataset, and is why columnar formats sort and use binary search rather than trees
What a senior engineer actually does

Ask one question first: do I need order? If no, hash map, and stop. If yes, ask the second question: does the data change after it is built? If no, sorted array — it beats a tree on cache locality and memory and is far simpler. Only when you need both order and ongoing mutation does a balanced tree earn its complexity.

That third branch is exactly where databases live, which is why every serious storage engine uses a B-tree (a BST widened so each node is one disk page) or an LSM tree (sorted arrays plus background merging). Both are answers to "ordered and mutating", tuned for the fact that the comparison is not the expensive part — the I/O is.


(c) Hands-on · 25 min

Save as trees.py. Zero dependencies beyond stdlib.

"""trees.py — build a BST, traverse 4 ways, validate, and find k-th smallest.
 
Run:  python trees.py
"""
from __future__ import annotations
from collections import deque
from typing import Optional
 
 
class Node:
    __slots__ = ("val", "left", "right")
 
    def __init__(self, val: int, left: "Node | None" = None, right: "Node | None" = None):
        self.val = val
        self.left = left
        self.right = right
 
 
# ------------------------------------------------------------------
# 1) BST insert + search
# ------------------------------------------------------------------
def bst_insert(root: Node | None, val: int) -> Node:
    if root is None:
        return Node(val)
    if val < root.val:
        root.left = bst_insert(root.left, val)
    elif val > root.val:
        root.right = bst_insert(root.right, val)
    # duplicates: silently ignored
    return root
 
 
def bst_search(root: Node | None, val: int) -> bool:
    node = root
    while node:
        if val == node.val:
            return True
        node = node.left if val < node.val else node.right
    return False
 
 
# ------------------------------------------------------------------
# 2) The four traversals
# ------------------------------------------------------------------
def pre_order(root: Node | None, out: list[int] | None = None) -> list[int]:
    out = out if out is not None else []
    if root:
        out.append(root.val)
        pre_order(root.left, out)
        pre_order(root.right, out)
    return out
 
 
def in_order(root: Node | None, out: list[int] | None = None) -> list[int]:
    out = out if out is not None else []
    if root:
        in_order(root.left, out)
        out.append(root.val)
        in_order(root.right, out)
    return out
 
 
def post_order(root: Node | None, out: list[int] | None = None) -> list[int]:
    out = out if out is not None else []
    if root:
        post_order(root.left, out)
        post_order(root.right, out)
        out.append(root.val)
    return out
 
 
def level_order(root: Node | None) -> list[list[int]]:
    """BFS, grouping values by level."""
    if not root:
        return []
    levels: list[list[int]] = []
    q: deque[Node] = deque([root])
    while q:
        level = []
        for _ in range(len(q)):                      # snapshot the current level size
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        levels.append(level)
    return levels
 
 
# ------------------------------------------------------------------
# 3) Tree properties — height, size, is-BST
# ------------------------------------------------------------------
def height(root: Node | None) -> int:
    if root is None:
        return 0
    return 1 + max(height(root.left), height(root.right))
 
 
def size(root: Node | None) -> int:
    if root is None:
        return 0
    return 1 + size(root.left) + size(root.right)
 
 
def is_valid_bst(root: Node | None, low: float = float("-inf"), high: float = float("inf")) -> bool:
    """Every node must lie strictly in (low, high). Recurse tightening the bounds."""
    if root is None:
        return True
    if not (low < root.val < high):
        return False
    return is_valid_bst(root.left, low, root.val) and is_valid_bst(root.right, root.val, high)
 
 
# ------------------------------------------------------------------
# 4) k-th smallest — leverage in-order + early stop
# ------------------------------------------------------------------
def kth_smallest(root: Node | None, k: int) -> Optional[int]:
    """Return the k-th smallest value in the BST, or None. O(h + k) time."""
    stack: list[Node] = []
    node = root
    count = 0
    while node or stack:
        while node:
            stack.append(node)
            node = node.left
        node = stack.pop()
        count += 1
        if count == k:
            return node.val
        node = node.right
    return None
 
 
# ------------------------------------------------------------------
# 5) Compare recursive vs iterative BFS on a balanced random tree
# ------------------------------------------------------------------
def build_bst_from(values: list[int]) -> Node | None:
    root: Node | None = None
    for v in values:
        root = bst_insert(root, v)
    return root
 
 
if __name__ == "__main__":
    # Build the tree:
    #         50
    #        /  \
    #      30    70
    #     / \   / \
    #   20  40 60 80
    root = build_bst_from([50, 30, 70, 20, 40, 60, 80])
 
    print("pre_order :", pre_order(root))
    print("in_order  :", in_order(root))            # sorted for a valid BST
    print("post_order:", post_order(root))
    print("level_order:", level_order(root))
    print("height    :", height(root))              # 3
    print("size      :", size(root))                # 7
    print("is_valid_bst :", is_valid_bst(root))     # True
    print("kth_smallest k=3 :", kth_smallest(root, 3))   # 40
    print("bst_search 60 :", bst_search(root, 60))
    print("bst_search 65 :", bst_search(root, 65))
 
    # Demonstrate the balance problem — insert sorted data
    bad = build_bst_from([1, 2, 3, 4, 5, 6, 7])
    print(f"height of BAD tree from sorted input : {height(bad)}  (should be ~3, actually 7 — a linked list)")

Anatomy of the script

What each function teaches

bst_insert
Classic recursive insert. The BST invariant (left < root < right) is enforced by the choice of which subtree to descend into.
insert
bst_search (iterative)
Same walking logic, but iterative. Practically all production BST implementations use the iterative form to avoid Python's recursion overhead.
search
pre/in/post_order
Same three lines in different orders — just move the append relative to the recursive calls. Memorise the pattern.
DFS
level_order
BFS using a deque. The <code>for _ in range(len(q))</code> trick captures the current level size — a common LeetCode technique.
BFS
is_valid_bst
The recursion tightens the (low, high) bounds as it descends. Checking only <code>left.val &lt; root.val &lt; right.val</code> is WRONG — a distant descendant could violate the invariant.
invariant
kth_smallest
Iterative in-order traversal — the loop with left-spine descent is a classic template. Early-stop when count == k → O(h + k).
template
‘bad’ tree
Inserting sorted data into an unbalanced BST produces a linked list. This is why every production BST self-balances.
gotcha
Try itWrite ‘lowest common ancestor of two nodes in a BST’ using the ordering invariant
def lca_bst(root: Node | None, p: int, q: int) -> Node | None:
    node = root
    while node:
        if p < node.val and q < node.val:
            node = node.left
        elif p > node.val and q > node.val:
            node = node.right
        else:
            return node
    return None
 
# On the earlier balanced tree:
print(lca_bst(root, 20, 40).val)   # 30
print(lca_bst(root, 20, 60).val)   # 50 (root)

This is one of the cleanest algorithms in DSA — the BST invariant does all the work for you.

💡 Hint · If both target values are less than the current node → answer is in the left subtree. If both greater → right. Otherwise (targets are on opposite sides, or one equals the current node) → the current node IS the LCA. Iterative solution is O(h).

(d) Production reality · 15 min

War story PostgreSQL / MySQL / SQLite · every SQL query with an indexthe entire relational database industry
🔥 What broke

Every SQL database uses B-trees (technically B+trees) for indexes. When you write CREATE INDEX idx ON users(email), PostgreSQL builds a B-tree keyed by email that lets it look up any user in ~4 disk seeks instead of a full table scan.

The reason it's not a BST: disk seeks are 100,000× slower than memory access. Making each node hold ~200 keys means a billion-row table has height 4 instead of 30.

🧯 The fix
Every index EXPLAIN plan is a B-tree traversal — check for ‘Index Scan’ vs ‘Seq Scan’. If your query hits an index, you get O(log n); if not, O(n) table scan. This is why EXPLAIN ANALYZE is your best friend as a data engineer.
🎓 Lesson to steal
Trees aren't academic. Every SQL query you write in your career hits a tree under the hood. Understanding B-trees turns you from a SQL user into someone who can debug slow queries.
Post-mortem
War story Every browser · every millisecondthe DOM is a tree
🔥 What broke

The DOM is a tree of HTML elements. Every CSS selector match, every event bubble, every React re-render walks that tree. A poorly-nested React app can have DOM depth of 500+ → each re-render walks the tree twice (once for the virtual DOM diff, once for reconciliation).

🧯 The fix
React's Fiber reconciler traverses the tree in an interruptible way (an explicit work stack, see S027 recursion). React 18's Concurrent Features (useTransition, useDeferredValue) let low-priority tree updates yield to high-priority ones.
🎓 Lesson to steal
Even in ‘frontend’ code, the shape is a tree, the algorithm is DFS/BFS, and the constants matter. Everything you learn here shows up two abstractions above where you write code.
War story Common failure mode · every recursive tree functionstack overflow on deeply-nested input
🔥 What broke
A team writes a JSON tree comparator recursively. Works great on 100-deep nested JSONs from the test fixtures. In production, a customer submits a JSON with a linked-list-of-objects 5,000 deep → RecursionError. Job crashes at 3 AM.
🧯 The fix
Rewrite iteratively with an explicit stack (like the kth_smallest function above). No depth limit, plus you can pause/resume. If you can't, cap the input depth defensively.
🎓 Lesson to steal
Any recursion that walks user-controlled input must have a depth cap OR be iterative. This bug is a whole family — JSON parsers, YAML parsers, regex engines, GraphQL query validators.

Where this shows up in the rest of the plan

Trees are the shape of half of computing
S029 · Heaps & Priority Queues
A binary heap IS a complete binary tree stored in an array.
S030 · Graphs
A tree is a graph with no cycles. DFS/BFS generalise directly.
S036 · SQL Basics
Every index is a B-tree. Every query planner walks a tree of operations.
S043 · JSON / Parsing
JSON parsing is tree construction. Regex ASTs are trees.
S062 · Redis Data Structures
Sorted sets use skip lists — a probabilistic tree cousin.
S105 · Merkle Trees & Git
Git commits form a DAG; Merkle trees give O(log n) verification.

(e) Recall + stretch · 10 min

Recall — click to reveal · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, one minute each, no notes:

  1. What is a BST and what makes lookup fast?
  2. Give one real system that uses a tree (with why).
  3. Why does balance matter, and what happens when you lose it?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.