◈ python mapVol 4 · Ch 57/62
Volume 4 Python, from the metal up · chapter 57

57The binary search tree — order that stays searchable

In Chapter 56 we built the binary tree. Nodes on the heap, two children apiece, hierarchy for its own sake. Here we add one ruthless rule to that skeleton, and it turns into a search engine. Here is the tension it resolves. A sorted array can binary-search in O(log n). But insert one value in the middle and every element after it shifts, an O(n) tax on every change. A hash table gives O(1) lookup, and then throws order in the bin. It cannot tell you the smallest key, or every key between 100 and 200. The binary search tree is the deal that keeps both. It stays sorted and stays fast to change, by holding order not in memory positions but in pointers. The one question we keep asking is this: what does it cost to keep a collection sorted while it is still changing under your hands? By the end you will insert, search, and delete in a BST. You will see why one careless insertion order can rot it into a slow linked list. And you will know the fix that runs every database index on Earth.

iolinked · chapter 57 — the checkpoints7 steps
$ sections covered in The binary search tree — order that stays searchable
01One rule, wired into every pointer
02Search: a binary search that walks pointers
03Insert: search for where it isn't, then hang a node
04The catastrophe: sorted input collapses the tree into a list
05The fix humans invented: trees that rebalance themselves
06Delete: the operation with a twist
07When to reach for a tree — the access pattern is the answer

01One rule, wired into every pointer

Let's start from what we already have. When we built the binary tree back in Chapter 56, each node lived on the heap. It held its data and up to two child pointers, left and right. That was hierarchy, and nothing more. A BST is that same skeleton plus a single, ruthless invariant. An invariant is a rule that must hold at every node, without exception: everything in my left subtree is less than me, and everything in my right subtree is greater than me. Watch how far that reaches. Not just my two children, but my entire left subtree, all the way down, and my entire right subtree. That one promise is what turns a plain tree into a search structure.

Why does that promise reach the whole subtree and not just the two children? Because the rule compounds down every level. My left child is less than me, and everything in that child's own right subtree is less than the child — so by simple transitivity it is less than me as well. Chain that reasoning down rung by rung and the ordering holds for the entire left subtree, all the way to the leaves. That is the quiet magic of the design: each node only ever checks its own two children, yet the order it enforces spans the whole tree beneath it. A cheap local rule buys a global guarantee.

★ YOU ALREADY RUN THIS · the-dictionary-you-never-readyou have never read a dictionary — you have only ever halved one
You want to check what quixotic means. You do not start at A. You take the fat book, crack it somewhere near the middle, and land on M — and in that one glance every page to the left of your thumb is out of the hunt. You fan forward, overshoot to S, and half of what was left dies too. Four or five thumbs on two thousand pages and you are on the word. Now the half nobody notices: nobody re-alphabetised that book to make room for rizz. It went in at R, on the day it arrived, at the one place a reader would think to look for it.
crack it near the middle, land on M, and everything left of your thumb is goneone comparison at the root, and an entire subtree leaves the hunt — key < node.key or it doesn't
overshot to S? fan back. Four or five thumbs, on two thousand pagesO(h) hops, and h ≈ log₂n while the tree stays bushy — 1,000 keys answered in about 20 compares
rizz went in at R on the day it arrived, not on the last pageinsert = search for where it isn't, then hang the node there. Put it where you'd look for it, and the book cannot become unsorted
on paper, slotting a word in resets every page after itthe sorted array's O(n) insert. Chapter 43's shower dial halved a range that could never grow, and chapter 31's phone book was printed once — a BST is that phone book, staying alphabetised while names keep arriving
a “dictionary” where each new word is simply stapled behind the lastsorted input, so every insert turns right: the spine. Chapter 50's treasure hunt wearing a tree costume — 999 hops for 1,000 words
pin it: you have never read a dictionary — you have only ever halved one, and the halving works only because every word was filed where a reader would look, on the day it arrived.

Because the rule holds everywhere, a BST has a hidden talent you first met in the binary-tree chapter. Walk it in-order, which means left subtree, then the node, then right subtree. The keys come out sorted, for free, with no sorting step. We built one and walked it:

invariant.pypython
class Node:
    __slots__ = ("key", "left", "right")   # three references, nothing else
    def __init__(self, key):
        self.key  = key
        self.left = None                    # < me
        self.right = None                   # > me

root = None
for k in [4, 2, 6, 1, 3, 5, 7]:            # inserted in this order
    root = insert(root, k)

out = []
inorder(root, out)
print(out)          # [1, 2, 3, 4, 5, 6, 7]   ← sorted, though we never sorted

Line by line: each Node is three references and nothing more — the key, a left pointer, and a right pointer. We insert seven keys in a jumbled order, then run an in-order walk, and it prints them back as 1 2 3 4 5 6 7. That sorted output falls straight out of the invariant, because "smaller stuff is always on the left" is exactly what an in-order walk visits first. Order was never stored anywhere. It is implied by where the pointers go.

the logical tree — what you picture 30 15 50 10 20 60 green edge = left (<) red edge = right (>) Every node obeys one rule: left subtree < node < right subtree. Nothing here says where any node lives in RAM. the heap — where those nodes actually sit 0x1A00 50 0x1C80 0x1A30 10 0x1A60 30 0x1AC0 0x1A00 0x1A90 20 0x1AC0 15 0x1A30 0x1A90 0x1C80 60 addr key left right The root's two pointers hold addresses 0x1AC0 and 0x1A00 — following them is the whole tree. The records sit in scrambled address order; the shape is purely in the left/right fields, not in how they're laid out in RAM. Each __slots__ record = 56 bytes: 32 of Python object overhead + 24 of pointers (key, left, right). That 24-byte pointer trio is the real payload.
Fig — a BST is scattered records held together by addresses. The tidy triangle on the left is a fiction your mind draws; in RAM the nodes sit wherever the allocator put them, and only the left/right pointers encode the shape. Chasing a pointer means jumping to a random address — remember that; it comes back to bite us.

Now for the memory bill, run for real: a node with __slots__ is the leanest a Python BST node gets, and we measured what one actually costs:

mem_arrow.pypython
import sys
sys.getsizeof(Node(30))          # 56   ← 16 GC + 16 header + 3×8 pointers
# a plain class (no __slots__):
sys.getsizeof(dict_node)         # 48   object…
sys.getsizeof(dict_node.__dict__)# 296  …plus its instance dict = 344 total
# the same 1000 ints in a Python list:
sys.getsizeof(list(range(1000))) # 8056 → ~8 bytes per reference

Read the tax. Every BST node costs 56 bytes at best, or 344 if you skip __slots__. Of that, only 24 bytes is the data-carrying part, which is three 8-byte references. The rest is Python's per-object overhead. That is Vol 1's ~32-byte object header, here split as a 16-byte GC header and a 16-byte object head. A plain list stores those same references at 8 bytes each in one contiguous block. So a BST is roughly 7× heavier per element than a list. You are paying that overhead to buy something a list can't give you: sorted order that survives insertion and deletion in O(log n). Whether that trade is worth it is the whole question of the chapter.

Put a real number on that 7× overhead so it stops being abstract. Store 1000 keys and the BST spends 1000 × 56 bytes, which is 56,000 bytes just for nodes. The same 1000 references packed into a list cost 1000 × 8, a mere 8,000 bytes. Divide and the ratio pops right back out: 56,000 ÷ 8,000 is exactly 7. You are handing the allocator 48,000 extra bytes to buy one thing the list cannot give you — sorted order that survives every insert and delete. On a laptop with gigabytes of RAM that is loose change; on an embedded board with 64 KB of memory it may be the whole design decision.

Wait —
If the order is only implied by the pointers, how does searching actually use it? Where does the "binary" in binary search tree come from?

The invariant isn't decoration. It's a machine for cutting the problem in half at every step →

Here is where the invariant pays off. To find a key you start at the root and compare, and there are three outcomes. Equal means you found it, so stop. Target is smaller means the invariant guarantees it can only be in the left subtree, so the entire right subtree is eliminated in one comparison. Target is larger sends you right, and the whole left subtree is gone in the same stroke. Each comparison throws away one of the two subtrees, and that is binary search, the halving technique from Volume 3. But instead of indexing into a sorted array, you are following pointers down a tree:

Try one search by hand on a tree whose root is 50. Say the left subtree holds smaller keys and the right holds larger all the way down, and we are hunting for 40. Compare 40 with 50. It is smaller, so in one step we discard the entire right half of the tree and step left. We land on, say, 30, and now we compare 40 with 30. This time it is larger, so we discard 30's left subtree and step right. Each comparison is cheap, and each one deletes a whole region of the tree from the hunt. We are not scanning keys one by one — we are halving the field at every hop.

search.pypython
def search(root, target):
    node = root
    while node is not None:          # until we fall off the tree
        if target == node.key:
            return node              # found — stop
        elif target < node.key:
            node = node.left         # smaller → whole right subtree gone
        else:
            node = node.right        # larger  → whole left subtree gone
    return None                      # ran out of tree → not present

Line by line: we hold a moving pointer node, starting at the root. Each loop is one comparison. If equal, done. If target < node.key we step left; otherwise right. When node becomes None we've walked off a branch tip without a match — the key isn't there. The number of loops is exactly the number of levels we descend: the path length from root to where the key is (or would be).

So search costs O(h), where h is the tree's height, the longest root-to-leaf path. And here is the crux. If the tree is balanced, each level roughly doubles the node count: 1, 2, 4, 8, and so on. So n nodes fit in about log₂ n levels, and search is O(log n). We built a million-node tree and searched it:

search.pypython
# tree of 1,000,000 keys, inserted in random order:
#   height              = 50        (log2(1,000,000) ≈ 19.9)
#   search steps for 20 random keys: min 17, max 35, avg 26.1

A million keys, and a lookup touches on average 26 nodes, not a million — that is the log at work, with each of those 26 comparisons halving what remained. The height here is 50 rather than the ideal 20, because a randomly-built BST is only roughly balanced. More on that, and on how to force it down to 20, is coming shortly.

Let's make that log concrete, because it is the whole reason the tree is fast, and the trick to hold onto is doubling. One level holds 1 node, two levels hold up to 3, and ten levels hold up to 1023, since 2^10 is 1024. Keep doubling and 2^20 reaches 1,048,576, which is just past a million. So a perfectly balanced million-key tree is about 20 levels tall, and a lookup makes at most 20 comparisons. That is why 26 nodes can answer a million-key query: each step down does not shave off one candidate, it throws away half of what is left.

Hold those two numbers side by side, because the gap between them is the entire payoff. A flat linear scan of a million keys is up to a million comparisons in the worst case. The balanced tree answers the exact same question in about 20. That is a fifty-thousand-fold cut in work — 1,000,000 ÷ 20 is 50,000 — bought with nothing but the shape of the pointers. And the trade only sweetens as the data grows. Every level you add doubles the keys the tree can hold, so the cost creeps up by one comparison while the capacity leaps by a factor of two.

InteractivePick a key — watch the search halve the tree
searching…
7
Each step compares once and drops half the remaining tree. 11 nodes, at most 4 comparisons — that's log₂.
Height is the only number that matters
Every BST operation — search, insert, delete — costs O(h). All the drama in the rest of this chapter is about one thing: keeping h near log n instead of letting it swell toward n. Get the height right and the tree is a scalpel; get it wrong and it's a linked list wearing a costume.

Search reads the tree. Insert has to grow it — and it turns out inserting is just searching for the empty spot where the key belongs →

03Insert: search for where it isn't, then hang a node

Here's the trick that makes insert almost free: it reuses search entirely. To add a key, you search for it, comparing and stepping left or right, until you fall off the bottom of the tree at a None. That spot is exactly where the key belongs. The path you walked is the same path a future search will walk to find it. So you allocate a new node and hang it there. One walk down, one link:

insert.pypython
def insert(root, key):
    if root is None:                 # empty spot found → this is the place
        return Node(key)
    if key < root.key:
        root.left  = insert(root.left,  key)   # belongs somewhere on the left
    elif key > root.key:
        root.right = insert(root.right, key)   # …or somewhere on the right
    # key == root.key: already present, do nothing (a BST holds a set)
    return root

Line by line: hit a None and you have arrived. Return a fresh node, and the caller links it in. Otherwise compare and recurse into the correct subtree, re-attaching the possibly grown subtree on the way back up. Equal keys are ignored here, so this BST behaves as a set. The cost is the length of the walk, which is O(h) again, the same descent as search, plus one node allocation. Crucially, the existing nodes never move. Unlike a sorted array, inserting into the middle of the key range shifts nothing. You only rewire one None into a pointer.

Why is the freshly allocated node guaranteed to land somewhere legal? Follow the logic of the walk that got you there. At every node along the way you compared and turned toward the side the key belongs on, so by the time you fall off the bottom at a None, every ancestor above you has already agreed the key sits on its correct side of them. The empty slot you arrived at is therefore the one place in the whole tree that keeps the invariant intact. That is why insert can stay blissfully careless about the rest of the tree and never break it — the search already did the checking on the way down.

Walk one insert by hand to feel it. Start with an empty tree and add 50. It becomes the root, since there is nothing to compare against. Now add 30. Compare with 50, 30 is smaller, so step left, and the left slot is empty, so 30 hangs there. Add 70. Compare with 50, 70 is larger, so step right into the empty slot. Add 40. Compare with 50 and go left to 30, then compare with 30 and go right into its empty slot. Four keys, four short walks, and no existing node ever moved. That is the whole operation.

insert(5): walk the search path for 5 until you fall off, then link 8 3 12 5 step 1: 5 < 8 → go LEFT to 3 step 2: 5 > 3 → go RIGHT 3's right pointer is ∅ → hang 5 there Cost = length of the walk = O(h). No existing node moved; one ∅ became a pointer.
Fig — insertion is a search that ends in a link. You descend the exact path a later search(5) will take — left at 8, right at 3 — and plant the node at the empty slot where that search would otherwise fail. That's why the tree stays consistent: put-it-where-you'd-look-for-it.
The design insight: a rule that maintains itself
Notice what the human who invented this got right. They didn't bolt "keep it sorted" on as a separate step you must remember to run. They chose an invariant that every insert preserves automatically — put a key where its own search path leads, and the tree cannot become unsorted. The order is a consequence of the insert rule, not a chore layered on top. Designing structures so their invariant is self-repairing is a move you'll reuse everywhere.

Insert always plants the node at the end of its search path. Which raises a dangerous question: what if every key's search path leads to the same side — every single time? →

04The catastrophe: sorted input collapses the tree into a list

Everything above assumed the tree is bushy, short and wide, but nothing in insert guarantees that. The shape is decided entirely by the order keys arrive in, so feed a BST already-sorted data and watch what happens. Insert 1 and it becomes the root. Insert 2, which is bigger, so go right. Insert 3, bigger than 1 so go right, then bigger than 2 so go right again. Every new key is the largest so far, so every insert goes right, right, right, and the tree grows a single right-leaning limb with no left children at all. We ran it and measured the heights:

degenerate.pypython
# insert 1,2,3,4,5,6,7 in that order → height = 7   (a chain of 7)
# insert 4,2,6,1,3,5,7 (jumbled)      → height = 3   (a bushy tree)

#        n   sorted-insert height   random-insert height   log2(n)
#       15                     15                      8      3.9
#     1000                   1000                     20     10.0
#    10000                  10000                     30     13.3
#    30000                  30000                     36     14.9

Read the columns of horror. Sorted insertion gives height exactly n: every node has a single child, forming one straight line. That is no longer a tree at all. It is a linked list that happens to store its "next" pointer in the right field, and a linked list means search is O(n) — to find the last key you walk all n nodes. The BST's entire reason for existing, O(log n), is gone. The random column shows what balance would give you instead: 1000 keys in height 20, and 30000 keys in height 36, hugging log₂ n. Same keys, same code, same invariant, and yet a 50× difference in height, decided only by arrival order.

You might now worry that every real tree collapses. It does not, and here is the honest reason. If keys arrive in random order, each new key is about as likely to go left as right at every node, so the tree tends to stay bushy on its own. That is why the random column sat near log₂ n without any special effort. The danger is not randomness. It is structure in the input: already-sorted data, reverse-sorted data, or a long run of increasing keys. Those patterns are common in the wild, which is exactly why nobody ships a plain BST for serious work. They ship one that refuses to lean, no matter what order you feed it.

InteractiveSorted vs. shuffled — watch the tree collapse into a chain
sorted insert 1..n balanced insert height = n height = ⌈log₂(n+1)⌉
7
Same n, same keys. The only difference is arrival order — and it decides everything.
↺ The thing people get backwards
People file "binary search tree" under "O(log n) structure." It is not. A plain BST is an O(h) structure, and h can be anything from log n to n depending on the order you fed it. The famous O(log n) is a best case that a naive BST does not defend. And the worst case isn't some exotic adversarial input — it's the most natural input imaginable: already-sorted data, the exact thing you so often have. That is why a raw BST is almost never what production code uses. The next section is the fix, and it's the reason balanced trees exist at all.

Myth

"A binary search tree searches in O(log n)."

Reality

A BST searches in O(h). Balanced, h ≈ log n. Fed sorted input, h = n and it degrades to a linked-list scan — O(n). Balance is not automatic; something has to enforce it.
Sorted input is the normal case in production
This isn't a contrived worst case you'll never meet. The data real systems ingest is overwhelmingly ordered: rows arrive by auto-increment primary key, events by timestamp, log lines by time, IDs by creation. A naive BST fed that stream builds a perfect chain and silently rots to O(n) — and it passes every test, because tests use small, shuffled fixtures. It ships, then falls over under real traffic. This exact trap is why "just use a plain BST" is almost always the wrong answer, and why the balanced variants below exist.

We even timed the collapse, searching for the deepest key in each tree:

ex2_hint.pypython
#   n     degenerate (chain)   balanced (random)
#  1000        115 µs               1.06 µs
#  2000        231 µs               1.06 µs
#  4000        462 µs               0.55 µs

The degenerate column doubles as n doubles, the fingerprint of O(n). The balanced column just sits near a microsecond no matter the size, the fingerprint of O(log n). At n=4000 the balanced tree answers roughly 800× faster for the same keys. These timings are from this machine and will vary run to run. But the shapes, one line climbing and one flat, are the invariant truth.

NOW BUILD THE TREE, THEN FEED IT SORTED DATA AND WATCH IT ROTinsert, contains — and the measurement that explains why nobody ships a plain BST
The drill. Build the BST yourself on a @dataclass(slots=True) node holding key, left, right. Write insert(root, key) (recursive, returning the possibly-new subtree) and contains(root, key) — and have contains return the number of comparisons it made, because that number is the point of the whole exercise.

Then break it on purpose. Feed one tree random.shuffled keys 0..999 and feed a second tree range(1000). Measure the height of each and the compares that contains(999) costs on each. Predict the two heights before you run it; the gap is bigger than almost anyone guesses.

The trap inside the drill. Your recursive insert will not survive its own experiment. One thousand sorted keys means recursing one thousand deep, and CPython's default limit is about a thousand frames — the measurement crashes before it can print. So write a second, iterative build() for the experiment, and an iterative height() carrying its own stack, then keep the recursive insert alongside and show it failing on 3,000 sorted keys. Both facts are the lesson: the degenerate tree is slow and it is a correctness hazard.
show the solution
from dataclasses import dataclass
from typing import Optional
import random, sys

@dataclass(slots=True)
class Node:
    key: int
    left:  Optional["Node"] = None
    right: Optional["Node"] = None

def insert(root, key):
    """The recursive version: walk to the empty slot, then hang a node there."""
    if root is None:
        return Node(key)                       # the slot - this is the place
    if key < root.key:
        root.left = insert(root.left, key)
    elif key > root.key:
        root.right = insert(root.right, key)
    return root                                # equal: a BST holds a set

def contains(root, key):
    """Iterative on purpose - a spine must not be able to blow the stack."""
    node, compares = root, 0
    while node is not None:
        compares += 1
        if key == node.key:
            return True, compares
        node = node.left if key < node.key else node.right
    return False, compares

def build(keys):
    """Iterative insert, so 1000 sorted keys cannot raise RecursionError."""
    root = None
    for k in keys:
        if root is None:
            root = Node(k); continue
        cur = root
        while True:
            if k < cur.key:
                if cur.left is None:  cur.left  = Node(k); break
                cur = cur.left
            elif k > cur.key:
                if cur.right is None: cur.right = Node(k); break
                cur = cur.right
            else:
                break
    return root

def height(root):
    """Explicit stack: measures a 1000-deep spine without recursing 1000 times."""
    best, stack = -1, [(root, 0)]
    while stack:
        n, d = stack.pop()
        if n is None:
            best = max(best, d - 1); continue
        stack.append((n.left, d + 1)); stack.append((n.right, d + 1))
    return best

random.seed(57)
print("--- the two shapes, same 1000 keys ---")
keys = list(range(1000))
shuffled = keys[:]; random.shuffle(shuffled)
for label, feed in (("random order", shuffled), ("sorted order", keys)):
    root = build(feed)
    found, compares = contains(root, 999)
    print(f"  {label}: height = {height(root):4}   contains(999) -> {found},"
          f" {compares} compares")

print("\n--- height vs n ---")
for n in (100, 1000, 10_000):
    s = list(range(n)); random.shuffle(s)
    print(f"  n={n:6,}   random height {height(build(s)):5}"
          f"   sorted height {height(build(range(n))):6}"
          f"   log2(n) = {(n-1).bit_length():2}")

print("\n--- and the recursive insert, fed sorted input ---")
print("  sys.getrecursionlimit() =", sys.getrecursionlimit())
try:
    root = None
    for k in range(3000):
        root = insert(root, k)
    print("  survived 3000 sorted inserts")
except RecursionError as e:
    print("  RecursionError:", e)

# ---------------- OUTPUT, verbatim (CPython 3.12.7) ----------------
# --- the two shapes, same 1000 keys ---
#   random order: height =   23   contains(999) -> True, 11 compares
#   sorted order: height =  999   contains(999) -> True, 1000 compares
#
# --- height vs n ---
#   n=   100   random height    12   sorted height     99   log2(n) =  7
#   n= 1,000   random height    22   sorted height    999   log2(n) = 10
#   n=10,000   random height    31   sorted height   9999   log2(n) = 14
#
# --- and the recursive insert, fed sorted input ---
#   sys.getrecursionlimit() = 1000
#   RecursionError: maximum recursion depth exceeded
InteractivePush the key-count to a quintillion — watch the comparison count barely move
store N keys in a balanced tree — to find any one of them, how many comparisons? N — KEYS STORED 8.6 billion ≈ every human being alive A BALANCED TREE FINDS ANY KEY IN… 34 comparisons — each one halves what's left. A SORTED-INPUT CHAIN WOULD COMPARE ALL… 8.6 billion — 253 million× more work. Bar is a log scale: even so, the green (balanced) cost is a sliver of the chain's.
8.6 billion
Drag from a thousand keys up to 9.2 quintillion (more than the grains of sand on Earth). The green number — the balanced tree's whole cost — creeps from 11 to just 64. That's what O(log n) means: every time N doubles, you pay exactly one more comparison. The chain pays with its whole length.
The tall tree bites twice
A degenerate BST doesn't just search slowly — it breaks recursive code that touches it. A recursive height or in-order walk on a chain of 3000 nodes recurses 3000 deep, and Python's default recursion limit is ~1000: you get a RecursionError mid-traversal. We hit exactly this writing the measurements for this chapter, and had to rewrite the walks with an explicit stack. It's one more reason "keep the height ≈ log n" is a correctness concern, not just a speed one.

A structure whose whole value can be destroyed by sorted input is a structure begging for a guardian. Humans built one →

05The fix humans invented: trees that rebalance themselves

The degeneracy problem has a clean statement: after enough one-sided inserts, the tree gets tall. So the fix is a structure that notices when it's leaning and straightens itself back out, a self-balancing tree. The key mechanical trick is the rotation. A rotation is a small, local rewiring of three or four pointers. It lifts a deep subtree up and pushes a shallow one down, without breaking the left<node<right invariant. A rotation is O(1), because it touches a constant number of pointers. It changes the tree's shape but not its sorted meaning.

Here is a rotation in the flesh, on the leaning chain the figure shows. We have 30 at the root, its left child 20, and 20's left child 10. To straighten it, we rotate 20 up into the root's place. Now 30 becomes the right child of 20, and 10 stays as 20's left child. We rewired a handful of pointers and nothing else. Read the new tree in-order and you still get 10, 20, 30, exactly as before. The shape got shorter, from three levels down to two, but the sorted meaning never budged. That invariance is why a self-balancing tree can rotate freely after every insert.

leaning: height 3 30 20 10 rotate right 3–4 pointers rewired, O(1) balanced: height 2 20 10 30 in-order still 10, 20, 30 — sorted meaning untouched
Fig — a rotation trades shape for balance, not order. The leaning chain 30→20→10 becomes the balanced tree rooted at 20. In-order is still 10, 20, 30. Self-balancing trees fire rotations automatically after inserts and deletes to keep height ≈ log n.

Two famous designs wrap rotations in a self-balancing policy. You use their descendants constantly, so meet them by name. An AVL tree keeps every node's two subtree heights within 1 of each other. The moment an insert violates that, it rotates. It is the strictest, so it is the shortest and the fastest to search. A red-black tree is looser. It colours nodes red or black and enforces rules that keep the longest path at most twice the shortest. That means fewer rotations on insert and delete, a better all-round trade. We stay at the concept here. The promise is "height stays O(log n)", and the exact rotation cases are a topic of their own. Red-black trees are not academic trivia. They are the ordered map and ordered set in your standard library: C++'s std::map/std::set, Java's TreeMap/TreeSet, and the Linux kernel's process scheduler and virtual-memory areas. Every time you iterate a Java TreeMap in sorted order, you are walking a balanced BST in-order.

One assumption has been quietly holding all of this up: the tree lives in RAM, where chasing a pointer costs a few nanoseconds and you can afford 20 or 30 of them per lookup. Change that single assumption and the whole design shifts under your feet. Put the tree on disk — a database index far too big to fit in memory — and every pointer you follow can become a physical read from storage. Now the number that decides your speed is not comparisons but disk reads, and a skinny binary tree, branching only two ways per level, forces far too many of them. That change of venue, from memory to disk, is exactly what summons the next structure.

🌍 Where you meet this — and why databases chose a cousin
The single biggest deployment of balanced search trees is the database index. When you put an index on a column and query WHERE age BETWEEN 30 AND 40, the engine is walking a search tree — but not a binary one. It uses a B-tree (and its variant the B⁺-tree), a search tree where each node is fat: instead of one key and two children, a node holds hundreds of keys and hundreds of child pointers. PostgreSQL, MySQL/InnoDB, SQLite, Oracle, and virtually every filesystem (NTFS, HFS+, ext4's directory index) store their indexes as B-trees. Ordered maps in memory use binary balanced trees; anything backed by disk uses B-trees. The reason is the next callout — and it's pure hardware.

Why fat nodes for disk? This is mandate #3, how the machine gets the benefit. It is the same cache story from Volume 3, one level down. Reading from a disk, even an SSD, happens in fixed blocks of typically 4 KB or more. And a single disk seek is glacial next to RAM, hundreds of thousands of times slower. A binary tree of a billion keys is ~30 levels deep. So a lookup could be 30 separate disk seeks, which is catastrophic. A B-tree sizes each node to fill exactly one disk block, packing perhaps 100–500 keys into it. Now the tree is only 3–4 levels deep for a billion keys, because each level branches 100+ ways instead of 2. A lookup is 3–4 block reads instead of 30 seeks. The design move is simple: match the node to the transfer unit of the storage. Pointer-chasing a skinny binary tree wastes almost the entire block it drags in. A B-tree makes every expensive block read carry hundreds of useful keys.

The fan-out arithmetic is worth doing once. Suppose each B-tree node holds 200 keys, which gives it about 200 children. One level reaches 200 nodes, and two levels reach 200 × 200, or 40,000. Three levels reach 200 × 40,000, which is 8 million, and four levels reach 1.6 billion, comfortably past a billion keys. That is the whole magic: widen each node and the depth collapses. Four block reads, and you have found your row in a billion.

binary tree — 2 children/node → deep → many seeks ~30 levels for 1e9 keys ≈ 30 disk seeks / lookup B-tree — each node = one disk block, 100s of keys · · · 200 keys · · · 200 keys 200 keys 200 keys 3–4 levels for 1e9 keys → 3–4 block reads match the node size to the disk block — turn seeks into a handful of reads
Fig — the B-tree fans out to stay shallow. A binary tree branches 2 ways and grows ~30 levels deep for a billion keys; a B-tree branches hundreds of ways and stays 3–4 levels deep, so a lookup costs a few disk reads instead of dozens of seeks. Height is everything, and width buys height.
The deeper cut: why Python doesn't ship a balanced BST

You may have noticed there's no tree in Python's standard library, no built-in ordered map. That is deliberate. For in-memory ordered work, Python leans on two other tools. First is the dict, which is O(1), and since 3.7 preserves insertion order, though not sorted order. Second is the bisect module, which keeps a plain list sorted and binary-searches it in O(log n), paying O(n) only on insert to shift the array. For genuinely dynamic sorted data at scale, the popular third-party sortedcontainers library is the pragmatic winner. And here is the twist: it does not use a balanced BST at all. It uses a list of short lists, which are cache-friendly contiguous chunks. It beats textbook tree implementations in practice precisely because of the memory-locality point above. Pointer-chasing scattered nodes stalls the CPU, while streaming through contiguous arrays keeps the cache hot. The BST is the idea. On real hardware, flatter and more contiguous often wins.

Search, insert, and balance handle growing the tree. One operation is still missing, and it's the only one with a genuine puzzle in it: taking a node out

06Delete: the operation with a twist

Deleting from a BST has to preserve the invariant, and that makes it the one operation with real cases. Find the node (an O(h) search), then handle three situations by how many children it has:

Make the three cases concrete with a small tree. Say the root is 50, with 30 on the left and 70 on the right, and 70 has one child 60. Deleting 30 is the easy case. It is a leaf with no children, so we null out the pointer to it and we're done. Deleting 70 is the one-child case. It has only the child 60, so we splice 60 up into 70's place, and the branch stays legal. The hard case is deleting 50, the root, which has two children. We cannot just null it, and we cannot pick either child, because that would strand the other subtree. That is why we need one specific replacement value, and the successor is what gives it to us.

Why the successor works: the smallest key on the right is larger than everything on the left, and smaller than everything else on the right. So it is the unique value that can sit in the vacated spot without breaking left<node<right. We ran all three cases and checked the in-order output stayed sorted after each:

delete.pypython
def delete(root, key):
    if root is None: return None
    if key < root.key:   root.left  = delete(root.left,  key)
    elif key > root.key: root.right = delete(root.right, key)
    else:                                     # found the node to remove
        if root.left  is None: return root.right   # 0/1 child
        if root.right is None: return root.left    # 1 child
        succ = root.right                          # 2 children:
        while succ.left is not None:               #   find in-order successor
            succ = succ.left                       #   (leftmost of right subtree)
        root.key = succ.key                        # overwrite, don't unlink
        root.right = delete(root.right, succ.key)  # delete the successor
    return root

Line by line: the first two branches are just search, recursing left or right and re-linking. Once found, if a child is missing, return the other child. That covers the leaf and one-child cases in two lines. With two children, walk right then left-to-the-end to reach the successor. Copy its key up into the current node, and delete the successor from the right subtree. The output confirmed order held through every case:

delete.pypython
# start:            [1, 2, 3, 4, 5, 6, 7, 8, 9]
# delete leaf 1:    [2, 3, 4, 5, 6, 7, 8, 9]
# delete 7 (1 child)[2, 3, 4, 5, 6, 8, 9]
# delete 3 (2 kids) [2, 4, 5, 6, 8, 9]   ← 3 replaced by successor 4
# delete root 5     [2, 4, 6, 8, 9]      ← new root is successor 6

Every line is still perfectly sorted. The invariant survived deletion of a leaf, a one-child node, a two-child node, and even the root. All of it is O(h): a search to find the node plus a walk to the successor, both bounded by the height. In a balanced tree that is O(log n), and in that degenerate chain, O(n). So the same tax hangs over delete as over everything else in the tree.

delete 5 (two children) → copy up successor 6, then delete 6 5 3 8 6 6 = leftmost of 5's right subtree = the in-order successor of 5 copy 6 up, drop old 6 6 3 8 in-order unchanged: 3, 6, 8 — the successor is the only key that fits the hole
Fig — deleting a two-child node is a key swap, not a surgery. Rather than untangle two subtrees, you promote the in-order successor (leftmost of the right subtree) into the vacancy — the one value guaranteed to keep everything sorted — then delete it from below, where it's an easy case.
Wait —
A hash table gives O(1) lookup and a BST gives O(log n) — slower. So why would anyone ever choose the tree?

That question is the whole point of learning structures. The answer is the last idea in the chapter →

07When to reach for a tree — the access pattern is the answer

A hash table wins on a single point lookup, hands down. "Is key X here, and what's its value?" answers in O(1) versus O(log n). If that is all you do, use the dict, and don't reach for a tree to feel clever. The tree earns its keep the moment your questions become ordered ones. Those are the exact questions a hash table, which scatters keys to random slots, physically cannot answer without scanning everything:

That's the transferable move, mandate #5: choosing the structure is choosing which questions are cheap. If all you do is point lookups, reach for a hash. If you need ordered questions on data that keeps changing, reach for a balanced tree. And if the ordered data rarely changes, you don't even need a tree — sort an array once and binary-search it, as in Volume 3, because a contiguous array streams through cache far faster than a tree of scattered nodes. The decision table:

hash table point lookup only O(1), no order balanced BST ordered + changing O(log n), ranges sorted array ordered + static O(log n), cache-hot B-tree ordered + on disk few block reads ask one question first: Do I need keys in ORDER — ranges, neighbours, min/max, sorted walks? no → hash table (fastest point lookup) yes → tree family; then: changing? in memory? on disk? → pick the branch above
Fig — the choice is the skill. Four structures answer "find my data," but each makes a different question cheap. Naming the access pattern first — point vs. ordered, static vs. changing, memory vs. disk — picks the structure almost automatically. That reflex is being good at data structures.
THE STDLIB TOOLBELT · bisect — order kept, without a treePython ships no balanced BST. It ships the array that never stops being sorted
import bisect bisect.bisect_left(seq, x) # index of the first item >= x O(log n) bisect.bisect_right(seq, x) # ...the first item > x bisect.insort(seq, x) # find the slot AND insert it O(n) bisect.insort(seq, x, key=f) # 3.10+, same key= as sorted() # the membership test bisect deliberately does NOT ship: i = bisect.bisect_left(seq, x) found = i < len(seq) and seq[i] == x # the range query a hash map cannot answer at all: seq[bisect.bisect_left(seq, lo) : bisect.bisect_right(seq, hi)]
bisect_left vs bisect_rightThey differ only on equal keys: left returns the index before the run of equals, right the index after. Measured on [1, 2, 2, 2, 3] looking for 2: 1 and 4. Subtract them and you have counted the duplicates without a scan.
insort(seq, x)bisect_right to find the slot, then list.insert to put it there. The find is O(log n); the insert shifts every element after the slot, so the call as a whole is O(n). That single fact is the whole reason balanced trees exist — see the first tripwire.
key=f (3.10+)Search and insert by a computed field, exactly like sorted(key=). Above, insort(people, ("dee", 50), key=lambda p: p[1]) slid the pair in by age, between 44 and 58. The list must already be sorted by that same key — nothing checks.
no bisect.containsbisect answers “where would it go?”, never “is it there?” The two-line idiom in the shape above is the membership test, and the i < len(seq) half is not optional: a key larger than everything returns len(seq), and indexing that raises IndexError.
sortedcontainersThe popular answer to “I need a real ordered container” — and it is not in the standard library; it is a pip install. Its SortedList is not a balanced tree either. It is a list of short lists, chosen for exactly the cache reason this chapter's deeper cut gives.
INPUTimport bisect

board = []
for score in [512, 340, 780, 195, 640]:
    bisect.insort(board, score)          # walk to the slot, then insert there
    print(f"insort({score:3}) -> {board}")

i = bisect.bisect_left(board, 640)
print("\nwhere would 500 go?  ", bisect.bisect_left(board, 500))
print("is 640 stored?       ", i < len(board) and board[i] == 640)
print("everything 340..640: ",
      board[bisect.bisect_left(board, 340):bisect.bisect_right(board, 640)])
print("the top three:       ", board[-3:][::-1])

dup = [1, 2, 2, 2, 3]                    # _left lands before the equals, _right after
print("left/right on dups:  ", bisect.bisect_left(dup, 2), bisect.bisect_right(dup, 2))

people = [("ana", 31), ("bo", 44), ("cy", 58)]
bisect.insort(people, ("dee", 50), key=lambda p: p[1])   # 3.10+
print("insort by key=age:   ", people)
OUTPUTinsort(512) -> [512]
insort(340) -> [340, 512]
insort(780) -> [340, 512, 780]
insort(195) -> [195, 340, 512, 780]
insort(640) -> [195, 340, 512, 640, 780]

where would 500 go?   2
is 640 stored?        True
everything 340..640:  [340, 512, 640]
the top three:        [780, 640, 512]
left/right on dups:   1 4
insort by key=age:    [('ana', 31), ('bo', 44), ('dee', 50), ('cy', 58)]
TRIPWIRES
  • The search never notices n; the insert is all n. Measured on this machine (CPython 3.12.7, so treat the nanoseconds loosely and the shape strictly): bisect_left held at 105 ns at n=25,000, 110 at 50,000, 114 at 100,000 and 136 at 200,000 — flat, the fingerprint of O(log n). insort over the same sizes ran 1,988 ns → 3,914 → 8,160 → 18,692: it doubles when n doubles, the fingerprint of O(n). (A repeat run gave finds of 112 / 125 / 138 / 132 ns and insorts of 2,152 / 4,139 / 8,683 / 16,711 — the wobble is noise, the flat line and the doubling are the law.) So a sorted list is the right answer when you read far more than you write, and a balanced tree earns its pointers the moment inserts start to dominate.
  • Point bisect at a descending list and it does not complain — it answers wrongly. bisect_left([50, 40, 30, 20, 10], 35) returned 5, and insort duly appended 35 to the end: [50, 40, 30, 20, 10, 35]. The same silence covers a merely unsorted list: insort(4) into [5, 1, 9, 3] produced [5, 1, 4, 9, 3]. Every function in the module assumes ascending order and checks nothing; the sortedness is your invariant to keep, exactly as it is inside a BST.
  • The lazy repair — seq.append(x); seq.sort() on every insert — costs far more than it looks. At n=100,000 that pair measured 597,707 ns against insort's 7,907 ns: about 76× slower (a repeat gave 74×), and that is with Timsort already exploiting the almost-sorted run. Sorting is not a way to maintain order; it is a way to establish it, once.
The one-line test
Before you pick a container, finish this sentence: "The operation I do most is ___." If it's "look up one key," reach for a hash. If it fills in with "everything between," "the next one after," "the smallest still left," or "in sorted order" — you need a tree, and you need it balanced, so its height can never betray you.

A BST spends O(log n) to keep total order — every key rank exact. But a huge class of problems doesn't need the whole order — only "what's the most extreme one right now?" Give up full sorting, keep just that, and you get a structure so lean it lives in a flat array with no pointers at all. That's the heap, chapter 58 →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 57, in working code

We already have the binary tree's skeleton — now we add one ruthless rule, and watch a plain heap of scattered nodes turn into a search engine that stays sorted even while it changes under our hands.

The invariant — sorted order, stored in pointers
Add one rule to a plain tree — left < me < right, everywhere — and an in-order walk hands you sorted keys for free, no sort step.
Search and shape — every operation costs O(h)
One comparison throws away a whole subtree, so search, insert and delete all cost O(h). The single question that decides everything is how tall the tree is.
Changing it while keeping it sorted
Insert and delete rewire only a pointer or two — nothing shifts, and the invariant survives every case. That is the deal a sorted array cannot match.
Checks, and the flat alternative
The invariant is global, not local — a real validator must carry bounds down the tree. And Python's own answer to ordered data isn't a tree at all: it's a list plus bisect.
end of chapter 57 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked