Binary search trees / ordered maps
A binary search tree keeps one rule at every node: keys in the left subtree are smaller, keys in the right subtree are bigger. Every operation walks a single root-to-leaf path, so it costs O(h), the tree’s height. A plain BST can degrade to a chain (h = n) on sorted input; a self-balancing tree (AVL, red-black) guarantees h = O(log n). That balanced tree is what an ordered map is: a hash map that also knows “smallest”, “next bigger” and “everything between a and b”.
The idea in plain words
Section titled “The idea in plain words”A BST is binary search, frozen into a tree. In a sorted array you look at the middle and throw away half. In a BST the root is the middle: everything smaller is to its left, everything bigger to its right. Each comparison throws away a whole subtree.
The array version is fast to search but slow to change: inserting in the middle shifts everything after it. The tree version only has to hang a new leaf, so it’s fast to change too. You get both, as long as the tree stays bushy.
That “as long as” is the whole story of BSTs:
- Insert keys in random order and the tree is usually bushy: height about 3·log₂ n in the worst path.
- Insert them in sorted order and every key goes right of the previous one. The “tree” is a linked list, and every operation is O(n).
- Self-balancing trees fix this by doing small local repairs (rotations) after inserts and deletes, so the height stays O(log n) no matter the order.
See it
Section titled “See it”Search, insert and delete keys. Then press sorted to load 12 keys in order and watch the tree collapse into a chain, and middle-first to load the same keys so it stays perfectly balanced. The log underneath shows how many comparisons each operation cost.
Things to try:
- Delete
30: it has two children, so its successor (35, the smallest key bigger than 30) takes its place. - Delete
20, a leaf, then40, which now has a single child. - Load sorted, then search for
60. Count the comparisons. Load middle-first and search again.
When to reach for it
Section titled “When to reach for it”| The problem says… | Why an ordered map / BST fits |
|---|---|
| “the next larger / previous smaller key”, “closest value to x” | Floor and ceiling are O(log n) walks. A hash map can’t do this without scanning. |
| “all keys between a and b”, “count in a range” | Find a, then walk in order until b: O(log n + k). |
| “keep it sorted while items arrive and leave” | Insert and delete are O(log n). A sorted array is O(n) per change; a heap can’t delete arbitrary items cheaply. |
| “min and max, with deletions” | Leftmost and rightmost nodes. |
| “iterate in key order” | Inorder traversal visits keys sorted. |
If you only need exact-key lookups, a hash map is faster (O(1) average). Reach for order only when the problem needs order.
How it works
Section titled “How it works”Search and insert
Section titled “Search and insert”- Start at the root. Compare the key with the node’s key.
- Equal: found (or, for insert, already present; a map would overwrite the value).
- Smaller: go left. Bigger: go right. If that child is empty, the key isn’t in the tree, and that empty spot is exactly where an insert hangs the new leaf.
from __future__ import annotationsfrom dataclasses import dataclass
@dataclassclass Node: key: int left: Node | None = None right: Node | None = None
def insert(root: Node | None, key: int) -> Node: if root is None: return Node(key) # 3. fell off the tree: the new key hangs here if key < root.key: # 1. compare, then go to the only side it can be on root.left = insert(root.left, key) elif key > root.key: root.right = insert(root.right, key) return root # 2. equal key: already present, keep it
def contains(root: Node | None, key: int) -> bool: n = root while n: # one comparison per level: O(height) if key == n.key: return True n = n.left if key < n.key else n.right return False
def keys_in_order(n: Node | None) -> list[int]: return keys_in_order(n.left) + [n.key] + keys_in_order(n.right) if n else []
if __name__ == "__main__": root = None for k in [50, 30, 70, 20, 40, 60, 80, 35, 30]: root = insert(root, k) assert keys_in_order(root) == [20, 30, 35, 40, 50, 60, 70, 80] # inorder = sorted assert contains(root, 35) and not contains(root, 45) assert not contains(None, 1) print("ok")import assert from "node:assert/strict";
export interface BstNode { key: number; left: BstNode | null; right: BstNode | null }
export function insert(root: BstNode | null, key: number): BstNode { if (root === null) return { key, left: null, right: null }; // 3. fell off: hang the new key here if (key < root.key) root.left = insert(root.left, key); // 1. go to the only side it can be on else if (key > root.key) root.right = insert(root.right, key); return root; // 2. equal key: already present, keep it}
export function contains(root: BstNode | null, key: number): boolean { for (let n = root; n !== null; n = key < n.key ? n.left : n.right) { // O(height) if (key === n.key) return true; } return false;}
export function keysInOrder(n: BstNode | null, out: number[] = []): number[] { if (n) { keysInOrder(n.left, out); out.push(n.key); keysInOrder(n.right, out); } return out;}
let root: BstNode | null = null;for (const k of [50, 30, 70, 20, 40, 60, 80, 35, 30]) root = insert(root, k);assert.deepEqual(keysInOrder(root), [20, 30, 35, 40, 50, 60, 70, 80]); // inorder = sortedassert.ok(contains(root, 35) && !contains(root, 45));assert.equal(contains(null, 1), false);console.log("ok");Node? root = null;foreach (var k in new[] { 50, 30, 70, 20, 40, 60, 80, 35, 30 }) root = Insert(root, k);if (!KeysInOrder(root, []).SequenceEqual([20, 30, 35, 40, 50, 60, 70, 80])) throw new Exception("inorder = sorted");if (!Contains(root, 35) || Contains(root, 45)) throw new Exception("contains");if (Contains(null, 1)) throw new Exception("empty");Console.WriteLine("ok");
static Node Insert(Node? root, int key){ if (root is null) return new Node(key); // 3. fell off the tree: hang the new key here if (key < root.Key) root.Left = Insert(root.Left, key); // 1. go to the only side it can be on else if (key > root.Key) root.Right = Insert(root.Right, key); return root; // 2. equal key: already present, keep it}
static bool Contains(Node? root, int key){ for (var n = root; n is not null; n = key < n.Key ? n.Left : n.Right) // O(height) if (key == n.Key) return true; return false;}
static List<int> KeysInOrder(Node? n, List<int> output){ if (n is null) return output; KeysInOrder(n.Left, output); output.Add(n.Key); KeysInOrder(n.Right, output); return output;}
class Node(int key){ public int Key = key; public Node? Left, Right;}Delete (Hibbard deletion)
Section titled “Delete (Hibbard deletion)”Delete is the only tricky operation, because removing an inner node leaves a hole that must be filled without breaking the order.
- Zero or one child: replace the node with its child (or with nothing). The child’s whole subtree was already on the correct side of the parent.
- Two children: find the successor, the leftmost node in the right subtree. It is bigger than everything on the left and smaller than everything else on the right, so it can stand in the hole.
- Copy the successor’s key into the node, then delete the successor from the right subtree. It has no left child (it’s the leftmost), so that’s always case 1.
from __future__ import annotationsfrom dataclasses import dataclass
@dataclassclass Node: key: int left: Node | None = None right: Node | None = None
def delete(root: Node | None, key: int) -> Node | None: """Hibbard deletion. Returns the new root of this subtree.""" if root is None: return None # not found: nothing to do if key < root.key: root.left = delete(root.left, key) elif key > root.key: root.right = delete(root.right, key) else: if root.left is None: # 1. zero or one child: splice it out return root.right if root.right is None: return root.left succ = root.right # 2. two children: successor is the while succ.left: # leftmost node of the right subtree succ = succ.left root.key = succ.key # 3. copy it up, then delete it below root.right = delete(root.right, succ.key) # (it has no left child: easy case) return root
def build(keys: list[int]) -> Node | None: def ins(n: Node | None, k: int) -> Node: if n is None: return Node(k) if k < n.key: n.left = ins(n.left, k) else: n.right = ins(n.right, k) return n root = None for k in keys: root = ins(root, k) return root
def inorder(n: Node | None) -> list[int]: return inorder(n.left) + [n.key] + inorder(n.right) if n else []
if __name__ == "__main__": root = build([50, 30, 70, 20, 40, 60, 80, 35]) root = delete(root, 20) # leaf root = delete(root, 40) # one child (35) root = delete(root, 50) # two children: 60 takes the root assert root is not None and root.key == 60 assert inorder(root) == [30, 35, 60, 70, 80] assert inorder(delete(root, 999)) == [30, 35, 60, 70, 80] print("ok")import assert from "node:assert/strict";
interface BstNode { key: number; left: BstNode | null; right: BstNode | null }
/** Hibbard deletion. Returns the new root of this subtree. */export function del(root: BstNode | null, key: number): BstNode | null { if (root === null) return null; // not found: nothing to do if (key < root.key) root.left = del(root.left, key); else if (key > root.key) root.right = del(root.right, key); else { if (root.left === null) return root.right; // 1. zero or one child: splice it out if (root.right === null) return root.left; let succ = root.right; // 2. two children: successor = leftmost of the right subtree while (succ.left) succ = succ.left; root.key = succ.key; // 3. copy it up, then delete it below (no left child: easy case) root.right = del(root.right, succ.key); } return root;}
function ins(n: BstNode | null, k: number): BstNode { if (!n) return { key: k, left: null, right: null }; if (k < n.key) n.left = ins(n.left, k); else n.right = ins(n.right, k); return n;}const inorder = (n: BstNode | null): number[] => (n ? [...inorder(n.left), n.key, ...inorder(n.right)] : []);
let root: BstNode | null = null;for (const k of [50, 30, 70, 20, 40, 60, 80, 35]) root = ins(root, k);root = del(root, 20); // leafroot = del(root, 40); // one child (35)root = del(root, 50); // two children: 60 takes the rootassert.equal(root?.key, 60);assert.deepEqual(inorder(root), [30, 35, 60, 70, 80]);assert.deepEqual(inorder(del(root, 999)), [30, 35, 60, 70, 80]);console.log("ok");Node? root = null;foreach (var k in new[] { 50, 30, 70, 20, 40, 60, 80, 35 }) root = Ins(root, k);root = Delete(root, 20); // leafroot = Delete(root, 40); // one child (35)root = Delete(root, 50); // two children: 60 takes the rootif (root?.Key != 60) throw new Exception("root should be 60");if (!Inorder(root).SequenceEqual([30, 35, 60, 70, 80])) throw new Exception("order");if (!Inorder(Delete(root, 999)).SequenceEqual([30, 35, 60, 70, 80])) throw new Exception("missing key");Console.WriteLine("ok");
// Hibbard deletion. Returns the new root of this subtree.static Node? Delete(Node? root, int key){ if (root is null) return null; // not found: nothing to do if (key < root.Key) root.Left = Delete(root.Left, key); else if (key > root.Key) root.Right = Delete(root.Right, key); else { if (root.Left is null) return root.Right; // 1. zero or one child: splice it out if (root.Right is null) return root.Left; var succ = root.Right; // 2. two children: successor is the while (succ.Left is not null) succ = succ.Left; // leftmost node of the right subtree root.Key = succ.Key; // 3. copy it up, then delete it below root.Right = Delete(root.Right, succ.Key); } return root;}
static Node Ins(Node? n, int k){ if (n is null) return new Node(k); if (k < n.Key) n.Left = Ins(n.Left, k); else n.Right = Ins(n.Right, k); return n;}
static IEnumerable<int> Inorder(Node? n) => n is null ? [] : Inorder(n.Left).Append(n.Key).Concat(Inorder(n.Right));
class Node(int key){ public int Key = key; public Node? Left, Right;}What balanced trees give you
Section titled “What balanced trees give you”A rotation is a local rewiring of three nodes that keeps inorder order but moves one node up and another down. Balanced trees do a few rotations on the way back up after an insert or delete.
| Tree | Rule it keeps | Height bound | Where you meet it |
|---|---|---|---|
| AVL | subtree heights differ by ≤ 1 at every node | < 1.44 · log₂ n | read-heavy in-memory indexes |
| Red-black | color rules: no red-red parent/child, equal black count on every path | ≤ 2 · log₂(n + 1) | .NET SortedDictionary / SortedSet, Java TreeMap, C++ std::map |
| Treap | BST on keys + heap on random priorities | O(log n) expected | the easiest balanced tree to hand-roll (~40 lines) |
| Skip list | not a tree: sorted linked lists with random express lanes | O(log n) expected | Redis sorted sets |
| B-tree / B+ tree | many keys per node, all leaves at the same depth | log_B n | databases and file systems (disk pages) |
Ordered maps per language
Section titled “Ordered maps per language”| Ordered map | Ordered set | Floor / ceiling / range | |
|---|---|---|---|
| C# | SortedDictionary<K,V> (red-black tree, O(log n) everything); SortedList<K,V> (two sorted arrays: O(log n) lookup, O(n) insert, index access) |
SortedSet<T> |
SortedSet.GetViewBetween(lo, hi) then .Min / .Max; SortedList.Keys + List.BinarySearch |
| Python | none in the stdlib. Third-party sortedcontainers.SortedDict (often available, e.g. on LeetCode) |
sortedcontainers.SortedSet / SortedList |
bisect_left / bisect_right on a sorted list (see sorted containers) |
| TypeScript | none. Map keeps insertion order, not key order |
none | sorted array + your own binary search; hand-roll a treap if you need O(log n) inserts |
| Java / C++ (for reference) | TreeMap / std::map |
TreeSet / std::set |
floorKey, ceilingKey, subMap / lower_bound, upper_bound |
import bisect
# Python's stdlib has no balanced tree. Two honest options:# - `sortedcontainers.SortedDict` / `SortedList` (third-party, O(log n)-ish, often allowed)# - a dict for lookups + a sorted key list kept with bisect (O(n) insert, fine for modest n)
def neighbors(sorted_keys: list[int], x: int) -> tuple[int | None, int | None]: """(largest key < x, smallest key > x): the 'what's around x' query an ordered map gives you.""" i = bisect.bisect_left(sorted_keys, x) # first index with key >= x j = bisect.bisect_right(sorted_keys, x) # first index with key > x below = sorted_keys[i - 1] if i > 0 else None above = sorted_keys[j] if j < len(sorted_keys) else None return below, above
if __name__ == "__main__": prices = {30: "c", 10: "a", 20: "b", 50: "e"} keys = sorted(prices) # [10, 20, 30, 50] bisect.insort(keys, 40) # O(log n) to find, O(n) to shift prices[40] = "d" assert keys == [10, 20, 30, 40, 50] assert neighbors(keys, 30) == (20, 40) assert neighbors(keys, 35) == (30, 40) assert neighbors(keys, 5) == (None, 10) assert [prices[k] for k in keys] == ["a", "b", "c", "d", "e"] # iterate in key order print("ok")import assert from "node:assert/strict";
// JS has no ordered map. Map keeps INSERTION order, not key order.// Options: a sorted key array + binary search (below), or hand-roll a treap / skip list.
/** First index whose key is >= x (strict = false) or > x (strict = true). */function bound(a: number[], x: number, strict: boolean): number { let lo = 0, hi = a.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (a[mid] < x || (strict && a[mid] === x)) lo = mid + 1; else hi = mid; } return lo;}
/** [largest key < x, smallest key > x]: the "what's around x" query an ordered map gives you. */export function neighbors(keys: number[], x: number): [number | undefined, number | undefined] { const i = bound(keys, x, false), j = bound(keys, x, true); return [i > 0 ? keys[i - 1] : undefined, keys[j]];}
const prices = new Map<number, string>([[30, "c"], [10, "a"], [20, "b"], [50, "e"]]);assert.deepEqual([...prices.keys()], [30, 10, 20, 50]); // insertion order, NOT sortedconst keys = [...prices.keys()].sort((a, b) => a - b); // numeric comparator: default sort is by stringkeys.splice(bound(keys, 40, false), 0, 40); // O(log n) to find, O(n) to shiftprices.set(40, "d");assert.deepEqual(keys, [10, 20, 30, 40, 50]);assert.deepEqual(neighbors(keys, 30), [20, 40]);assert.deepEqual(neighbors(keys, 35), [30, 40]);assert.deepEqual(neighbors(keys, 5), [undefined, 10]);console.log("ok");// SortedDictionary<K,V>: red-black tree, O(log n) insert/remove/lookup, iterates in key order.var prices = new SortedDictionary<int, string> { [30] = "c", [10] = "a", [20] = "b", [50] = "e" };prices[40] = "d";if (!prices.Keys.SequenceEqual([10, 20, 30, 40, 50])) throw new Exception("key order");
// SortedDictionary has no floor/ceiling. SortedSet<T> does, through a range view.var keys = new SortedSet<int>(prices.Keys);if (Neighbors(keys, 30) != (20, 40)) throw new Exception("around 30");if (Neighbors(keys, 35) != (30, 40)) throw new Exception("around 35");if (Neighbors(keys, 5) != (null, 10)) throw new Exception("around 5");Console.WriteLine("ok");
// (largest key < x, smallest key > x): the "what's around x" query an ordered map gives you.static (int? Below, int? Above) Neighbors(SortedSet<int> s, int x){ int? below = null, above = null; if (s.Count > 0 && s.Min < x) below = s.GetViewBetween(s.Min, x - 1).Max; // view is lazy; Min/Max are O(log n) if (s.Count > 0 && s.Max > x) above = s.GetViewBetween(x + 1, s.Max).Min; // (avoid .Count on views: it walks them) return (below, above);}What it costs
Section titled “What it costs”| Operation | Plain BST, average (random order) | Plain BST, worst | Balanced BST | Hash map | Sorted array |
|---|---|---|---|---|---|
| search | O(log n) | O(n) | O(log n) | O(1) avg | O(log n) |
| insert / delete | O(log n) | O(n) | O(log n) | O(1) avg | O(n) |
| min / max | O(log n) | O(n) | O(log n) (O(1) if cached) | O(n) | O(1) |
| floor / ceiling | O(log n) | O(n) | O(log n) | O(n) | O(log n) |
| range of k keys | O(log n + k) | O(n) | O(log n + k) | O(n) | O(log n + k) |
| in-order iteration | O(n) | O(n) | O(n) | needs a sort | O(n) |
| memory | 2 pointers + key per node | + a color bit or height | tightest |
Every operation is “walk one path”, so everything is O(h). The only question is how big h gets. Slide n and compare:
Variations you’ll meet
Section titled “Variations you’ll meet”- Ordered map vs ordered set. Same tree; the map stores a value next to each key.
- Duplicates. Either store a count per node, or break ties by a secondary key (e.g.
(score, id)) so all keys are unique. - Augmented trees. Store extra data per node and keep it updated through rotations: subtree size gives “k-th smallest” and “rank of x” in O(log n) (an order-statistic tree); subtree max-end gives an interval tree.
- Iterators. Inorder successor without recursion: if there’s a right child, go right once then left all the way; otherwise climb until you come up from a left child.
- Validation. “Is this a BST?” needs a valid
(low, high)range passed down, not just comparing each node with its children.
Pitfalls
Section titled “Pitfalls”- Checking only parent vs child when validating. A node deep in the left subtree must be smaller than every ancestor it’s left of, not just its parent.
- Assuming O(log n) for a plain BST. It’s only true on average. Sorted or nearly sorted input, which is common in real data, gives O(n).
- Recursion depth on a degenerate tree. A 100,000-key chain overflows the stack in a recursive insert. Another reason to use a library tree.
- Duplicate keys with no policy. Decide: reject, overwrite, count, or tie-break.
- TS
Maporder. It’s insertion order. Sorting[...map.keys()]each query is O(n log n) per query. .sort()without a comparator in JS sorts numbers as strings:[10, 9, 1].sort()is[1, 10, 9].- C#
SortedListvsSortedDictionary.SortedListinserts are O(n) (array shifts). Fine for build-once, read-many; bad for heavy churn.
In the interview
Section titled “In the interview”- Ask: do I need order, or just lookup? If just lookup, say “a hash map is enough” and move on.
- Name the guarantee you rely on: “An ordered map, so floor and ceiling are O(log n). In C# that’s
SortedSetwithGetViewBetween; in Python I’d usesortedcontainersif allowed, otherwisebisecton a list and accept O(n) inserts.” - Mention degeneration unprompted: “A hand-rolled BST is O(n) on sorted input, so I’d use a balanced one.”
- Follow-ups to expect: “How would you find the k-th smallest quickly?” (augment with subtree sizes), “What if it doesn’t fit in memory?” (B-tree), “Why would a database prefer a B+ tree over a red-black tree?” (fewer, larger node reads from disk).
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”These are classic textbook exercises, not gym questions:
- Validate that a binary tree is a BST (pass down the allowed range).
- Build a height-balanced BST from a sorted array.
- Find the inorder successor of a node, with and without parent pointers.
- Trim a BST so it only contains keys in
[lo, hi]. - Hand-roll a treap with insert and search, and test it on sorted input.
Go deeper
Section titled “Go deeper”- Sedgewick and Wayne, Algorithms §3.2 Binary Search Trees and §3.3 Balanced Search Trees: Hibbard deletion, floor/ceiling, left-leaning red-black trees.
- CLRS, ch. 12 “Binary Search Trees” and ch. 13 “Red-Black Trees”.
- .NET docs:
SortedSet<T>.GetViewBetweenandSortedDictionary<TKey,TValue>. - Sorted Containers documentation, including why it uses a list of lists instead of a tree.