DAY 20 / 30 · WEEK 3

Binary Search Trees

Goal of the day

Concept

A binary search tree (BST) uses the exact same TreeNode class as Day 19 — val, left, right — and the exact same shape (at most two children per node). What changes is where a new value is allowed to go. Day 19's tree had no ordering rule at all — a new node could land "wherever there's room." A BST adds one rule and enforces it everywhere: at every node, every value in its left subtree must be smaller than it, and every value in its right subtree must be larger. That one rule is what makes insert and search fast — it turns "where do I look?" into a single comparison at each step instead of a full scan.

Think of a well-organized filing cabinet, or a paper phone book: entries are kept in sorted order specifically so you never have to scan the whole thing. Looking for "Mendez"? Open roughly to the middle — say you land on "K". "Mendez" comes after "K" alphabetically, so you already know the entire first half of the book (everything from "A" to "K") can't contain it — skip it, and only look to the right. That's exactly Day 12's binary search, applied to a tree instead of a sorted array: one comparison eliminates an entire subtree, not just one entry.

A BST bakes that same idea into its shape. Instead of a sorted array you binary search over with lo/hi/mid, the sort order lives in the tree's structure itself — "go left" or "go right" at each node is the narrowing step, and the empty spot where a walk runs out of tree is where a new value belongs.

Diagram: a valid BST and its invariant

A valid BST — left subtree < node < right subtree, at every node 50 30 70 20 40 60 80 ◀ left: {20, 30, 40} all < 50 right: {60, 70, 80} all > 50 ▶ The same rule repeats one level down: 20 < 30 < 40, and 60 < 70 < 80. Inorder traversal (left, node, right) visits 20, 30, 40, 50, 60, 70, 80 — sorted order, for free, as a side effect of the BST invariant.

Diagram: insert's walk — deciding left/right until an empty spot

Inserting 45: walk left/right by comparison until an empty spot appears 50 30 70 20 40 60 80 45 45 < 50 → go left 45 > 30 → go right 40 has no right child → 45 lands here Two comparisons, then an empty spot — the walk always ends at the first null child.

Diagram: why "just check parent vs. child" validation is wrong

Locally fine, globally invalid: the classic isValidBST trap 10 5 15 6 20 15's right subtree ✓ locally: 6 < 15 (its own parent) — looks fine ✗ globally: 6 is in root 10's RIGHT subtree — must be > 10. But 6 < 10, so this tree is actually invalid. A validator checking only left.val < node.val < right.val per node misses this — it needs min/max bounds threaded from every ancestor.

Interactive visualization: insert into a BST

current node — comparing now path already confirmed new node — landed here
Press Play or Step to begin.

Interactive visualization: search a BST

current node — comparing now path so far (green on the last node = match found) path so far, ending in a dead end — not found
Press Play or Step to begin.

The code: insert, search, isValidBST

// Same TreeNode class as Day 19 — val, left, right. What's new today is the
// RULE every insert obeys: smaller goes left, larger goes right, at every node.
class TreeNode {
  constructor(val, left = null, right = null) {
    this.val = val;
    this.left = left;
    this.right = right;
  }
}

function insert(root, val) {
  if (!root) return new TreeNode(val);    // empty spot found — this is where val belongs
  if (val < root.val) root.left = insert(root.left, val);
  else if (val > root.val) root.right = insert(root.right, val);
  // val === root.val: already present — no duplicate inserted, tree unchanged
  return root;
}

function search(root, val) {
  if (!root) return false;                // fell off the tree — dead end, not found
  if (val === root.val) return true;      // match
  return val < root.val ? search(root.left, val) : search(root.right, val);
}

// The classic trap: checking only "left.val < node.val < right.val" at each node
// misses violations against a GRANDPARENT or higher ancestor. Thread a min/max
// bound down through the recursion instead — every node must fall strictly
// between the tightest bounds set by ALL of its ancestors, not just its parent.
function isValidBST(root, min = -Infinity, max = Infinity) {
  if (!root) return true;                 // an empty tree (or empty subtree) is valid
  if (root.val <= min || root.val >= max) return false;
  return isValidBST(root.left, min, root.val) &&
         isValidBST(root.right, root.val, max);
}

Line by line:

Dry run: insert(65) into the 7-node BST above

StepCurrent nodeCompareDecision
15065 > 50go right → 70
27065 < 70go left → 60
36065 > 60, 60.right is emptyinsert 65 as 60's right child

Three nodes deep, two real comparisons — the walk always terminates the instant it steps toward a null child, which is exactly where the new node belongs.

Complexity

OperationTimeSpaceWhy
insertO(h)O(h)One comparison per level walked down, h = tree height. Recursive call stack depth also tracks h.
searchO(h)O(h)Identical walk to insert, just without wiring in a new node — same per-level comparison, same recursion depth.
isValidBSTO(n)O(h)Must visit every node once to confirm the whole tree (O(n) time) — the min/max bounds are just extra arguments, not extra visits. Call stack depth is still h.

h = the tree's height, n = total nodes. O(h) is not automatically O(log n). On a balanced tree, h ≈ log n, and insert/search really do run in logarithmic time — that's the whole appeal of a BST. But nothing about a plain BST keeps it balanced: insert 1, 2, 3, 4, 5 in that exact order and every new value is always larger than everything already in the tree, so each one becomes the previous node's right child. The result is a straight rightward chain — height n, no branching at all — and insert/search degrade to O(n), the same cost as scanning a linked list. Say "O(log n) for a balanced BST," never "O(log n) BST operations" unqualified.

Interview corner

How to talk through it out loud: name the invariant before writing a line — "at every node, left subtree is smaller, right subtree is larger" — then narrate each walk as a single comparison per step: "is my target smaller or larger than the current node? that tells me the only subtree worth looking in." For isValidBST specifically, say the trap out loud before coding it: "checking just the immediate parent isn't enough — I need to carry a min and max bound down from every ancestor." Interviewers on this topic are listening for whether you state the invariant precisely (not "sorted," but "left subtree fully smaller, right subtree fully larger, at every node") and whether you qualify O(log n) with "if balanced" instead of asserting it unconditionally.

Practice problems

1. Search in a Binary Search Tree (Easy — LeetCode: Search in a Binary Search Tree)

Given the root of a BST and a value val, find the node whose value equals val and return the subtree rooted there, or null if it doesn't exist — today's search, returning the node itself instead of a boolean.

2. Insert into a Binary Search Tree (Medium — LeetCode: Insert into a Binary Search Tree)

Given the root of a BST and a value to insert (guaranteed not already present), insert it and return the root of the resulting BST — today's insert, applied directly.

3. Validate Binary Search Tree (Medium — LeetCode: Validate Binary Search Tree)

Given the root of a binary tree, determine if it is a valid BST — today's isValidBST, the min/max-bound version, applied directly.

Quiz

1. What does the BST invariant actually require at each node?

2. Where does insert(root, val) place a brand-new value?

3. Why does checking only "left.val < node.val < right.val" at each node fail to validate a BST correctly?

4. Inserting the values 1, 2, 3, 4, 5 in that exact order into an empty BST produces what shape, and what does that do to insert/search cost?

5. What is the truly accurate worst-case time complexity of BST insert/search, stated in terms of the tree itself?