← All structures

B-Tree

Trees · view source on GitHub

Read this in: English · Português · Español

Category: Trees

The problem

A Binary Search Tree answers "find this key" in O(height), but every node holds exactly one key and has at most two children — so height grows with log2(n) even in the best case, and degenerates to O(n) on adversarial insert order. For an in-memory tree that is usually fine. It stops being fine the moment the tree doesn't fit in memory: a real database index lives on disk, and every level of the tree crossed while searching is, in the worst case, one disk-page read. A million-row index as a binary tree needs roughly 20 levels — 20 potential page reads — just to find one row. Disk (or even SSD) latency per read dwarfs an in-memory comparison by orders of magnitude, so the number of levels, not the number of comparisons, is what actually has to be minimized.

The solution

Let each node hold many keys instead of one, and have proportionally many children instead of two. A B-tree of minimum degree t packs between t - 1 and 2t - 1 keys into every non-root node, with up to 2t children — so instead of branching by 2 at every level, it branches by t to 2t. That single change is what collapses the tree's height from O(log2 n) to O(log_t n): with t = 32, a tree that would need ~20 levels as a binary tree needs 3-4.

Insertion here uses the "preemptive split on the way down" strategy: while descending toward the leaf a new key belongs in, any full node encountered along the path — including the root — gets split before the recursion steps into it. That guarantees the parent of a full node about to be split always has room for the median key the split promotes upward, so a split never needs to "bubble back up" afterward. It also guarantees every leaf stays at exactly the same depth at all times, which is what makes "the tree's height" a single well-defined number instead of "the height of whichever branch happens to be deepest".

flowchart TD
    R["20 | 40"] --> C1["10"]
    R --> C2["25 | 30"]
    R --> C3["50 | 60 | 70"]
Operation Cost Why
get O(log_t n) height is O(log_t n); each level does an O(t) scan through that node's keys
insert O(log_t n) amortized same height bound; each preemptive split along the way costs O(t)
height() O(log_t n) walks the single leftmost path once — every leaf is at the same depth

Classic example

classic/BTree implements insert, get, and height() from scratch with a configurable minimum degree t (constructor parameter, default 3). Splitting is the hard part: splitChild breaks a full 2t - 1-key node into two t - 1-key nodes and promotes the median key/value into the parent, and insertNonFull re-checks the just-promoted key after every split it triggers, since that key might turn out to be the key being inserted (an overwrite, not a new key). BTreeTest forces splits at multiple levels with both ascending and descending 200-key insertion sequences (using t = 2, the smallest legal degree, to make splits as frequent as possible), and includes a deliberately constructed sequence that reinserts a key at the exact moment it's the median of a node that's about to be preemptively split — the single trickiest branch in the whole class.

Applied example: legacy bank account index simulation

applied/AccountIndexSimulation indexes account records by account number the way a real RDBMS index would during a mainframe-to-microservices modernization: "find account 4471203" needs to stay fast whether the table holds a thousand rows or a hundred million. This is exactly why production databases index with a B-tree (or a close relative) instead of a binary tree — each B-tree node is sized to match roughly one disk page, so a high branching factor directly means fewer pages touched per lookup, not just a smaller asymptotic exponent. AccountIndexSimulationTest indexes 100,000 accounts and asserts the resulting height stays at or below 4, and separately confirms that a lower minimum degree produces a measurably taller index for the same account count — the branching-factor claim, made concrete.

Benchmark

./gradlew :trees:b-tree:jmh

Real run (JMH 1.37, JDK 26.0.2, 2 warmup + 3 measurement iterations, 1 fork). Same shuffled key set (seeded Random(42)) inserted into both a B-tree (t = 32) and this repo's BinarySearchTree — the benchmark's @Setup prints each structure's real, just-measured height immediately after building it:

Height (levels to descend) size=1,000 size=10,000 size=100,000
B-tree (t=32) 2 3 3
BinarySearchTree (random insert order) 27 30 44

That's the entire point of this module: the same 100,000 keys need 3 levels in a t=32 B-tree and 44 in an unbalanced binary tree — roughly a 15x reduction in the number of node/page visits a lookup needs, growing wider (not just proportionally) as the key count grows.

get cost tells a different, equally honest story:

get cost size=1,000 size=10,000 size=100,000
B-tree (t=32) 37.7 ns 115.0 ns 167.3 ns
BinarySearchTree (random insert order) 35.3 ns 27.2 ns 48.2 ns

Counterintuitively, the B-tree's get is not faster here despite needing far fewer levels — at these sizes it's slightly slower. The reason is the other half of the height trade-off: each B-tree node holds up to 2t - 1 = 63 keys, and get scans that list linearly at every level, so total comparisons end up in the same ballpark as walking a taller binary tree one comparison at a time. The height win only pays for itself once each node visit has a real cost attached to it — a disk-page read, a network round trip, a cache-line miss on data too large for RAM — which is precisely the scenario AccountIndexSimulation models and a plain in-memory JMH benchmark cannot: an in-memory node visit is cheap regardless of branching factor, so this benchmark correctly shows the trade-off has two sides, not just the favorable one this module leads with.

When not to use it

Test coverage

100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:

./gradlew :trees:b-tree:jacocoTestReport

Report at trees/b-tree/build/reports/jacoco/test/html/index.html.

Unit tests

src/test/java/com/datastructures/trees/btree/classic/BTreeTest.java
package com.datastructures.trees.btree.classic;

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class BTreeTest {

    @Test
    void startsEmpty() {
        BTree<Integer, String> tree = new BTree<>();

        assertThat(tree.isEmpty()).isTrue();
        assertThat(tree.size()).isZero();
        assertThat(tree.height()).isZero();
    }

    @Test
    void constructorRejectsMinDegreeBelowTwo() {
        assertThatThrownBy(() -> new BTree<Integer, String>(1))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("minDegree");
    }

    @Test
    void insertThenGetReturnsTheStoredValue() {
        BTree<Integer, String> tree = new BTree<>(2);

        tree.insert(10, "ten");

        assertThat(tree.get(10)).isEqualTo("ten");
        assertThat(tree.contains(10)).isTrue();
        assertThat(tree.isEmpty()).isFalse();
        assertThat(tree.size()).isEqualTo(1);
        assertThat(tree.height()).isEqualTo(1);
    }

    @Test
    void getOnAMissingKeyReturnsNull() {
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "ten");

        assertThat(tree.get(99)).isNull();
        assertThat(tree.contains(99)).isFalse();
    }

    @Test
    void getOnAnEmptyTreeReturnsNull() {
        BTree<Integer, String> tree = new BTree<>(2);

        assertThat(tree.get(1)).isNull();
    }

    @Test
    void insertingAnExistingLeafKeyOverwritesItsValueWithoutGrowingSize() {
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "original");

        tree.insert(10, "replaced");

        assertThat(tree.get(10)).isEqualTo("replaced");
        assertThat(tree.size()).isEqualTo(1);
    }

    @Test
    void fillingANodeToCapacityDoesNotYetSplitIt() {
        // t = 2: a node holds up to 2t - 1 = 3 keys before it's full.
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "10");
        tree.insert(20, "20");
        tree.insert(30, "30");

        assertThat(tree.height()).isEqualTo(1);
        assertThat(tree.size()).isEqualTo(3);
        assertThat(tree.get(10)).isEqualTo("10");
        assertThat(tree.get(20)).isEqualTo("20");
        assertThat(tree.get(30)).isEqualTo("30");
    }

    @Test
    void insertingIntoAFullRootSplitsItAndGrowsHeight() {
        // t = 2: the 4th insert finds the root full (3 keys) and must preemptively split it.
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "10");
        tree.insert(20, "20");
        tree.insert(30, "30");
        tree.insert(40, "40");

        assertThat(tree.height()).isEqualTo(2);
        assertThat(tree.size()).isEqualTo(4);
        for (int key : new int[] {10, 20, 30, 40}) {
            assertThat(tree.get(key)).isEqualTo(String.valueOf(key));
        }
    }

    @Test
    void ascendingInsertionOrderForcesRepeatedSplitsAtMultipleLevels() {
        // t = 2 keeps nodes tiny (max 3 keys), so 200 ascending keys forces splits at several
        // levels, not just at the root.
        BTree<Integer, Integer> tree = new BTree<>(2);
        int count = 200;
        for (int key = 0; key < count; key++) {
            tree.insert(key, key * 10);
        }

        assertThat(tree.size()).isEqualTo(count);
        // A t=2 B-tree of 200 keys is nowhere near the O(n) degenerate height an unbalanced BST
        // would reach for the same sorted insertion order (200) - branching keeps it shallow.
        assertThat(tree.height()).isLessThan(15);
        for (int key = 0; key < count; key++) {
            assertThat(tree.get(key)).isEqualTo(key * 10);
        }
    }

    @Test
    void descendingInsertionOrderAlsoForcesRepeatedSplitsAtMultipleLevels() {
        BTree<Integer, Integer> tree = new BTree<>(2);
        int count = 200;
        for (int key = count - 1; key >= 0; key--) {
            tree.insert(key, key * 10);
        }

        assertThat(tree.size()).isEqualTo(count);
        assertThat(tree.height()).isLessThan(15);
        for (int key = 0; key < count; key++) {
            assertThat(tree.get(key)).isEqualTo(key * 10);
        }
    }

    @Test
    void randomInsertionOrderWithHigherMinDegreeStaysVeryShallow() {
        BTree<Integer, Integer> tree = new BTree<>(16);
        List<Integer> keys = new ArrayList<>();
        for (int i = 0; i < 10_000; i++) {
            keys.add(i);
        }
        Collections.shuffle(keys, new Random(7));
        for (int key : keys) {
            tree.insert(key, key);
        }

        assertThat(tree.size()).isEqualTo(10_000);
        assertThat(tree.height()).isLessThanOrEqualTo(4);
        for (int key : keys) {
            assertThat(tree.get(key)).isEqualTo(key);
        }
    }

    @Test
    void reinsertingAnExistingKeyThatIsCurrentlyTheMedianOfAFullNodeOverwritesItsValue() {
        // t = 2 (max 3 keys/node). Carefully built sequence so that, by the time 33 is
        // reinserted, it sits as the *middle* key of a full node reached only after a
        // preemptive split fires during the descent for this exact insert - this is what
        // exercises the "the split's promoted key equals the key we're inserting" branch.
        BTree<Integer, String> tree = new BTree<>(2);
        int[] buildSequence = {10, 20, 30, 40, 50, 60, 25, 28, 35, 33};
        for (int key : buildSequence) {
            tree.insert(key, "v" + key);
        }

        tree.insert(33, "v33-updated");

        assertThat(tree.get(33)).isEqualTo("v33-updated");
        assertThat(tree.size()).isEqualTo(buildSequence.length);
        // Every other key from the build sequence must have survived untouched.
        for (int key : buildSequence) {
            if (key != 33) {
                assertThat(tree.get(key)).isEqualTo("v" + key);
            }
        }
    }

    @Test
    void reinsertingAnExistingInternalKeyOverwritesItsValueWithoutDescending() {
        // Build a tree with t = 2, forcing at least one internal (non-leaf) key, then reinsert
        // that exact internal key - covers the equality check firing at a non-leaf node.
        BTree<Integer, String> tree = new BTree<>(2);
        tree.insert(10, "10");
        tree.insert(20, "20");
        tree.insert(30, "30");
        tree.insert(40, "40"); // splits the root; 20 becomes the new root's only key.

        tree.insert(20, "20-updated");

        assertThat(tree.get(20)).isEqualTo("20-updated");
        assertThat(tree.size()).isEqualTo(4);
    }

    @Test
    void heightGrowsAsMoreKeysAreInsertedWithASmallBranchingFactor() {
        BTree<Integer, Integer> tree = new BTree<>(2);
        assertThat(tree.height()).isZero();

        tree.insert(1, 1);
        assertThat(tree.height()).isEqualTo(1);

        for (int key = 2; key <= 3; key++) {
            tree.insert(key, key);
        }
        assertThat(tree.height()).isEqualTo(1);

        tree.insert(4, 4);
        assertThat(tree.height()).isEqualTo(2);
    }
}
src/test/java/com/datastructures/trees/btree/applied/AccountIndexSimulationTest.java
package com.datastructures.trees.btree.applied;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;

class AccountIndexSimulationTest {

    @Test
    void startsEmpty() {
        AccountIndexSimulation index = new AccountIndexSimulation();

        assertThat(index.size()).isZero();
        assertThat(index.height()).isZero();
    }

    @Test
    void indexedAccountIsFoundByAccountNumber() {
        AccountIndexSimulation index = new AccountIndexSimulation();
        AccountRecord record = new AccountRecord(1001L, "Leonardo Gomes", BigDecimal.valueOf(5000));

        index.index(record);

        assertThat(index.lookup(1001L)).isEqualTo(record);
        assertThat(index.size()).isEqualTo(1);
    }

    @Test
    void lookupOnAnUnindexedAccountNumberReturnsNull() {
        AccountIndexSimulation index = new AccountIndexSimulation();
        index.index(new AccountRecord(1001L, "Leonardo Gomes", BigDecimal.valueOf(5000)));

        assertThat(index.lookup(9999L)).isNull();
    }

    @Test
    void indexingManyAccountsWithAHighBranchingFactorStaysVeryShallow() {
        AccountIndexSimulation index = new AccountIndexSimulation(); // default minDegree = 32

        for (long accountNumber = 1; accountNumber <= 100_000; accountNumber++) {
            index.index(new AccountRecord(accountNumber, "holder-" + accountNumber, BigDecimal.ZERO));
        }

        assertThat(index.size()).isEqualTo(100_000);
        // log_32(100,000) ~= 3.4, so a handful of levels comfortably covers 100k accounts -
        // this is the concrete "far fewer disk-page reads" claim from this class's Javadoc.
        assertThat(index.height()).isLessThanOrEqualTo(4);
        assertThat(index.lookup(1L).holderName()).isEqualTo("holder-1");
        assertThat(index.lookup(100_000L).holderName()).isEqualTo("holder-100000");
    }

    @Test
    void aLowerMinDegreeProducesATallerIndexForTheSameAccountCount() {
        AccountIndexSimulation wideIndex = new AccountIndexSimulation(32);
        AccountIndexSimulation narrowIndex = new AccountIndexSimulation(2);

        for (long accountNumber = 1; accountNumber <= 1_000; accountNumber++) {
            AccountRecord record = new AccountRecord(accountNumber, "holder-" + accountNumber, BigDecimal.ZERO);
            wideIndex.index(record);
            narrowIndex.index(record);
        }

        assertThat(narrowIndex.height()).isGreaterThan(wideIndex.height());
    }
}

View full JaCoCo coverage report →