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
- Small, entirely in-memory datasets with no disk or network cost per node access: as the
getbenchmark above shows, a B-tree's per-node linear scan can make it slower than a plain Binary Search Tree once there's no page-read cost to amortize against. - This implementation only supports
insertandget— nodelete. Real B-tree deletion (borrowing from or merging with sibling nodes to keep every node at or abovet - 1keys) is one of the more intricate operations in this whole family of structures, and out of scope here. - Need ordered range queries in an already-in-memory structure without the disk-page framing? A
Binary Search Tree offers the same
O(log n)-shaped ordered access with a simpler implementation.
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());
}
}