CLRS Minimum Degree $t$ Invariants, Proactive Splitting & B-Tree vs B+ Tree
In CLRS notation, a B-Tree of minimum degree $t \ge 2$ (maximum branching factor $M = 2t$) satisfies:
- Every node except the root holds at least $t - 1$ keys ($t$ children); the root holds at least $1$ key ($2$ children) if non-empty.
- Every node holds at most $2t - 1$ keys ($2t$ children). A node with $2t - 1$ keys is full.
- All leaf nodes reside at the exact same depth $h$, bounded by $h \le \log_t \frac{N + 1}{2}$.
Proactive Top-Down Splitting (Single-Pass Insertion): In an on-disk B-Tree, if we waited until reaching a full leaf to split and propagate median keys back up, we would have to hold exclusive write locks (latches) on the entire root-to-leaf path or re-read ancestor pages from disk. Instead, CLRS splits every full node ($2t - 1$ keys) encountered on the way down into two nodes of $t - 1$ keys each, promoting the median key into the parent (which is guaranteed not to be full because we would have already split it on the step above!). This guarantees single-pass top-down insertion and enables latch crabbing (releasing parent locks as soon as the child is verified safe).
| Feature / Operation | Original B-Tree (Bayer-McCreight) | B+ Tree (PostgreSQL / InnoDB / SQLite) | CPU In-Memory B-Tree (rust BTreeMap) |
|---|---|---|---|
| Where Do Row Payloads Live? | In BOTH internal and leaf nodes | Exclusively in Leaf Nodes (Internal = routing keys) | In all nodes ($B = 6$, max $11$ keys/node) |
| Internal Node Fanout ($16\text{ KB}$ Page) | Low ($\approx 50\text{–}150$, bloated by payloads) | High (≈ 500–1000 keys/page!) | 12-way branching (fits 2–4 cache lines) |
| Point Lookup I/Os | O(log_B N) | O(log_B N) ($le 3 ext{–}4$ page reads; upper levels in RAM) | O(log₂ N) cmps, O(log_B N) cache misses |
| Range Scan $[K_1, K_2]$ ($S$ items) | Slow tree up/down traversal | O(log_B N + S/B) Sequential Leaf Walk! | Fast contiguous array slice iteration |
B-Tree Split vs B+ Tree Split: Push-Up vs Copy-Up
When a full node with $2t - 1$ keys splits around its median key $k_{\text{mid}}$:
- B-Tree Split (Push-Up Everywhere): Because every node stores actual data records, $k_{\text{mid}}$ is moved (pushed) up into the parent and removed from the child level, leaving two children of size $t - 1$.
- B+ Tree Leaf Split (Copy-Up) vs Internal Split (Push-Up): In a B+ Tree, every key-value record must remain present in the leaf level so sequential scans work! Therefore, when a B+ Tree Leaf splits, the separator key $k_{\text{mid}}$ is copied up to the parent directory node while remaining in the right leaf! When a B+ Tree Internal Routing Node overflows, however, its median routing key is pushed up (not duplicated) just like a standard B-Tree.
Trace how a full B+ Tree leaf page splits and copies its separator key up to the parent directory, or switch to LSM-Tree mode to inspect MemTable flushes, L0→L1 k-way merge compaction, and Bloom filter skips.
Invariant & State
[ 30 ]Hardware Reality: Why Rust's std::collections::BTreeMap Replaced Red-Black Trees in RAM
B-Trees are not just for disks! Standard C++ std::map allocates every single key in a separate 40-byte Red-Black Tree node on the heap, causing a $60\text{ ns}$ L3/DRAM cache miss on every comparison and wasting $70\%$ of memory on pointers. Rust's standard library (std::collections::BTreeMap) and Google's Abseil absl::btree_map use an in-memory B-Tree with $B = 6$ (up to $2B - 1 = 11$ keys per node). Scanning up to 11 contiguous keys inside a node sits in 1–2 CPU cache lines, runs at L1 speed with hardware prefetching and SIMD, reduces pointer overhead by $85\%$, and beats std::map by $2\times$ to $4\times$ on both lookups and iteration!
Implementation: In-Memory Cache-Aligned B-Tree Node Split (splitChild)
// C++20: Proactive B-Tree Node Split (CLRS Minimum Degree t, Max Keys = 2t - 1)
#include <vector>
#include <algorithm>
template <typename Key, int T = 4> // T=4 => max 7 keys, 8 children per cache-aligned node
struct BTreeNode {
bool isLeaf = true;
std::vector<Key> keys;
std::vector<BTreeNode*> children;
explicit BTreeNode(bool leaf) : isLeaf(leaf) {
keys.reserve(2 * T - 1);
if (!leaf) children.reserve(2 * T);
}
};
// Splits full child y = parent->children[idx] (which has 2T - 1 keys) into two nodes of T - 1 keys
template <typename Key, int T>
void splitChild(BTreeNode<Key, T>* parent, int idx) {
BTreeNode<Key, T>* y = parent->children[idx];
auto* z = new BTreeNode<Key, T>(y->isLeaf);
Key medianKey = y->keys[T - 1];
// Move upper T - 1 keys [T .. 2T-2] from y to z
z->keys.assign(y->keys.begin() + T, y->keys.end());
y->keys.resize(T - 1);
// If internal node, move upper T children [T .. 2T-1] from y to z
if (!y->isLeaf) {
z->children.assign(y->children.begin() + T, y->children.end());
y->children.resize(T);
}
// Insert medianKey and new right sibling z into parent
parent->keys.insert(parent->keys.begin() + idx, medianKey);
parent->children.insert(parent->children.begin() + idx + 1, z);
}
Failure Modes & Adversarial Pitfalls
- Random-Insertion Page Space Utilisation (~69% = $ln 2$) vs Right-Edge Sequential Inserts: Under random insertions, B+ Tree pages split into two $50\%$ full pages and average $\ln 2 \approx 69\%$ space utilization. If keys are inserted in strictly increasing order (e.g.,
AUTO_INCREMENTIDs) and pages split $50/50$, the left half is never touched again, wasting $50\%$ of disk space! Production engines (InnoDB, SQLite) detect right-edge monotonic inserts and perform fast $90/10$ or $100/0$ right-leaning splits to achieve $>95\%$ page packing. - Write Amplification on Small Random Updates: Updating a single 8-byte integer inside a $16\text{ KB}$ B+ Tree leaf page forces the storage engine to flush the entire $16\text{ KB}$ dirty page (plus WAL record) back to SSD—a $2000\times$ raw write amplification that motivates LSM-Trees!