template<int Order> // e.g. Order=4 for 2-3-4 treesstruct BTreeNode { int numKeys = 0; int keys[Order - 1]; BTreeNode* children[Order] = {}; // null for leaves BTreeNode* parent = nullptr; bool isLeaf() const { return children[0] == nullptr; } bool isFull() const { return numKeys == Order - 1; }};template<int Order>struct BTree { BTreeNode<Order>* root = nullptr;};
Helper primitives
The insert / remove / rotate / fuse listings later in this chapter call into a small set of in-node primitives. We collect their signatures + brief implementations here so the chapter listings are runnable-shape rather than illustrative-only:
C++
// Linear-scan key lookup inside one node. Returns -1 on miss.template<int Order>int indexOfKey(BTreeNode<Order>* n, int key) { for (int i = 0; i < n->numKeys; ++i) if (n->keys[i] == key) return i; return -1;}template<int Order>bool containsKey(BTreeNode<Order>* n, int key) { return indexOfKey(n, key) != -1;}// Pick the child whose subtree must contain `key` (caller guarantees// the key is not in `n` itself).template<int Order>BTreeNode<Order>* btreeChildForKey(BTreeNode<Order>* n, int key) { int i = 0; while (i < n->numKeys && key > n->keys[i]) ++i; return n->children[i];}// Insert `key` into a non-full leaf. Shifts existing keys right.template<int Order>void btreeInsertIntoLeaf(BTreeNode<Order>* n, int key) { int i = n->numKeys - 1; while (i >= 0 && n->keys[i] > key) { n->keys[i+1] = n->keys[i]; --i; } n->keys[i+1] = key; ++n->numKeys;}// Insert (key, leftChild, rightChild) into a non-full internal node,// preserving sort order. Used by btreeSplit on the parent.template<int Order>void btreeInsertKeyWithChildren(BTreeNode<Order>* p, int key, BTreeNode<Order>* L, BTreeNode<Order>* R) { int i = p->numKeys - 1; while (i >= 0 && p->keys[i] > key) { p->keys[i+1] = p->keys[i]; p->children[i+2] = p->children[i+1]; --i; } p->keys[i+1] = key; p->children[i+1] = L; p->children[i+2] = R; ++p->numKeys; L->parent = R->parent = p;}// Drop key at index `idx`, shifting neighbours left.template<int Order>void removeKey(BTreeNode<Order>* n, int idx) { for (int i = idx; i < n->numKeys - 1; ++i) n->keys[i] = n->keys[i+1]; --n->numKeys;}// Smallest key in the subtree rooted at `n` (leftmost descendant).template<int Order>int btreeGetMinKey(BTreeNode<Order>* n) { while (!n->isLeaf()) n = n->children[0]; return n->keys[0];}
The split / rotate / merge / fuse / remove listings later treat the helpers above as black-box primitives. Production B-tree code factors them similarly --- the algorithm-level listings stay readable, the in-node bookkeeping lives in one place.
The CLRS minimum-degree convention — t vs K
Height analysis
A B-tree of order K with n keys has height h=O(logKn). For K=100 and n=109, that’s log100109≈4.5 — roughly 5 node accesses per lookup. Contrast with a binary tree’s log2109≈30.
That factor-of-six reduction is the whole point. On disk it translates directly to query latency.
The disk-access cost model
A 3-level B-tree of order 4 — the running picture
The structural invariants are easier to internalise on a worked diagram. Below is a 3-level B-tree of order K=4 (so 1—3 keys per node, 2—4 children, leaves at the same depth):
::: center
:::
Read off each invariant on the picture:
Sorted within node. Every node’s keys are left-to-right ascending: [10, 20], [12, 15, 18], [33, 36, 38].
n+1 children. The root has 1 key, 2 children. [10, 20] has 2 keys, 3 children. The 4-node [33, 36, 38] would have 4 children if it were internal --- here it’s a leaf.
Subtree key-ranges. Under [10, 20]: left child is <10 ([3, 7]), middle child is between 10 and 20 ([12, 15, 18]), right child is >20 ([22, 25]).
All leaves at the same depth. Every leaf sits at depth 2.
Min-occupancy floor. Order K=4⇒⌈4/2⌉−1=1 key minimum per non-root. Every non-root node holds ≥1 key. Root exception: [30] has exactly 1 key (legal because it’s the root).
This tree carries 16 keys at depth 3. With K=100 on the same 3 levels, a B-tree carries up to 1003=106 keys --- the same shape, vastly more capacity.
11.2 Search
The algorithm
B-tree search is binary search within a node combined with tree descent. At each node:
Linear-scan (or binary-search, for larger orders) the node’s keys for the target.
If found, return the node.
Otherwise, find the child whose key range contains the target, and recurse.
For the 2-3-4 case, the child selection reduces to a short comparison chain:
::: center
Condition Descend to
key < node.A left
node has 1 key, or key < node.B middle1
node has 2 keys, or key < node.C middle2
otherwise right
:::
C++
BTreeNode* btreeSearch(BTreeNode* node, int key) { if (!node) return nullptr; // Check keys in this node. if (node->numKeys >= 1 && node->keys[0] == key) return node; if (node->numKeys >= 2 && node->keys[1] == key) return node; if (node->numKeys >= 3 && node->keys[2] == key) return node; // Descend to the appropriate child. if (key < node->keys[0]) return btreeSearch(node->children[0], key); // left if (node->numKeys == 1 || key < node->keys[1]) return btreeSearch(node->children[1], key); // middle1 if (node->numKeys == 2 || key < node->keys[2]) return btreeSearch(node->children[2], key); // middle2 return btreeSearch(node->children[3], key); // right}
B-TREE-SEARCH — the general form
The 2-3-4-special listing above unrolls the in-node comparisons by hand. CLRS Ch. 18.1 gives the general recursive form that works for any order K: scan the keys for the smallest one ≥ the target, either return on a hit or descend into the matching child.
C++
template<int Order>BTreeNode<Order>* btreeSearchGeneric(BTreeNode<Order>* node, int key) { if (!node) return nullptr; int i = 0; while (i < node->numKeys && key > node->keys[i]) ++i; if (i < node->numKeys && node->keys[i] == key) return node; if (node->isLeaf()) return nullptr; return btreeSearchGeneric(node->children[i], key);}
Cost — CPU vs disk decomposition
::: center
Cost dimension Linear in-node scan Binary in-node scan
Disk reads (block transfers) O(logKn)O(logKn)
CPU comparisons / node O(K)O(logK)
Total CPU work O(KlogKn)O(logK⋅logKn)=O(logn)
Memory accesses 1 per node-step 1 per node-step
:::
The two takeaways:
Disk cost is independent of in-node search style. Both linear and binary read the same number of blocks --- one per level. That’s what fanout buys you.
CPU cost equals balanced-binary-tree cost once you switch to binary in-node search: O(logK⋅logKn)=O(log2n). So a B-tree is never CPU-slower than a binary tree (on identical data) and is dramatically faster on disk-bound workloads.
11.3 Insertion
The big picture
New keys always go into leaf nodes. The challenge: leaves might already be full. Strategy:
The split operation
A full node (with its maximum K−1 keys) is split into two nodes of (K/2−1) keys each, and the middle key is promoted to the parent. For 2-3-4 trees:
::: center
:::
Keys A and C become two new sibling nodes; key B moves into the parent, with pointers to the new siblings. The parent must have room (thanks to preemptive split).
C++
BTreeNode* btreeSplit(BTree& t, BTreeNode* node) { if (!node->isFull()) return nullptr; BTreeNode* parent = node->parent; BTreeNode* left = new BTreeNode{1, {node->keys[0]}, {node->children[0], node->children[1]}, parent}; BTreeNode* right = new BTreeNode{1, {node->keys[2]}, {node->children[2], node->children[3]}, parent}; if (parent) { btreeInsertKeyWithChildren(parent, node->keys[1], left, right); } else { parent = new BTreeNode{1, {node->keys[1]}, {left, right}, nullptr}; t.root = parent; left->parent = right->parent = parent; } return parent;}
B-TREE-SPLIT-CHILD — the CLRS form
The listing above is hard-coded to order 4. CLRS Ch. 18.2’s B-TREE-SPLIT-CHILD generalises to any order. The invariant: the parent p is non-full (so it has room for one more key + child pointer), and the child to split is at index i. The child is full (K−1 keys); after the split, we get two siblings each with ⌊(K−1)/2⌋ keys, and the median key moves up to p.
C++
template<int Order>void btreeSplitChild(BTreeNode<Order>* p, int i) { constexpr int t = Order / 2; // CLRS minimum-degree t BTreeNode<Order>* y = p->children[i]; // the full child BTreeNode<Order>* z = new BTreeNode<Order>{}; // z gets y's right half (keys [t .. 2t-2], children [t .. 2t-1]). z->numKeys = t - 1; for (int j = 0; j < t - 1; ++j) z->keys[j] = y->keys[j + t]; if (!y->isLeaf()) { for (int j = 0; j < t; ++j) { z->children[j] = y->children[j + t]; z->children[j]->parent = z; y->children[j + t] = nullptr; } } y->numKeys = t - 1; // y keeps left half // Make room in p for the new key + child pointer at slot i. for (int j = p->numKeys; j > i; --j) p->children[j + 1] = p->children[j]; p->children[i + 1] = z; z->parent = p; for (int j = p->numKeys - 1; j >= i; --j) p->keys[j + 1] = p->keys[j]; p->keys[i] = y->keys[t - 1]; // promote median ++p->numKeys;}
B-TREE-INSERT-NONFULL — the recursive descent
CLRS Ch. 18.2 splits the insert API in two: btreeInsert handles the root-split-as-only-height-growth event; everything else goes through btreeInsertNonFull, which assumes the node it’s called on already has room. This is the cleanest expression of the preemptive-split invariant.
C++
template<int Order>void btreeInsertNonFull(BTreeNode<Order>* node, int key) { int i = node->numKeys - 1; if (node->isLeaf()) { // Shift keys right, place new key in sorted slot. while (i >= 0 && node->keys[i] > key) { node->keys[i + 1] = node->keys[i]; --i; } node->keys[i + 1] = key; ++node->numKeys; } else { // Find child whose subtree contains key. while (i >= 0 && node->keys[i] > key) --i; ++i; // Preemptive split: if descent target is full, split first. if (node->children[i]->isFull()) { btreeSplitChild(node, i); // After split, decide which of the two halves to descend. if (key > node->keys[i]) ++i; } btreeInsertNonFull(node->children[i], key); }}template<int Order>void btreeInsertCLRS(BTree<Order>& t, int key) { if (!t.root) { t.root = new BTreeNode<Order>{}; } if (t.root->isFull()) { // Root split: brand-new root, height grows by 1. BTreeNode<Order>* s = new BTreeNode<Order>{}; s->children[0] = t.root; t.root->parent = s; t.root = s; btreeSplitChild(s, 0); } btreeInsertNonFull(t.root, key);}
The split-and-shift bookkeeping is fiddly because the arrays are fixed-size --- everything is in-place index arithmetic. The algorithmic shape is identical to the order-4 btreeSplit above; only the loop bounds change.
Special case: splitting the root
When the root itself is full and we start inserting, the split creates a brand-new root with a single key. This is the only way a B-tree grows in height. Every other operation happens at the leaf level or via splits that push keys sideways to a non-full ancestor.
Insert into a leaf
Once we’ve descended (splitting along the way) to the target leaf, the leaf is guaranteed non-full. Insert the new key in sorted position:
::: center
Condition (non-full leaf) Action
key equals existing key no-op (duplicates forbidden)
key < node.A shift existing keys right, place new key at A
key < node.B (or no B) shift B→C if needed, place new key at B
otherwise place new key at C
:::
The full insert
C++
BTreeNode* btreeInsert(BTreeNode* node, int key) { if (containsKey(node, key)) return nullptr; // duplicates rejected if (node->isFull()) node = btreeSplit(node); // preemptive split if (!node->isLeaf()) { BTreeNode* child = btreeChildForKey(node, key); return btreeInsert(child, key); } btreeInsertIntoLeaf(node, key); return node;}
Cost
Descent: O(logKn) levels.
At each level: at most one split, O(K) work.
Total: O(KlogKn). For fixed K, that’s O(logn).
Preemptive split during descent — before / after
The order-4 split TikZ above shows the structural rearrangement of one split. The diagram below shows the full preemptive-descent move: we are inserting a new key, the descent path passes through a full node, and we split that node before entering it so the parent (which is guaranteed non-full by induction) absorbs the median.
::: center
:::
The full [5,10,15] child is split before we descend into it: median 10 moves up to the parent (which had room because we already split it on the way down if it had been full), and we now have two non-full children to choose from. The new key 12 goes into [12, 15] via btreeInsertNonFull.
11.4 Rotations and Fusion
Removal is the hard half of any balanced tree. For B-trees, the two tools are rotation (borrow a key from a sibling) and fusion (merge three nodes into one — the inverse of split).
The CLRS 4-case deletion taxonomy
CLRS Ch. 18.3 organises B-tree deletion into four cases, branching on where the key sits and whether the path to it is “safe to descend” (every non-root node has ≥t keys, i.e., one above the floor, so we can remove a key without underflowing). The taxonomy is the canonical mental model for the deletion algorithm:
Rotation
Right rotation on node
Takes a key from node’s left sibling, moves the parent separator key into node, and pushes the left sibling’s rightmost key up to fill the separator slot. Result: the left sibling loses one key, node gains one.
Left rotation on node
Mirror image: takes a key from node’s right sibling.
C++
// Illustrative form. A "rotation on node X" in this context means// "give X one more key by borrowing from a sibling through the parent."void btreeRotateLeft(BTreeNode* node) { BTreeNode* leftSib = getLeftSibling(node); int parentKey = getParentKeyLeftOfChild(node->parent, node); addKeyAndChild(leftSib, parentKey, node->children[0]); setParentKeyLeftOfChild(node->parent, node, node->keys[0]); removeKey(node, 0);}
Fusion
::: center
Before fusion → After fusion
[L]⋅ (parent sep M) ⋅[R]→[L, M, R]
:::
The parent loses a key (and a child pointer); the fused node has three keys. Fusion is used when neither sibling has a spare key to rotate.
Root fusion – a special case
When the root has 1 key and both its children have 1 key each, no rotation is possible and ordinary fusion would empty the parent. Instead, the three keys are combined into a new root of three keys, and the tree shrinks in height by 1:
Picture: rotation from sibling vs. fusion with sibling
Cases 3a and 3b of the deletion taxonomy decide between the two deletion-prep strategies. The diagrams below show both moves on the same starting configuration --- the deficient child [c] has t−1=1 key (order K=4), and we need to ensure it has at least t=2 before descending into it.
::: center
:::
Reading the two cases. The shaded child is the one we’re about to descend into; we must raise its key count above the floor first.
Case 3a (left). Sibling [a, b] has ≥t keys. Rotate-right: b stays in the left sibling, a? No --- the parent separator s comes down into c, and the sibling’s rightmost key b moves up to take s‘s place. (Mirror of left-rotation when the spare key is in the right sibling.) The child gains one key, the sibling loses one, the parent’s key changes but its count stays the same.
Case 3b (right). Both siblings have only t−1 keys; no rotation possible. Fuse the parent’s separator s with both children into one node of 2t−1 keys. The parent loses one key + one child pointer. If the parent was the root and is now empty, the fused node becomes the new root --- the only way a B-tree shrinks in height (mirror of root-split being the only way it grows).
Visualizing split vs. fusion
::: center
Operation Effect on height Inverse of
Root split +1 Root fusion
Internal split 0 Internal fusion
Right rotation 0 Left rotation (roughly)
:::
The symmetry is beautiful: every modification to a B-tree is either a rotation (constant-cost redistribution) or a split/fuse (constant-cost restructuring). Both are strictly local.
11.5 Removal
The same dual-pass idea as insertion
Insertion used preemptive split: any full node seen on the way down gets split before we touch it, guaranteeing the target leaf has room.
Removal uses the mirror: preemptive merge. Any single-key non-root node seen on the way down gets merged (via rotation or fusion) to have ≥2 keys before we touch it. That guarantees the target leaf has ≥2 keys and can give one up.
The merge procedure
Given a 1-key non-root node whose parent has ≥2 keys, merge as follows:
C++
BTreeNode* btreeMerge(BTreeNode* node) { BTreeNode* leftSib = getLeftSibling(node); BTreeNode* rightSib = getRightSibling(node); // Preference 1: rotate from a sibling with a spare key. if (leftSib && leftSib->numKeys >= 2) { btreeRotateRight(leftSib); // one of leftSib's keys flows into node } else if (rightSib && rightSib->numKeys >= 2) { btreeRotateLeft(rightSib); } else { // Preference 2: fuse with a 1-key adjacent sibling. if (!leftSib) node = btreeFuse(node, rightSib); else node = btreeFuse(leftSib, node); } return node;}
Leaf case vs. internal case
Leaf removal. Once preemptive merging has guaranteed the leaf has ≥2 keys, just splice the key out and shift neighbors left.
Internal removal. Replace the key with its successor (the minimum key in its right subtree) — just like BST removal. But now we have to recursively remove the successor, which is in a leaf. The remove call:
Find and remember the successor key.
Recursively remove the successor (which triggers a leaf removal, safe after merging).
Swap the saved successor key into the original internal node’s position.
C++
bool btreeRemove(BTree& t, int key) { // Special case: single-key leaf root. if (t.root->isLeaf() && t.root->numKeys == 1 && t.root->keys[0] == key) { delete t.root; t.root = nullptr; return true; } BTreeNode* cur = t.root; while (cur) { // Preemptive merge: no 1-key non-root nodes on our path. if (cur->numKeys == 1 && cur != t.root) cur = btreeMerge(cur); int idx = indexOfKey(cur, key); if (idx != -1) { if (cur->isLeaf()) { removeKey(cur, idx); return true; } // Internal: swap with min of right child, then remove the min. BTreeNode* rightChild = cur->children[idx + 1]; int replacement = btreeGetMinKey(rightChild); btreeRemove(t, replacement); btreeKeySwap(t.root, key, replacement); return true; } cur = btreeNextNode(cur, key); // descend toward the target } return false; // not found}
Cost
Same as insertion: O(KlogKn)=O(logn). Single downward pass, constant work per level.
Why preemptive merging is elegant
B+ trees – the production refinement
Real databases and filesystems use B+ trees, not plain B-trees. The variant changes where data lives and adds a linked-list-of-leaves structure on top. The algorithms (search, insert, preemptive split, preemptive merge, rotate, fuse) adapt directly --- the only change is that internal nodes hold routing keys but no values, and leaves chain to their right neighbours.
B-tree vs B+ tree — side by side
::: center
Feature Classic B-tree B+ tree
Where values live internal + leaves leaves only
Routing keys not duplicated duplicated at leaf
Internal-node fanout moderate high (no values)
Range scan in-order traversal O(RlogKn) leaf walk O(R)
Sequential scan re-descend per row one leaf walk
Point-query depth may stop at internal always reaches leaf
Production use rare today standard everywhere
Insert / delete algorithm as in §11.3 / §11.5 identical (with leaf-link bookkeeping)
:::
The picture: same conceptual tree shape, but data + links live at the leaf level only.
::: center
:::
11.6 Memory hierarchy and B-tree variants
The basic B-tree of §11.1—§11.5 is the foundation; production systems layer on variants that target different points in the memory hierarchy.
2-3-4 trees and red-black trees are the same data structure