Cairn
data structures · first multiway search tree · O(log n)

back to Node-Linked Trees

B-Tree

Every tree on this site so far — BST, AVL, red-black, splay — branches at most two ways per node. A B-tree branches many ways: each node holds several sorted keys at once, and a node with k keys has k + 1 children, one for each gap between (and around) the keys. That single change has a large consequence — the tree gets wide instead of deep. A binary tree holding a million keys needs roughly 20 levels even perfectly balanced; a B-tree with, say, 100 keys per node needs about 3. Every level of a tree is a pointer to chase, and for a structure that lives on disk rather than in RAM — a database index, a filesystem's directory tree — a pointer chase is a seek, and a seek is millions of times slower than comparing a few more keys already sitting in memory once you've paid for the block. Fewer levels means fewer seeks; that trade is the entire reason B-trees exist.

The parameter that controls the branching factor is called the minimum degree, written t. Every node except the root must hold between t - 1 and 2t - 1 keys, and every internal node's number of children is always exactly one more than its number of keys. Small t values are easiest to draw and reason about — this page's demo fixes t = 2, so every node holds 1 to 3 keys and has 2 to 4 children. That specific case has a name of its own: a t = 2 B-tree is exactly a 2-3-4 tree, and it turns out to be the same structure as a red-black tree wearing a different notation — a 3-key B-tree node unpacks into a black node with two red children, a 2-key node unpacks into a black node with one red child folded onto either side, and a red-black tree's height-balance rule (no root- to-leaf path has more than twice the black nodes of any other) is the same balance guarantee this page's split rule enforces directly on the wide node. Two very different-looking pages on this site describe one underlying shape.

Try it

The tree below was built by inserting 10, 20, 30, … 90 — nine values, strictly ascending, the exact input that collapsed a plain BST into a straight chain and forced splay/AVL/ red-black to do real rebalancing work to avoid it. A B-tree doesn't need to react at all: it stays at height 3 the whole way through, verified below, not because anything detects and fixes imbalance after the fact, but because insert itself never lets a node overflow past 2t - 1 keys in the first place. Try inserting 25 — it lands in the leaf that already holds 30, with no split needed. Then try 75: the leaf holding 70, 80, 90 is already full, so watch the log name the split before it happens — the middle key (80) moves up into the parent, the node divides into 70 and 90, and only then does the walk continue downward to place 75. Search works the ordinary way: at each node, scan its keys to find the value, an exact match, or the gap to descend through. Delete has its own two-step story, covered in full below — try deleting 30 first: both of the root's children hold only one key each (the legal minimum), so removing 30 forces a merge that cascades down two levels and shrinks the tree's height from 3 to 2, all in one click. Then delete 10: this time a sibling has a key to spare, so the fix is a cheap borrow (a rotation through the parent) instead of a merge — no height change.

Loaded by inserting 10 through 90 in steps of 10 — height stays 3. Try inserting 75 to see a split, or deleting 30 to see a merge.

Splitting a full node

A node is full once it holds 2t - 1 keys — for this page's t = 2, that's 3. Splitting a full node y (the i-th child of some parent) works in one fixed move: the top t - 1 keys of y (and, if y isn't a leaf, its top t children) are cut off into a brand-new node z; the single key that used to sit between those two halves — y's new maximum after the cut — moves up into the parent, landing at index i, with z inserted as the parent's new child at index i + 1. y and z now each hold exactly t - 1 keys — the legal minimum, half of what y had before minus the one that moved up — and the parent has gained exactly one key and one child, keeping its own key/child-count relationship intact. If the parent was itself the root and had no room, splitting it is what grows the tree by one level: the only place a B-tree's height ever increases is at the root, which is also why every root-to-leaf path in a B-tree is always exactly the same length (checked below, not just claimed) — a leaf never ends up shallower than another because nothing about a split can make one subtree's leaves diverge from another's depth.

Insert: split on the way down, not on the way back up

The textbook alternative to this page's approach inserts first, then walks back up fixing any node that ended up over capacity — workable, but it needs either parent pointers or a call stack that remembers the whole descent path, so it can unwind and split. This page's algorithm instead makes it structurally impossible for a full node to ever be walked into:

  1. Before starting, check whether the root itself is full. If it is, split it first — this is the one case a full node isn't a child of anything already checked, so it needs its own explicit check, and it's the only step that ever adds a new root.
  2. Walk down from the (now guaranteed non-full) root. At each internal node, find which child the new value belongs under — before descending into it, check whether that child is full. If it is, split it right there, which promotes a key into the current node and may shift which child index the search should follow (the newly promoted key could now sit on the boundary).
  3. Because every node is split the moment before it would be descended into, the node actually reached at the bottom always has room. Insert the new key into that leaf directly, in sorted position — no further propagation needed, ever.

The whole operation is a single top-down pass with no backtracking, which is the practical payoff of proactive splitting: a node can be discarded (or, in an on-disk implementation, its page written back) the moment the walk moves past it, since nothing later will ever need to revisit it.

Search starts at the root and, at each node, scans its sorted keys left to right. Three things can happen at a key: the target is smaller (keep scanning, or descend through the gap immediately before it if this is the last key checked), it matches exactly (done — found), or the target is larger than every key in the node (descend through the last gap, past all of them). Descending means following the child pointer sitting at that gap's index; falling off the bottom of a leaf without a match means the value isn't in the tree. With a small t like this page's, scanning a node's keys is a handful of comparisons; production B-trees sized for disk pages often use dozens or hundreds of keys per node and binary-search within each one instead of scanning linearly — a real optimization, but one this page's reference implementation skips, since it wouldn't change any comparison count that matters at t = 2.

Delete: fill before descending, the mirror image of split

Insert only ever has to worry about one full node on the way down at a time, which is why proactively splitting it just before descending is enough. Delete has the opposite problem: removing a key can leave a node under the minimum of t - 1 keys, and unlike a full node (which only insert can cause), an under-full node can cascade — fixing one node's shortage by pulling a key from it can immediately make its neighbor short too. The proactive discipline this page's insert algorithm uses generalizes directly: instead of guaranteeing every node about to be entered has room to grow, delete guarantees every node about to be entered has room to shrink — at least t keys, one more than the bare minimum, so that losing one during the visit still leaves it legal.

  1. Search for the key k being deleted the ordinary way, at each node deciding whether it sits in this node's own keys or in the gap leading to some child.
  2. If k is one of the current node's own keys and the node is a leaf, removing it is a direct splice — nothing else can be affected, since a leaf has no children to keep consistent.
  3. If k is one of the current node's own keys but the node is internal, it can't just be spliced out — every other key still needs a value on each side of it. Instead: if the child immediately to k's left has ≥ t keys, find its largest key (the predecessor, always in a leaf — keep following the rightmost child down), copy it into k's old slot, then recursively delete the predecessor from that child instead (a strictly simpler problem: deleting a key that's now known to live in a leaf, or another internal node one level down). Symmetrically, if the right child has ≥ t keys, pull up the successor (its smallest key) instead. If neither child can spare a key, merge the two children and k itself into one node, then delete k from that merged node.
  4. If k isn't in the current node at all, the walk is about to descend into whichever child's gap it belongs in — but first, if that child has only t - 1 keys (the bare minimum, one fill away from going illegal), fix it: borrow a key from a sibling that has one to spare — the same rotation shape as AVL's or red-black's rebalancing, just through a wide node instead of a two-way one: the parent's separator moves down into the thin child, and the sibling's outermost key moves up to replace it, so the ordering invariant between them never breaks — if either sibling has ≥ t keys, or, if both siblings are also sitting at the bare minimum, merge the child with one sibling through their shared parent separator — the same merge operation used by the internal-key case above, just triggered on the way down instead of by finding k directly.
  5. A merge always removes one key and one child from the parent. If that parent was the root and this was its only key, the root itself is now empty with exactly one child — that child becomes the new root, and the tree's height drops by one. This is the only way a B-tree's height ever decreases, the mirror image of the only way it ever increases (splitting a full root).

Like insert, the whole walk is single-pass with no backtracking — every node the search actually descends into is fixed up before the descent, never after, so nothing already passed needs to be revisited. Borrowing costs the same O(1) work as a merge locally, but a borrow stops the fix right there; only a merge can propagate, and even then it propagates at most once per level on the way down, the same way a split propagates at most once per level on the way up during insert.

Reference implementation

class BTree {
  #t;
  #root;

  constructor(t = 2) {
    this.#t = t;
    this.#root = { keys: [], children: [], leaf: true };
  }

  #splitChild(parent, i) {
    const t = this.#t;
    const y = parent.children[i];
    const z = { keys: y.keys.splice(t, t - 1), children: [], leaf: y.leaf };
    const midKey = y.keys.pop();               // must be pop(), not shift() — see Pitfalls
    if (!y.leaf) z.children = y.children.splice(t, t);
    parent.children.splice(i + 1, 0, z);
    parent.keys.splice(i, 0, midKey);
  }

  #insertNonFull(node, k) {
    let i = node.keys.length - 1;
    while (i >= 0 && k < node.keys[i]) i--;
    if (node.leaf) {
      node.keys.splice(i + 1, 0, k);
      return;
    }
    i++;
    if (node.children[i].keys.length === 2 * this.#t - 1) {
      this.#splitChild(node, i);
      if (k > node.keys[i]) i++;
    }
    this.#insertNonFull(node.children[i], k);
  }

  search(k, node = this.#root) {
    let i = 0;
    while (i < node.keys.length && k > node.keys[i]) i++;
    if (i < node.keys.length && node.keys[i] === k) return true;
    if (node.leaf) return false;
    return this.search(k, node.children[i]);
  }

  insert(k) {
    if (this.search(k)) return false;           // duplicate — see Pitfalls
    if (this.#root.keys.length === 2 * this.#t - 1) {
      const newRoot = { keys: [], children: [this.#root], leaf: false };
      this.#root = newRoot;
      this.#splitChild(newRoot, 0);
    }
    this.#insertNonFull(this.#root, k);
    return true;
  }

  // ---- delete ----
  #getPred(node) {
    while (!node.leaf) node = node.children[node.children.length - 1];
    return node.keys[node.keys.length - 1];
  }
  #getSucc(node) {
    while (!node.leaf) node = node.children[0];
    return node.keys[0];
  }
  #borrowFromLeft(node, idx) {
    const child = node.children[idx];
    const sibling = node.children[idx - 1];
    child.keys.unshift(node.keys[idx - 1]);
    node.keys[idx - 1] = sibling.keys.pop();
    if (!child.leaf) child.children.unshift(sibling.children.pop());
  }
  #borrowFromRight(node, idx) {
    const child = node.children[idx];
    const sibling = node.children[idx + 1];
    child.keys.push(node.keys[idx]);
    node.keys[idx] = sibling.keys.shift();
    if (!child.leaf) child.children.push(sibling.children.shift());
  }
  #merge(node, idx) {
    const child = node.children[idx];
    const sibling = node.children[idx + 1];
    child.keys.push(node.keys[idx], ...sibling.keys);
    if (!child.leaf) child.children.push(...sibling.children);
    node.keys.splice(idx, 1);
    node.children.splice(idx + 1, 1);
  }
  #fill(node, idx) {
    const t = this.#t;
    if (idx > 0 && node.children[idx - 1].keys.length >= t) {
      this.#borrowFromLeft(node, idx);
    } else if (idx < node.children.length - 1 && node.children[idx + 1].keys.length >= t) {
      this.#borrowFromRight(node, idx);
    } else if (idx < node.children.length - 1) {
      this.#merge(node, idx);
    } else {
      this.#merge(node, idx - 1);            // idx was the last child — merge with its left sibling instead
    }
  }
  #removeFromInternal(node, idx) {
    const t = this.#t;                        // must check capacity — see Pitfalls
    const k = node.keys[idx];
    if (node.children[idx].keys.length >= t) {
      const pred = this.#getPred(node.children[idx]);
      node.keys[idx] = pred;
      this.#remove(node.children[idx], pred);
    } else if (node.children[idx + 1].keys.length >= t) {
      const succ = this.#getSucc(node.children[idx + 1]);
      node.keys[idx] = succ;
      this.#remove(node.children[idx + 1], succ);
    } else {
      this.#merge(node, idx);
      this.#remove(node.children[idx], k);
    }
  }
  #remove(node, k) {
    const t = this.#t;
    let idx = 0;
    while (idx < node.keys.length && node.keys[idx] < k) idx++;
    if (idx < node.keys.length && node.keys[idx] === k) {
      if (node.leaf) { node.keys.splice(idx, 1); return; }
      this.#removeFromInternal(node, idx);
      return;
    }
    if (node.leaf) return;                    // not present — delete() already checked search() first
    if (node.children[idx].keys.length < t) this.#fill(node, idx);   // must happen before descending — see Pitfalls
    const nextIdx = Math.min(idx, node.children.length - 1);
    this.#remove(node.children[nextIdx], k);
  }
  delete(k) {
    if (!this.search(k)) return false;
    this.#remove(this.#root, k);
    if (this.#root.keys.length === 0 && !this.#root.leaf) {
      this.#root = this.#root.children[0];    // root emptied by a merge — tree shrinks by one level
    }
    return true;
  }
}

The interactive demo above uses an equivalent set of functions with extra bookkeeping — a stable id on every node, a recorded descent path, and a list of split/merge/borrow events — purely to drive the highlighting and log text; the underlying splitting, insertion, and deletion logic is identical. Insert was verified against a plain JavaScript Set as a reference model across 500 randomized trials of 60 mixed inserts each (values drawn from a small range to force frequent splits and duplicate attempts), checking after every single insert: every node's key count within [t - 1, 2t - 1] (root exempt), every node's child count exactly one more than its key count, every node's keys strictly sorted, every leaf at the identical depth, an in-order traversal matching the Set's sorted contents exactly, and search agreeing with the Set for five random probes after each insert — 30,000 inserts, zero mismatches. The checker was self-tested against two deliberately broken variants first: swapping pop() for shift() in #splitChild (caught at the third insert of the very first trial — in-order traversal came back out of order) and dropping the z.children = y.children.splice(t, t) line (caught by a crash the moment an internal node's own split fired, not a leaf's), before trusting a clean run on the real code. Re-verified with a real click-driven fake-DOM harness against the exact shipped functions: the loaded 10-through-90 sequence lands at height 3 with the exact node shapes named in Pitfalls below, and inserting 75 against that loaded state logs a split of the 70, 80, 90 leaf (80 promoted) before placing 75, matching the scratch reference's own trace of the identical sequence, and running both broken variants against the shipped functions confirmed the same failures named in Pitfalls: the children-move bug crashes loading this very page's own 10-through-90 sequence (not just a synthetic one), and the promoted-key bug leaves the shipped tree's own structure misordered. See /tmp/btree/ref.js, test1.js, test2_broken.js, test3_ascending.js, test4_pitfalls.js, test5_small_pitfall.js, and /tmp/btree/harness.js/fake_dom.js/test_broken_children.js/ test_broken_shift.js, scratch, not committed.

Delete got the same two-layer treatment. A scratch reference implementation ran 500 trials of 60 randomized mixed insert/delete operations each (30,000 operations total, values drawn from a small range to force frequent splits, merges, and borrows) against a plain Set model, checking after every single operation the same invariants as insert plus one more (both insert and delete results — present/absent — have to match the model exactly) — zero mismatches. The checker was self-tested first against two deliberately broken delete variants: skipping the pre-emptive #fill call before descending, and having #removeFromInternal always take the predecessor route without checking either child's capacity first (both described in full in Pitfalls below) — both caught immediately. Re-verified against the exact shipped Delete button through the same real click-driven fake-DOM harness: starting from this page's own loaded 10-through-90 tree, deleting 30 merges the root's two children through separator 40 into [20, 40, 60], discovers the child now holding 30 is also at the minimum, merges again through separator 40 into [30, 40, 50], removes 30 directly, and then shrinks the root — landing on [20, 60] over [10], [40, 50], [70, 80, 90], height 2. Deleting 10 next borrows instead: the sibling [40, 50] has a spare key, so 20 rotates down and 40 rotates up with no merge at all, landing on [40, 60] over [20], [50], [70, 80, 90]. Deleting 60 after that hits the internal-key case directly (60 is a root key): its right child [70, 80, 90] has keys to spare, so the successor 70 is pulled up in its place, landing on [40, 70] over [20], [50], [80, 90]. All three outcomes match the class-based reference implementation's own trace of the identical sequence exactly, not just independently plausible-looking trees.

Pitfalls

Promoting the wrong key during a split silently breaks the tree's ordering, not just its balance. The key that moves up during a split has to be y's new maximum after the top t - 1 keys are cut away — y.keys.pop(), not y.keys.shift(). Swap the two and the bug doesn't crash and doesn't look wrong at a glance: inserting 1, 2, 3, 4 with the broken version produces a root of [1] with children [2] and [3, 4] — every value from the input is present somewhere, so a naive "does it contain everything" check would pass. But the parent key 1 is supposed to mean "everything left of me is < 1, everything right is > 1," and the left child holds 2, which violates that outright. Checked directly: calling search(2) against this broken tree returns false — it compares 2 against the root's key 1, decides 2 belongs in the right subtree since 2 > 1, and never looks left, even though 2 is sitting right there. An in-order traversal makes the same bug visible from a different angle: it comes back 2, 1, 3, 4, not sorted, immediately.

Forgetting to move children during an internal-node split doesn't fail until the first internal split — which this page's own loaded example reaches on its very last insert. The first several splits while building the 10-through-90 demo only ever split leaves (leaves have no children to move, so a version of #splitChild missing the z.children = y.children.splice(t, t) line works by pure accident every time). The bug stays invisible until a node that already has children becomes full and gets split — which, tracing through this exact sequence, first happens splitting the root while inserting 90, the ninth and last value. At that point z is created with an empty children array but a promised two children (since z.leaf is false), and the very next line that tries to read node.children[i].keys during the following descent hits undefined and throws — confirmed by running the broken variant against this page's own loaded sequence, not a synthetic one.

Skipping the duplicate check before inserting doesn't corrupt values — it corrupts the "keys are sorted" guarantee everything else depends on. #insertNonFull's leaf case only ever compares with < while finding where to splice a new key in; it has no reason to also check ===, because insert is supposed to rule out duplicates before it's ever called. Remove that guard and insert 10, 20, 30, 40, 20, 20: the tree ends up with a node holding 20, 20 side by side, and the in-order traversal comes back 10, 20, 20, 20, 30, 40 — every value in non-decreasing order, easy to glance at and call fine, but two adjacent keys are equal, which the strict ordering every proof and every duplicate-free assumption on this page relies on forbids. Confirmed by running the exact broken sequence above.

Skipping the pre-emptive #fill call doesn't crash — it just leaves a node under the legal minimum, silently, until something else notices. Delete a value from a node that's exactly at the minimum without first checking (and fixing) it, and the splice still "succeeds": running this broken variant against this page's own shipped functions, deleting 30 from the loaded tree leaves the leaf that held it holding 0 keys — one below the t - 1 = 1 minimum every complexity claim on this page assumes. Nothing about search breaks immediately (the empty leaf just reports "not found" at that dead end, which happens to be correct for anything that would've been there), and the very next insert(25) silently "heals" the empty leaf by dropping 25 straight into it — so the bug never announces itself with a wrong answer, only with a structure that's temporarily out of spec. Confirmed on the exact shipped Delete/Insert buttons in that order, not reasoned about.

Choosing predecessor vs. successor without checking either child's capacity first can leave a keyless "pass-through" node — the exact height blowup B-trees exist to prevent, smuggled back in through delete. If #removeFromInternal always takes the predecessor route (skips the node.children[idx].keys.length ≥ t check and the successor/merge fallbacks entirely), deleting an internal key whose left child is already at the minimum pulls that child's own minimum key up and then has to merge underneath to fix the resulting shortage — running this broken variant against the shipped functions, deleting 40 (the loaded tree's own root key) from the loaded tree leaves an internal node with 0 keys and exactly one child. Every one of the tree's 8 remaining values is still search-able (checked directly, all eight), so this bug is invisible to a pure correctness check — but it's added a level to that one path without adding any branching, which is precisely the shape of degeneration the intro paragraph says a B-tree can't suffer from. It shows up as a measurable regression, not just a theoretical one: search(20), which the correct tree answers in 2 node visits (20 sits at the root's own child, no need to reach a leaf), needs 3 visits after this bug — the demo's own descent counter, not a hand count.

Where B-trees show up

Complexity

Time: a B-tree of minimum degree t holding n keys has height at most log_t((n + 1) / 2) — the standard bound, not re-derived here. The payoff of a large t is concrete: at t = 100, a million keys need a height of at most 2 (three levels total, root included) — three node reads to find any key among a million, versus a perfectly balanced binary tree's roughly 20 levels for the same n. search, insert, and delete all cost O(log_t n) node visits — delete's fill (borrow or merge) is the same constant-work-per-level check as insert's split, just running on the way down instead of being triggered by a value on the way up — each visit doing O(t) work scanning that node's keys (or O(log t) with a binary search inside the node, for implementations where t is large enough for that to matter) — O(t log_t n) overall, which is why t is chosen to match a disk page's size exactly: large enough to keep the tree shallow, small enough that scanning one full node is still cheap once it's already been read into memory. Space: O(n); unlike the site's binary trees, a B-tree's per-node overhead is dominated by the keys and child pointers a node holds (up to 2t - 1 and 2t respectively) rather than a fixed per-node cost, and typically runs well below one pointer per key once t is large, the opposite of a binary tree's fixed two-pointers-per-key floor.

See Choosing a Search Tree for why disk- or cache-backed storage is the one factor that settles this choice on its own, ahead of every other question the other four entries argue over.