Binary Search Trees

The invariant that turns a tree into a search structure — and why an unbalanced BST quietly degrades your O(log n) into the O(n) you were trying to avoid.

!!!2/5Theory: 1h 30m8 problems

The BST invariant

A Binary Search Tree is a binary tree with one extra rule imposed on every node, not just its immediate children:

For any node n, every value in n's left subtree is strictly less than n.val, and every value in n's right subtree is strictly greater than n.val.

That "every value in the subtree," not "just the direct children," is the entire subtlety of this topic — it's what separates a real BST from a tree that merely "looks sorted" one level at a time. (LeetCode's constraints usually assume unique values; if duplicates are allowed, you must pick and consistently apply a convention — e.g., duplicates go right — or the invariant becomes ambiguous.)

This single rule is what turns a tree into a search structure: at any node, comparing your target to node.val tells you which entire subtree to discard, the same way binary search discards half an array each step (see the Binary Search topic — a BST is structurally the same divide-and-conquer idea, just materialized as pointers instead of index arithmetic).

Search and insert

Both operations follow identical logic: compare, then recurse (or loop) into exactly one subtree.

def search(root, target): if root is None or root.val == target: return root return search(root.left, target) if target < root.val else search(root.right, target) def insert(root, val): if root is None: return TreeNode(val) if val < root.val: root.left = insert(root.left, val) elif val > root.val: root.right = insert(root.right, val) return root # unchanged if val already present

Notice the return root pattern in insert: at every level of the recursion, the (possibly-unchanged) subtree root gets reattached to its parent. This is the standard idiom for "modify a tree and return the new root" — you'll see it again in delete below, and it generalizes to the "compute something, then reconnect" recursive shape used throughout the Advanced Tree Patterns subtopic.

Delete — the operation everyone gets wrong on the first try

Deletion has three cases, and only the third one is genuinely tricky:

  1. Leaf node — just remove it (return None to the parent).
  2. One child — splice it out by returning that one child up to the parent.
  3. Two children — you cannot simply remove the node; you'd orphan one entire subtree. Instead, replace the node's value with its inorder successor (the smallest value in its right subtree, found by walking node.right all the way left) or equivalently its inorder predecessor (the largest value in its left subtree), then recursively delete that successor/predecessor from its original position — which is now guaranteed to be a case-1-or-2 deletion, since the successor by construction has no left child.
def delete_node(root, key): if root is None: return None if key < root.val: root.left = delete_node(root.left, key) elif key > root.val: root.right = delete_node(root.right, key) else: # found the node to delete if root.left is None: return root.right # 0 or 1 child (right) if root.right is None: return root.left # 1 child (left) successor = root.right # 2 children: find inorder successor while successor.left: successor = successor.left root.val = successor.val # copy value up... root.right = delete_node(root.right, successor.val) # ...then remove the duplicate return root

The reason the successor is safe to promote: it's the smallest value greater than everything in the left subtree, and by definition of "smallest," it has no left child of its own — so deleting it from its original spot is always a trivial case-1/case-2 deletion, never another two-child case.

Why balance matters

Every BST operation above runs in O(h), where h is the tree's height — not O(log n). Those are only the same thing when the tree is balanced (height ≈ log n). Insert values in sorted order with no rebalancing, and you get a tree that's really a linked list in disguise: every node has only a right child, h = n, and search/insert/delete all silently degrade to O(n). This is precisely the recursion-tree intuition from the Big-O & Complexity Analysis topic — a "1 recursive call, shrink by a constant" shape is O(n), not O(log n).

This is also why interviewers care whether you say "O(h)" instead of reflexively saying "O(log n)" — stating the balanced-case bound as if it were guaranteed is a tell that you're pattern-matching rather than reasoning. Self-balancing variants (AVL trees, red-black trees) exist specifically to guarantee h = O(log n) by rebalancing on every insert/delete, but they're out of scope for coding interviews — knowing why they exist is the expected depth.

StructureSearchInsertDeleteNotes
Unsorted arrayO(n)O(1) amortized (append)O(n)No ordering to exploit
Sorted arrayO(log n)O(n) (shifting)O(n) (shifting)Fast search, slow mutation
BST (balanced)O(log n)O(log n)O(log n)Fast search and mutation
BST (degenerate/skewed)O(n)O(n)O(n)Effectively a linked list
Hash Map/SetO(1) avgO(1) avgO(1) avgNo ordering preserved — can't do range queries or "kth smallest"

Validating a BST — the bug almost everyone writes first

The instinctive first attempt checks only the immediate parent-child relationship:

# WRONG — only checks one level, not the full ancestor chain def is_valid_bst_buggy(node): if node is None: return True if node.left and node.left.val >= node.val: return False if node.right and node.right.val <= node.val: return False return is_valid_bst_buggy(node.left) and is_valid_bst_buggy(node.right)

This passes simple cases but fails on a tree like 5 -> (3, 8) where 8's left child is 6: 6 < 8 satisfies the local check, but 6 is in 5's right subtree, where every value must be > 5 — 6 passes that too, so this particular example needs one more level (8's left child being 4, which is < 5) to actually break it. The point stands generally: local checks only compare a node to its direct children, never to the full chain of ancestors that bounds where it's allowed to sit.

The fix is to thread a valid (low, high) range down through the recursion, tightening it as you descend:

def is_valid_bst(node, low=float("-inf"), high=float("inf")): if node is None: return True if not (low < node.val < high): return False return (is_valid_bst(node.left, low, node.val) and is_valid_bst(node.right, node.val, high))

Every node is now checked against the tightest bounds implied by every ancestor on the path from the root, not just its immediate parent — which is exactly the guarantee the BST invariant makes.

Inorder traversal gives sorted order

Because inorder visits left → root → right, and the BST invariant guarantees left-subtree-values < root < right-subtree-values at every level, an inorder traversal of a BST always produces values in strictly increasing order. This single fact is the workhorse of BST problems: "kth smallest," "closest value," "two-sum on a BST," and validation itself can all be reframed as "run inorder traversal and reason about the resulting sorted sequence" (checking it's strictly increasing is an equally valid way to validate a BST).

Complexity summary

OperationBalanced BSTSkewed BSTSpace
Search / Insert / DeleteO(log n)O(n)O(h) recursion stack
Inorder traversal (full)O(n)O(n)O(h)

Common pitfalls

  • Checking only immediate children instead of a valid range — covered above, but worth repeating: this is the single most common bug in this entire subtopic.
  • Strict vs. non-strict inequalities. Decide up front whether duplicates are allowed and where they go (typically the right subtree), and apply that consistently in search/insert/delete/validate — mixing < and <= across your own functions is a common self-inflicted bug.
  • Forgetting to reconnect after delete/insert. Both operations must return the (possibly new) subtree root so the caller can reattach it — forgetting the root.left = insert(root.left, val) assignment silently drops the mutation.
  • Assuming a BST is height-balanced. It isn't, by definition — balance is a separate, optional property. Don't reason about complexity as if h = log n unless the problem guarantees a balanced tree.
  • Using integer overflow-prone sentinels. float("-inf")/float("inf") work cleanly in Python; in languages with fixed-width integers, be careful that your sentinel bounds can't collide with legal node values at the extremes of the input range.

Further Resources (Optional)

Practice Problems

Interview relevancy:!!!Critical — must-have!!Important — highly recommended!Good to have~Niche — rarely asked

Optional Practice (Extra Reps)

For once you've cleared the main set above and want more reps on this pattern. These don't count toward the roadmap's progress stats — solve them purely for your own benefit.