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.
🎯 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.
- 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
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.
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
- 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
- 1958Binary search tree (Windley, Booth)First formal treatment of the BST. Everyone immediately notices the balance problem.
- 1962AVL tree (Adelson-Velsky & Landis)First self-balancing BST. Height difference between subtrees never exceeds 1. Guaranteed O(log n).
- 1970B-tree (Bayer & McCreight)Wider fan-out for disk-based storage. Each node holds many keys → shallow tree → few disk seeks. Every database ever since.
- 1972Red-Black treeSlightly-less-balanced BST that's cheaper to maintain than AVL. Java TreeMap, C++ std::map, Linux CFS scheduler.
- 1985Splay treeTarjan & Sleator: rotate accessed node to the root. Amortised O(log n) — recently-accessed items are close by.
- 2007LSM-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:
root → left → right
- 1, 2, 4, 5, 3, 6, 7
- Copy / serialise a tree
- Prefix expression eval
left → root → right
- 4, 2, 5, 1, 6, 3, 7
- On BST → sorted output
- K-th smallest via early stop
left → right → root
- 4, 5, 2, 6, 7, 3, 1
- Compute size / height
- Delete tree bottom-up
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
Why databases use B-trees, not BSTs
A memory access is 100 ns. Disk is 100,000× slower. Every tree node visit that requires a seek costs real time.
30 seeks × 10ms = 300ms per query. That's the p99 latency of your worst API.
5 seeks × 10ms = 50ms. 6× faster with the same disk.
A B-tree node is one 8KB / 16KB disk page — one seek reads all keys in the node. Cache-friendly.
PostgreSQL, MySQL, SQLite, Oracle. Redis uses skip lists (a probabilistic alternative). MongoDB uses B-trees.
"A binary search tree gives O(log n) lookup. That's what a BST is for."
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.
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.
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 expectedWhy 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.
- 1The 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
- 2The 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
- 3In-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"
- 4By 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 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.
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.
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.
You need keyed lookup over a large collection. Hash map, self-balancing BST, or a sorted array?
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
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.
(d) Production reality · 15 min
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.
EXPLAIN ANALYZE is your best friend as a data engineer.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).
RecursionError. Job crashes at 3 AM.kth_smallest function above). No depth limit, plus you can pause/resume. If you can't, cap the input depth defensively.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, one minute each, no notes:
- What is a BST and what makes lookup fast?
- Give one real system that uses a tree (with why).
- 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.