python/volume-4/chapter-57ch 57 / 64 · 11 min

57The binary search tree — order that stays searchable

In Chapter 56 we built the binary tree: nodes on the heap, two children each, hierarchy and nothing more. Here we add one strict rule to that skeleton, and it turns into a search engine. The problem it solves is a trade-off. A sorted array can binary-search in O(log n), but insert one value in the middle and every element after it has to shift — an O(n) tax on every change. A hash table gives O(1) lookup, but it throws order away, so it can't tell you the smallest key or every key between 100 and 200. The binary search tree keeps both sides of that deal. It stays sorted and stays fast to change, because it holds order not in memory positions but in pointers. So one question runs through the whole chapter: what does it cost to keep a collection sorted while it is still changing under your hands? By the end you'll insert, search, and delete in a BST, see exactly why one careless insertion order can rot it into a slow linked list, and learn the fix that runs every database index on Earth.

One rule, wired into every pointer Search: a binary search that walks pointers Insert: search for where it isn't, then hang a node The catastrophe: sorted input collapses the tree into a list
scroll to begin

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, holding its data and up to two child pointers, left and right. That was hierarchy, and nothing more. A BST is that same skeleton plus one strict invariant — a rule that must hold at every node, with no exceptions: everything in my left subtree is less than me; everything in my right subtree is greater than me. Notice how far that reaches. It covers not just my two children, but my entire left subtree, all the way down, and my entire right subtree. That single promise is what turns a plain tree into a search structure.

Because the rule holds everywhere, a BST has a handy trait you first met in the binary-tree chapter. Walk it in-order — left subtree, then the node, then right subtree — and 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 just three references — the key, a left pointer, and a right pointer. We insert seven keys in a jumbled order. Then an in-order walk prints them 1 2 3 4 5 6 7. The sorted output falls straight out of the invariant, because "smaller stuff is always on the left" is exactly what in-order visits first. The order isn't stored anywhere. It's 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 the memory bill, run for real. A node with __slots__ is the leanest a Python BST node gets, and here's what we measured:

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 (344 if you skip __slots__). Of that, only 24 bytes — three 8-byte references — actually carries data. The rest is Python's per-object overhead: Vol 1's ~32-byte object header, here split into 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 pay 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.

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, start at the root and compare. There are three outcomes. Equal — you found it, stop. Target is smaller — the invariant guarantees it can only be in the left subtree, so the entire right subtree is eliminated in one comparison. Target is larger — go right, and the whole left subtree is gone. Each comparison throws away one of the two subtrees. That is binary search — the halving technique from Volume 3 — except instead of indexing into a sorted array, you are following pointers down a tree:

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, we're done. If target < node.key we step left; otherwise right. When node becomes None, we've walked off the tip of a branch without a match, so 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. Here is the crux. If the tree is balanced, each level roughly doubles the node count (1, 2, 4, 8, …), 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: each of those 26 comparisons halved what was left. (The height 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, shortly.)

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, because 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: when you hit a None, you've 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 — O(h) again, the same descent as search, plus one node allocation. The important part: 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.

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. Feed a BST already-sorted data and watch what happens. Insert 1, and it's the root. Insert 2 — it's bigger, so go right. Insert 3 — bigger than 1, go right; bigger than 2, go right again. Every new key is the largest so far, so every insert goes right, right, right. 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. Sorted insertion gives height exactly n — every node has one child, in a straight line. It is no longer a tree; 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 whole reason for existing — O(log n) — is gone. The random column shows what balance would give you instead: 1000 keys in height 20, 30000 keys in height 36, hugging log₂ n. Same keys, same code, same invariant — a 50× difference in height, decided only by arrival order.

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 by 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) — while the balanced column just sits near a microsecond whatever the size, the fingerprint of O(log n). At n=4000 the balanced tree answers roughly 800× faster for the same keys. (The timings are from this machine and will vary from run to run. The shapes — one line climbing, one flat — are what stays true.)

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 small, local rewiring of three or four pointers that 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 only a constant number of pointers. It changes the tree's shape but not its sorted meaning.

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 let's meet them by name. An AVL tree keeps every node's two subtree heights within 1 of each other; the moment an insert breaks that, it rotates. It's the strictest, so it's 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, which 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)"; 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.

🌍 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 — and it's the same cache story from Volume 3, one level down. Reading from a disk (even an SSD) happens in fixed blocks, typically 4 KB or more. And a single disk seek is painfully slow next to RAM: hundreds of thousands of times slower. A binary tree of a billion keys is about 30 levels deep, so a lookup could be 30 separate disk seeks — a disaster. 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: match the node to the transfer unit of the storage. Pointer-chasing a skinny binary tree wastes almost the whole block it drags in; a B-tree makes every expensive block read carry hundreds of useful keys.

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's deliberate. For in-memory ordered work, Python leans on two other tools. First the dict (O(1), and since 3.7 it preserves insertion order, though not sorted order). Then 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 practical winner — and here's the twist: it does not use a balanced BST at all. It uses a list of short lists — cache-friendly contiguous chunks — and it beats textbook tree implementations in practice for exactly the memory-locality reason 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. First find the node (an O(h) search), then handle three situations, one for each number of children it has:

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's the one value that can sit in the vacated spot without breaking left<node<right. We ran all three cases and checked that 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 the node is 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 and 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 that 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's O(log n); in that degenerate chain it's O(n). The same tax hangs over delete as over everything else.

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 — "is key X here, and what's its value?" — hands down, O(1) versus O(log n). If that is all you do, use the dict; don't reach for a tree just to feel clever. The tree earns its keep the moment your questions become ordered ones — the very 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. Point lookup only → hash. Ordered questions on changing data → balanced tree. Ordered questions on data that rarely changes → don't even use a tree; sort an array once and binary-search it (Volume 3), because a contiguous array streams through cache far faster than a tree of scattered nodes. Here's 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 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 strict 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.com
Written by Ajai Raj