Part V · Advanced Data Structures Chapter 18 Extra depth

B-Trees

Balanced search trees for data that lives on disk, where one access costs more than a million comparisons — so the answer is nodes with hundreds of children and a tree three levels deep.

Every structure so far assumed the RAM model, where all memory costs the same. B-trees discard that assumption. When data sits on a disk or SSD, a single access costs milliseconds while a comparison costs nanoseconds, so the only quantity worth minimising is the number of node accesses. A B-tree does that by making each node enormous — one full disk page, holding hundreds or thousands of keys — which makes the tree extremely shallow. This is the data structure behind essentially every relational database index and every serious filesystem.

4th edition note. Numbering shifted from 18 to 18 (unchanged in Part V’s reordering). Content is the same, including the single-pass “split full nodes on the way down” insertion.

Contents

  1. Why disk changes everything
  2. Definition of a B-tree
  3. Height
  4. Search
  5. Splitting a full node
  6. Insertion in one pass
  7. Deletion
  8. B+ trees and practice
  9. Recap

Why disk changes everything

AccessTypical latencyRelative to a comparison
CPU comparison~1 ns
Main memory~100 ns100×
SSD read~100 µs100,000×
Spinning disk seek~5 ms5,000,000×
When a node access costs millions of times more than a comparison, the running time is the number of nodes visited, and everything done inside a node is free by comparison. So make nodes as large as a disk page and put as many keys in each as will fit. A red-black tree with a million keys is 20 levels deep and needs 20 disk reads; a B-tree with 1000 keys per node is 2 levels deep.

CLRS models this with explicit DISK-READ and DISK-WRITE operations in the pseudocode, and counts them separately from CPU time.

Definition of a B-tree

A B-tree T with root T.root satisfies:

  1. Every node x has: x.n keys stored in non-decreasing order x.key₁ ≤ … ≤ x.keyx.n, and a boolean x.leaf.
  2. An internal node has x.n + 1 children pointers x.c₁, …, x.cx.n+1. Leaves have none.
  3. The keys separate the subtrees: any key kᵢ in subtree x.cᵢ satisfies k₁ ≤ x.key₁ ≤ k₂ ≤ x.key₂ ≤ …
  4. All leaves have the same depth, which is the tree’s height. This is what keeps it balanced.
  5. A fixed minimum degree t ≥ 2 bounds node occupancy: every node other than the root has at least t - 1 keys, and every node has at most 2t - 1 keys. A node with exactly 2t - 1 keys is full.
M T C F J L P R V X Z keys < M M < keys < T keys > T t = 3: every non-root node holds between 2 and 5 keys, and all leaves are at depth 1
Figure 18.1 — A B-tree with minimum degree t = 3. Keys inside a node act as separators between its children.
The minimum degree t is the one parameter. Each node holds between t-1 and 2t-1 keys, and therefore has between t and 2t children. The lower bound is what guarantees nodes stay at least half full, so the tree cannot become sparse and tall. In practice t is chosen so a node exactly fills one disk page — often in the hundreds or low thousands.

Height

Theorem 18.1. If n ≥ 1, then for any n-key B-tree of height h and minimum degree t ≥ 2,
h ≤ logₜ ( (n + 1) / 2 )

The proof counts nodes level by level: the root has at least 1 key, and at depth i ≥ 1 there are at least 2ti-1 nodes each holding at least t-1 keys. Summing the geometric series gives n ≥ 2tᵗ - 1, and rearranging gives the bound.

Put numbers on it. With t = 1001 (so up to 2001 keys and 2002 children per node) and one billion keys, the height is at most log₁₀₀₁(500,000,000) ≈ 2.9, so 3 levels. A search touches 3 nodes. If the root is cached in memory, that is 2 disk reads to find any key among a billion. Compare with a red-black tree at 30 levels.

The saving compared to a binary tree is a factor of lg t, since logₜ n = lg n / lg t. That is the entire design in one identity.

B-TREE-SEARCH(x, k) 1 i = 1 2 while i ≤ x.n and k > x.keyᵢ 3 i = i + 1 4 if i ≤ x.n and k == x.keyᵢ 5 return (x, i) // found 6 elseif x.leaf 7 return NIL // not in the tree 8 else 9 DISK-READ(x.cᵢ) 10 return B-TREE-SEARCH(x.cᵢ, k)

A multiway generalisation of binary search: at each node find which of the x.n + 1 gaps k falls into, then descend.

MeasureCost
Disk accessesO(h) = O(logₜ n) — the number that matters
CPU timeO(t logₜ n) with a linear scan inside each node

The linear scan on line 2 could be a binary search, reducing CPU time to O(lg t · logₜ n) = O(lg n). CLRS notes this and then ignores it, because CPU time is not the bottleneck — which is exactly the point of the chapter.

Splitting a full node

Insertion cannot simply add a key to a full node. The primitive that fixes this is the split.

B-TREE-SPLIT-CHILD(x, i) takes a non-full internal node x and a full child y = x.cᵢ with 2t-1 keys. It splits y around its median key into two nodes of t-1 keys each, and moves the median up into x, which gains one key and one child.
beforeafter A D P Q R S T full: 2t-1 = 5 keys R is the median split A R D P Q S T two nodes of t-1 = 2 keys each the parent grew by one key; height unchanged
Figure 18.2 — A split. O(t) CPU time and O(1) disk writes. This is the only operation that changes the tree’s shape during insertion.
Why the tree grows at the root, not the leaves. A split pushes a key upward. Every other tree in this book grows downward by adding leaves; a B-tree keeps all leaves at the same depth by growing only when the root itself splits, which adds one level to every path at once. That is how property 4 is maintained without any rebalancing.

Insertion in one pass

The elegant part. Rather than descending, discovering an overflow, and backing up, the algorithm splits every full node it meets on the way down. That guarantees the parent always has room for a key pushed up, so no backtracking is ever needed.

B-TREE-INSERT(T, k) 1 r = T.root 2 if r.n == 2t - 1 // root is full: grow the tree taller 3 s = ALLOCATE-NODE() 4 T.root = s 5 s.leaf = FALSE 6 s.n = 0 7 s.c₁ = r 8 B-TREE-SPLIT-CHILD(s, 1) 9 B-TREE-INSERT-NONFULL(s, k) 10 else B-TREE-INSERT-NONFULL(r, k)

B-TREE-INSERT-NONFULL is called only on a node guaranteed not to be full. If the node is a leaf it inserts the key directly; otherwise it finds the right child, splits it if full, and recurses.

The single-pass invariant: whenever the algorithm calls itself on a node, that node is not full. Splitting proactively on the way down is what maintains it. The cost is that some nodes get split unnecessarily, which is a small price for never needing a second pass over the disk.
MeasureInsert cost
Disk accessesO(h) — one downward pass, no backtracking
CPU timeO(t h) = O(t logₜ n)
Splits per insertAt most h, and usually far fewer

Deletion

Deletion is the messiest operation in the chapter. CLRS describes it in prose rather than pseudocode, which is itself a comment on its complexity. The governing principle mirrors insertion:

Descend ensuring every node visited has at least t keys — one more than the minimum. Then removing a key from it cannot push it below the minimum, and again no backtracking is needed.

The cases, in outline:

CaseSituationAction
1k is in a leaf with at least t keysDelete it. Done.
2ak is in internal node x; the child before k has ≥ t keysReplace k with its predecessor and recursively delete that.
2bThe child after k has ≥ t keysReplace k with its successor and recursively delete that.
2cBoth adjacent children have only t-1 keysMerge them with k into one node of 2t-1 keys, then delete k from it.
3ak is in a subtree whose root has t-1 keys, and a sibling has ≥ tBorrow: move a key from the sibling up to the parent and one from the parent down.
3bBoth siblings have t-1 keysMerge the child with a sibling, pulling a key down from the parent.

Cost is O(h) disk accesses and O(t h) CPU time, same as insertion. Cases 2a/2b are the direct analogue of BST deletion by successor from Chapter 12; the borrow-or-merge pair in case 3 is the mirror of the split in insertion.

Do not memorise the six cases. Remember the governing invariant — never descend into a node with only t-1 keys, fix it first by borrowing or merging — and the cases follow from it. This is the same advice as red-black deletion, and for the same reason.

B+ trees and practice

What databases actually use: the B+ tree. A variant where all data lives in the leaves and internal nodes hold only separator keys. Two consequences make it dominant in practice. Internal nodes hold more keys, since they carry no payload, so the tree is even shallower. And the leaves are linked together in a list, so a range scan finds the start in O(logₜ n) and then walks sequentially without touching internal nodes at all — which is what makes WHERE x BETWEEN a AND b fast.
Red-black treeB-tree
Children per node2t to 2t, often hundreds
Height for n = 10⁹~30~3
OptimisesComparisonsNode accesses
Lives inMemoryDisk or SSD
Found instd::map, Linux schedulerEvery database index, NTFS, ext4, HFS+, APFS
B-trees are the clearest example in the book of an algorithm designed against a cost model other than the RAM model. Nothing about them is faster in the RAM model — a red-black tree does fewer comparisons. They win because the real machine has a memory hierarchy the RAM model refuses to see.

Recap

The eight things to carry forward

Where this goes next

Chapter 19 covers disjoint-set forests, a structure with a startling analysis: two simple heuristics bring the amortized cost per operation down to α(n), the inverse Ackermann function, which is at most 4 for any input that will ever exist. It is the last data structure in the book and the one Kruskal’s algorithm in Chapter 21 depends on.


Ch 17 — Augmenting Data Structures Ch 19 — Data Structures for Disjoint Sets