B-Trees
By the end of this lesson you will be able to explain exactly why databases and filesystems store their indexes in B-trees instead of red-black trees, trace bTreeSearch, bTreeSplitChild, bTreeInsert and every one of bTreeDelete's six cases line by line, count real disk accesses as the unit of cost, and implement a fully working, generic B-tree in Dart.
0. Why B-trees exist: the disk-access model
Disks don't hand you one byte at a time — they read and write whole pages (typically 211–214 bytes) in a single trip. The standard disk-access model makes this explicit with two primitives: diskRead(x) brings a page (a tree node) into memory, and diskWrite(x) saves a modified page back. Every algorithm in this lesson is written to keep at most O(1) pages in memory at once, and running time is measured by two numbers: (1) the number of disk accesses, and (2) the CPU time (which is usually a lower-order concern once you're paying 10ms per disk trip).
Here is that model as Dart: diskRead and diskWrite become two counters, so every procedure below can report its disk cost.
Input size → what is feasible. n = 109 keys on disk: a balanced binary tree needs about 29 page reads per lookup (≈ 0.3 s at 10 ms each), a B-tree with t = 1001 needs at most 2 (the root stays in memory) — which is why the model counts pages, not CPU steps.
A B-tree is a balanced search tree designed exactly around this cost model: instead of a binary tree (2 children per node, like a red-black tree), a B-tree node is sized to fill one whole disk page and can have hundreds or thousands of children. Packing more branching into every single disk trip means the tree's height — and therefore the number of trips a search needs — shrinks dramatically. Concretely: with a branching factor of 1001 and height 2, a B-tree can index over one billion keys while the root stays cached in memory, so any single key can be found in at most 2 disk accesses. A balanced binary tree over a billion keys would need roughly 30 levels — 30 disk accesses per search, fifteen times slower.
t = 2 case: minimum degree t = 2 gives nodes with 2, 3 or 4 children, i.e. the 2-3-4 tree — the one that corresponds to red-black trees.Real database engines (PostgreSQL, MySQL/InnoDB, SQLite) build their indexes almost exclusively on B-trees (technically usually the B+-tree variant, which keeps all payload in the leaves; see the B⁺-tree question below) for exactly this reason — and it is why "explain how a database index works" interview questions almost always want a B-tree answer.
1. Definition of a B-tree
n dividers always has exactly n + 1 sub-folder pockets — one before the first divider, one between each pair, and one after the last.Every node x has these attributes (named the way the Dart code below stores them; everything is 0-indexed):
x.keys.length— the number of keys currently stored inx.x.keys[0] ≤ x.keys[1] ≤ … ≤ x.keys[x.keys.length − 1]— the keys themselves, kept in nondecreasing order.x.leaf—trueifxhas no children.- If
xis not a leaf, it hasx.keys.length + 1childrenx.children[0], …, x.children[x.keys.length]. Every key in the subtree rooted atx.children[i]lies betweenx.keys[i − 1]andx.keys[i](with−∞/+∞boundaries at the ends) — this is exactly the ordering property that makes searching work. - All leaves have the same depth — a B-tree is always perfectly height-balanced, by construction, never as an afterthought.
Every B-tree is built around one number, its minimum degree t ≥ 2 (also called the branching factor), which bounds how empty or full any node may get:
- Every node other than the root must have at least
t − 1keys (so at leasttchildren, if internal). - The root may have as few as 1 key (or, in an empty tree, 0 keys with the root being an empty leaf).
- Every node may have at most
2t − 1keys (at most2tchildren). A node with exactly2t − 1keys is called full.
t = 2 is the smallest legal minimum degree, and it gives every internal node between 2 and 4 children — exactly a 2-3-4 tree. Real databases use much larger t (tens to thousands) to make each node exactly one disk page.
The same rules as code (a node with n keys has n + 1 children, so t … 2t children):
t = 1 illegal? With t − 1 = 0, the "at least t − 1 keys" rule becomes "at least 0 keys", which puts no floor at all on how empty a node can be — you could have a node with 1 key and 2 children forever, degenerating into an ordinary unbalanced binary search tree with none of the height guarantees a B-tree is supposed to provide. The minimum-degree rule only does useful work once t ≥ 2.Height bound. If n ≥ 1, then for any n-key B-tree T of height h and minimum degree t ≥ 2: h ≤ logt((n+1)/2). The proof counts the fewest possible nodes at each depth (the root contributes ≥ 1 key; every other node contributes ≥ t − 1 keys, and there are at least 2ti-1 nodes at depth i for i ≥ 1), sums a geometric series, and solves for h. Because the branching factor t sits inside a logarithm's base, doubling or tripling it shrinks the height dramatically — this is the entire reason B-trees beat red-black trees for disk-resident data. Here is the bound in numbers for n = 109 keys (the root counts as depth 0; checked in verify/c18.dart):
| minimum degree t | 2 | 10 | 100 | 1001 |
|---|---|---|---|---|
| largest legal height h = ⌊logt((n+1)/2)⌋ | 28 | 8 | 4 | 2 |
A red-black tree on the same keys needs about 30 levels. Explore the exact minimal-node-count tree the proof constructs (each • is a key; the highlighted level is the one being counted):
The height bound as code, computed instead of quoted: the loop adds up the fewest keys a height-h tree can have (the same count the proof makes), it is compared with the closed form 2th − 1, and maxHeight inverts it to ⌊logt((n+1)/2)⌋ with exact integers (the floating-point log version is shown only to warn you):
Input size → what is feasible. n = 1012 keys, t = 2 → h ≤ 38; t = 103 → h ≤ 3; maxHeight runs at most ~40 loop steps, so it is O(logt n) for any n that fits in a 64-bit int (keep (n+1)·t < 9.2·1018).
t keeps every node between t−1 and 2t−1 keys (the root is exempt from the lower bound), keeps ALL leaves at the same depth, and bounds height by O(logt n) — a factor of lg t shorter than a comparable red-black tree, which is exactly what makes disk accesses rare.2. Basic operations
Three conventions run through every algorithm below: the root is always assumed to already be in memory (no diskRead needed to reach it, but a diskWrite is still needed whenever it changes); every node passed as a parameter has already been read from disk by its caller; and every algorithm makes a single downward pass from the root — it never has to back up and revisit a node it already left, which is exactly what keeps disk accesses to O(h).
bTreeCreate(t)
Input size → what is feasible. t is any integer from 2 to 105: one node allocation and one diskWrite regardless of t, so Θ(1) — a tree for n = 109 starts exactly like a tree for n = 1.
bTreeCreate runs the same 4 rows no matter how big t is or how many keys will eventually be inserted — it is O(1) disk operations and O(1) CPU time, period. Three players: t = 2 (smallest legal degree), t = 1000 (a realistic disk-page-sized branching factor) and your own t. Each takes exactly 4 frames and 1 diskWrite, which shows CREATE is independent of t and n.
bTreeSearch(x, k)
Example 2 — the key is not in the tree. Watch what happens when the scan reaches a leaf and no key matches: there is nowhere left to descend, so the search reports the key is absent.
Example 3 — your own tree and target key. Build any tree and search it:
Running time. The scan inside one node costs O(t) CPU time (up to 2t − 1 keys, scanned linearly), and the search visits O(h) = O(logt n) nodes — one disk access per node beyond the (already-in-memory) root. Total: O(t · logt n) CPU time, O(logt n) disk accesses.
Input size → what is feasible. n = 109, t = 1001 → at most 2 page reads and 2 · 2002 = 4004 key comparisons per lookup; t = 2 → at most 29 reads. A linear scan of all n keys (109 steps ≈ 10 s) is only acceptable for n up to about 104 keys.
O(h)) is completely unaffected — disk accesses are about how many nodes you visit, not how you search within one already-loaded node — but the CPU time per node drops from O(t) to O(lg t), so overall CPU time becomes O(lg n), independent of t.bTreeSplitChild(x, i)
2t − 1 dividers, the maximum allowed). Before it can accept one more, you split it in half: the middle divider gets promoted up into the parent folder (which gains one more divider and one more sub-folder pocket), and the two halves either side of it become two separate, now half-full sub-folders.Example 1 — splitting a full leaf.
Example 2 — splitting a full internal node (notice the child pointers get redistributed too, not just the keys):
Example 3 — your own node to split:
Input size → what is feasible. t ≤ 1001: a split copies at most 2t − 1 = 2001 keys (Θ(t) CPU) but always costs exactly 3 diskWrites, and a single INSERT splits at most once per level (h ≤ 40).
bTreeSplitChild assumes x is not full but its child y = x.children[i] is full (exactly 2t − 1 keys). It creates a new node z, moves y's largest t − 1 keys (and, if internal, its largest t children) into z, shrinks y down to its smallest t − 1 keys, and inserts the one median key (plus a pointer to z) into x. Cost: Θ(t) CPU time (copying up to t keys/pointers) and exactly 3 disk writes (y, z, x) — O(1) disk operations.
bTreeInsert(tree, k) and bTreeInsertNonFull(x, k)
Example 1 — a mixed 19-key sequence: insert 40, 15, 70, 25, 90, 5, 60, 35, 80, 10, 55, 30, 95, 20, 65, 45, 85, 50, 75 one at a time into an empty B-tree with t = 2 and watch proactive splitting happen live:
Example 2 — ascending keys, t = 3. Inserting 1, 2, 3, … in increasing order always lands in the rightmost leaf, which fills up and splits over and over; every left half is left only minimally full (t−1 keys). Watch the height grow when the root fills:
Example 3 — your own minimum degree and key sequence:
bTreeInsert first checks only the root: if it's full, a brand-new empty root is created above it, and the (only) way a B-tree ever grows in height happens right here — the old root gets split as the new root's first (and, for a moment, only) child. Either way, bTreeInsertNonFull is then called, and it is a genuinely non-full node every single time it recurses — because any full child it is about to step into gets split first, bTreeInsertNonFull rows 18-19 — before the recursive call on row 22.
Running time. Exactly like search, both procedures do O(h) disk accesses (at most one diskRead per level going down, one diskWrite for the leaf, and 3 diskWrites per split, at most one split per level) and O(t · h) = O(t · logt n) CPU time (each level does O(t) work: a linear scan plus, sometimes, a split). Because bTreeInsertNonFull is tail-recursive (and directly usable in practice), it can be rewritten as a plain loop needing only O(1) pages in memory at any instant — never the whole root-to-leaf path at once.
Input size → what is feasible. n = 106 keys inserted one by one with 2 ≤ t ≤ 16 → about 108 simple steps (fine for ~1 s); one insert is at most h page reads and 3h + 1 page writes (checked above); inserting into a sorted array would cost up to n/2 = 5·105 shifts per key instead.
3. Deleting a key
t − 1 dividers) on the way down, because this algorithm — like insertion — commits to a single downward pass with no backtracking. So before stepping into any child, you first top it up (borrow a divider from a sibling, or merge two half-empty folders into one) if it's sitting right at the minimum.The strengthened invariant is the key idea: whenever bTreeDelete recurses into a node x, it guarantees x has at least t keys — one more than the bare minimum — so that even if we end up removing a key from x, it will never drop below the legal minimum of t − 1. This is arranged in two places: when the key to delete is found in an internal node (cases 2a/2b/2c), and when the key must be found by descending further (case 3, the "cases 3a/3b" preparation step below).
Deleting from a leaf — Case 1
Deleting a key found in an internal node — Cases 2a, 2b, 2c
Three fresh, independent trees below — each one isolates exactly ONE of the three cases so you can see it without any other case obscuring it:
Each case is one small function; the dispatcher deleteFrom further down calls them. Cases 2a and 2b first find the predecessor / successor; 2c and 3b share mergeChildren.
Descending through an underfull child — Cases 3a, 3b
Two more fresh, independent trees — one isolating case 3a (borrowing), one isolating case 3b (merging) and, immediately after, a second deletion on that same tiny tree that makes the ROOT itself disappear — the only way a B-tree's height ever decreases:
Example 4 — your own tree, minimum degree, and deletions (delete several keys in a row and watch the case label change live):
A five-step worked example. Starting from a t = 3 tree of 23 keys under the root [100], the player deletes 45, 80, 30, 10 and 70 in turn. Each step triggers a different case — 1, 2a, 2c, 3b (which also makes the root disappear) and 3a — and the scene label names the case the captions must agree with:
All six cases wired together — the whole bTreeDelete as one downward pass:
Running time. Exactly like insertion: O(h) disk accesses (the strengthened invariant needs at most one extra look at a sibling per level) and O(t · h) = O(t · logt n) CPU time.
Input size → what is feasible. n = 106 deletions with 2 ≤ t ≤ 16 → about 108 simple steps; each delete does at most about 4(h + 1) page reads and writes (the random test above measures a worst case below 4(h + 1)), where h ≤ 18 for t = 2.
4. From pseudocode to working Dart
The pseudocode in this lesson is already 0-indexed, exactly like Dart's List: x.keys[i] and x.children[i] mean the same thing in both. The invariant children.length == keys.length + 1 holds for every internal node, and when you scan with an index i, the child before keys[i] is children[i] and the child after it is children[i + 1]. Here is the complete, generic implementation (full source, exercised by 220 randomized trials plus every worked example above, lives in verify/c18.dart):
class BTreeNode<K extends Comparable> {
BTreeNode(this.leaf);
bool leaf;
List<K> keys = [];
List<BTreeNode<K>> children = [];
}
class BTree<K extends Comparable> {
BTree(this.t) {
if (t < 2) throw ArgumentError('minimum degree t must be >= 2');
}
final int t;
BTreeNode<K>? root;
int diskReads = 0;
int diskWrites = 0;
int get maxKeys => 2 * t - 1;
int get minKeys => t - 1;
// bTreeSearch(x, k)
bool contains(K k) => root == null ? false : _search(root!, k, true);
bool _search(BTreeNode<K> x, K k, bool isRoot) {
if (!isRoot) diskReads++;
var i = 0;
while (i < x.keys.length && k.compareTo(x.keys[i]) > 0) {
i++;
}
if (i < x.keys.length && k.compareTo(x.keys[i]) == 0) return true;
if (x.leaf) return false;
return _search(x.children[i], k, false);
}
// bTreeCreate + bTreeInsert
void insert(K k) {
if (root == null) {
root = BTreeNode<K>(true); // bTreeCreate
diskWrites++; // its diskWrite
}
final r = root!;
if (r.keys.length == maxKeys) {
final s = BTreeNode<K>(false);
s.children.add(r);
root = s;
_splitChild(s, 0);
_insertNonFull(s, k);
} else {
_insertNonFull(r, k);
}
}
// bTreeSplitChild(x, i) — 0-indexed i, splits the FULL child x.children[i]
void _splitChild(BTreeNode<K> x, int i) {
final y = x.children[i];
final z = BTreeNode<K>(y.leaf);
z.keys = y.keys.sublist(t);
if (!y.leaf) z.children = y.children.sublist(t);
final median = y.keys[t - 1];
y.keys = y.keys.sublist(0, t - 1);
if (!y.leaf) y.children = y.children.sublist(0, t);
x.children.insert(i + 1, z);
x.keys.insert(i, median);
diskWrites += 3; // y, z, x
}
// bTreeInsertNonFull(x, k)
void _insertNonFull(BTreeNode<K> x, K k) {
var i = x.keys.length - 1;
if (x.leaf) {
x.keys.add(k); // grow by one slot, then shift into place
while (i >= 0 && k.compareTo(x.keys[i]) < 0) {
x.keys[i + 1] = x.keys[i];
i--;
}
x.keys[i + 1] = k;
diskWrites++;
} else {
while (i >= 0 && k.compareTo(x.keys[i]) < 0) {
i--;
}
i++;
diskReads++;
if (x.children[i].keys.length == maxKeys) {
_splitChild(x, i);
if (k.compareTo(x.keys[i]) > 0) i++;
}
_insertNonFull(x.children[i], k);
}
}
}
The full verify/c18.dart file also includes delete(k) (all six cases, plus the root-shrink check), inorder(), and checkInvariants() — the exact same logic this page's JavaScript animation engine runs, just translated line-for-line into 0-indexed Dart.
i, the child that lies before that key is children[i] and the child after it is children[i + 1] — the “after” side is one further along, not the same index. Splitting child i likewise puts the new sibling at children[i + 1] and the promoted median at keys[i].Quiz
Interview questions
Cheat sheet
| Operation | Disk accesses | CPU time | Notes |
|---|---|---|---|
| bTreeCreate(t) | O(1) | O(1) | Independent of t and n — one empty leaf root |
| bTreeSearch(x, k) | O(h) = O(logt n) | O(t·h) = O(t·logt n); O(lg n) with binary search inside a node | (x.keys.length + 1)-way branch at each node |
| bTreeSplitChild(x, i) | O(1) — exactly 3 writes | Θ(t) | Median key moves up; called proactively, never after the fact |
| bTreeInsert(tree, k) | O(h) | O(t·h) = O(t·logt n) | Root split is the ONLY way height increases |
| bTreeInsertNonFull(x, k) | O(h) | O(t·h) | Tail-recursive → rewritable as an O(1)-memory loop |
| bTreeDelete(x, k) | O(h) | O(t·h) = O(t·logt n) | 6 cases (1, 2a, 2b, 2c, 3a, 3b); root shrink is the ONLY way height decreases |
| Height bound | h ≤ logt((n+1)/2) — vs. Θ(lg n) for a red-black tree: a factor of lg t fewer levels | ||
| t = 2 special case | Exactly a 2-3-4 tree (2, 3, or 4 children per internal node); corresponds to a red-black tree (each black node absorbs its red children) | ||
| Space | Θ(n) keys stored, Θ(n/t) nodes; every node uses Θ(t) words regardless of how full it is | ||