Chapter 11 · Lectures

B-trees and 2-3-4 trees

11.1 B-tree Structure

The definition

The 2-3-4 tree – a B-tree of order 4

Order-4 B-trees are the standard teaching example. A node holds:

  • 1, 2, or 3 keys — called 2-node, 3-node, or 4-node.

  • Correspondingly 2, 3, or 4 children (for internal nodes).

A common convention labels node keys as AA, BB, CC and children as left, middle1, middle2, right:

::: center Node type Keys used Children used


2-node AA left, middle1 3-node A,BA, B left, middle1, middle2 4-node (full) A,B,CA, B, C left, middle1, middle2, right :::

C++ node skeleton

template<int Order>   // e.g. Order=4 for 2-3-4 trees
struct 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:

// 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 KK with nn keys has height h=O(logKn)h = O(\log_K n). For K=100K = 100 and n=109n = 10^9, that’s log1001094.5\log_{100} 10^9 \approx 4.5 — roughly 5 node accesses per lookup. Contrast with a binary tree’s log210930\log_2 10^9 \approx 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=4K = 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+1n+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< 10 ([3, 7]), middle child is between 10 and 20 ([12, 15, 18]), right child is >20> 20 ([22, 25]).

  • All leaves at the same depth. Every leaf sits at depth 2.

  • Min-occupancy floor. Order K=4K = 4 \Rightarrow 4/21=1\lceil 4/2 \rceil - 1 = 1 key minimum per non-root. Every non-root node holds 1\ge 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=100K = 100 on the same 3 levels, a B-tree carries up to 1003=106100^3 = 10^6 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:

  1. Linear-scan (or binary-search, for larger orders) the node’s keys for the target.

  2. If found, return the node.

  3. 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 :::

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 KK: scan the keys for the smallest one \ge the target, either return on a hit or descend into the matching child.

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(\log_K n) O(logKn)O(\log_K n) CPU comparisons / node O(K)O(K) O(logK)O(\log K) Total CPU work O(KlogKn)O(K \log_K n) O(logKlogKn)=O(logn)O(\log K \cdot \log_K n) = O(\log n) 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(logKlogKn)=O(log2n)O(\log K \cdot \log_K n) = O(\log_2 n). 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 K1K-1 keys) is split into two nodes of (K/21)(K/2 - 1) keys each, and the middle key is promoted to the parent. For 2-3-4 trees:

::: center :::

Keys AA and CC become two new sibling nodes; key BB moves into the parent, with pointers to the new siblings. The parent must have room (thanks to preemptive split).

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 pp is non-full (so it has room for one more key + child pointer), and the child to split is at index ii. The child is full (K1K-1 keys); after the split, we get two siblings each with (K1)/2\lfloor (K-1)/2 \rfloor keys, and the median key moves up to pp.

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.

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 AA key << node.B (or no BB) shift BCB \to C if needed, place new key at BB otherwise place new key at CC :::

The full insert

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)O(\log_K n) levels.

  • At each level: at most one split, O(K)O(K) work.

  • Total: O(KlogKn)O(K \log_K n). For fixed KK, that’s O(logn)O(\log n).

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\ge 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.

// 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 \to After fusion [L] \cdot (parent sep MM) \cdot [R] \to [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 t1=1t-1 = 1 key (order K=4K=4), and we need to ensure it has at least t=2t = 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\ge t keys. Rotate-right: bb stays in the left sibling, aa? No --- the parent separator ss comes down into cc, and the sibling’s rightmost key bb moves up to take ss‘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 t1t - 1 keys; no rotation possible. Fuse the parent’s separator ss with both children into one node of 2t12t - 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\geq 2 keys before we touch it. That guarantees the target leaf has 2\geq 2 keys and can give one up.

The merge procedure

Given a 1-key non-root node whose parent has 2\geq 2 keys, merge as follows:

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\geq 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:

  1. Find and remember the successor key.

  2. Recursively remove the successor (which triggers a leaf removal, safe after merging).

  3. Swap the saved successor key into the original internal node’s position.

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)O(K \log_K n) = O(\log n). 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)O(R \log_K n) leaf walk O(R)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

Cache-oblivious B-trees

LSM-trees: the write-optimized alternative

Other variants worth pointing at

11.7 Production references and further reading

interactive mode active