Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Balanced Trees  ›  Lesson

B-Trees, the Disk, and the 2-3 Tree

20 minA wide node costs one disk seek whether it holds 2 keys or 200 — so branching factor, not key count, is what shrinks the tree
You're building a piece ofQuarry — the index layer
This piece — tree_depth_comparison(): Compares a wide B-tree's depth against a binary BST's, in real disk reads.
Scenario Quarry's index layer is about to move from an in-memory binary index to a disk-backed one, and the team wants real numbers before picking a page size.
Your task
Build tree_depth_comparison(n, order): report the depth of a B-tree of the given order (max keys per node) over n keys, alongside a binary BST's worst case (n) and balanced case. Return {"btree_depth": ..., "bst_worst_depth": ..., "bst_balanced_depth": ...}. Example: tree_depth_comparison(1000000, 100) -> {"btree_depth": 3, "bst_worst_depth": 1000000, "bst_balanced_depth": 20}.

B-Trees, the Disk, and the 2-3 Tree

In short: a binary tree wastes disk reads. Every level is one more seek — a real, millisecond-scale trip to storage — so a binary tree over a million keys costs 20 seeks to find one row. A B-tree widens each node to hold many keys and many children (a "page" sized to match one disk read), so the same million keys cost 3 or 4 seeks, not 20. The 2-3 tree is the smallest useful B-tree — every node holds 1 or 2 keys and has 2 or 3 children — and it's a good place to see why branching factor matters before looking at a real, wide disk index.

A short, wide tree of fat boxes (a B-tree, 3 levels) sitting beside a tall, thin tree of small circles (a binary BST, 20 levels) over the same million keys, with a caption under each: "3 reads" and "20 reads"

Why database indexes use B-trees, not BSTs

Every real database (Postgres, MySQL, SQLite) indexes rows with a B-tree, not a binary search tree. The reason is physical: reading one disk page costs about the same whether it holds 2 keys or 200, because the expensive part is the seek, not reading the extra bytes once you're there. A wide node wastes almost nothing and saves a whole level.

python

A filesystem's directory index

A filesystem (NTFS, most of ext4's modern layout, APFS) stores a directory's entries — thousands of files, sorted by name — in a B-tree on disk too, for the same reason a database does: listing or looking up a file by name should cost a handful of reads, not one per comparison. The mistake people actually make reasoning about this: assuming a wider node is "more work" because it holds more data, instead of noticing that the read itself — not the comparisons inside it — is the expensive step.

What does this print?
def seeks_for(n, max_keys):
    import math
    branching = max_keys + 1
    depth, capacity = 0, 1
    while capacity < n + 1:
        capacity *= branching
        depth += 1
    return depth

# A directory with 50,000 files, compared at two page sizes
print(seeks_for(50_000, 4), seeks_for(50_000, 200))

The 2-3 tree: the smallest useful B-tree

In Knuth's standard terminology, a B-tree's order counts the maximum number of children a node can have, not keys. A 2-3 tree — every node holds 1 or 2 keys and has 2 or 3 children — is therefore a B-tree of order 3, not "order 2". To dodge that off-by-one trap in every example, this lesson's code parameterises by max keys per node instead (a 2-3 tree is max_keys = 2; a page holding up to 100 keys is max_keys = 100, order 101 in Knuth's sense). A 1-key node looks exactly like a binary BST node. A 2-key node is the new shape — [a | b] with three children: everything less than a, everything between a and b, and everything greater than b. That third child is the whole idea of a B-tree in miniature: one more key buys one more branch.

step through it
1# A 2-3 tree node search: which of the (up to) 3 children holds `target`?
2def child_for(node_keys, target):
3 for i, key in enumerate(node_keys):
4 if target < key:
5 return i # left of this key
6 return len(node_keys) # right of every key in this node
7 
8print(child_for([10], 7)) # a 1-key node: child 0 (left) or 1 (right)
9print(child_for([10, 25], 7)) # a 2-key node: child 0, 1, or 2
10print(child_for([10, 25], 18))
11print(child_for([10, 25], 40))

Max-keys-per-node doesn't just mean "store more per node" — it's the whole reason a B-tree stays shallow. Going from max_keys=2 (2-3 trees, order 3) to max_keys=100 (a real disk page, order 101) doesn't make each node 50x more expensive to read (it's still one disk seek); it makes the tree 50x wider at every level, which is where the seek count actually comes from.

What does this print?
import math

def btree_depth(n, max_keys):
    if n <= 0:
        return 0
    branching = max_keys + 1
    depth, capacity = 0, 1
    while capacity < n + 1:
        capacity *= branching
        depth += 1
    return depth

print(btree_depth(1_000_000, 2), btree_depth(1_000_000, 100))

Quick practice

What does this print?
def child_for(node_keys, target):
    for i, key in enumerate(node_keys):
        if target < key:
            return i
    return len(node_keys)

print(child_for([5, 15, 25], 30))
What does this print?
import math
print(math.ceil(math.log2(1_000_001)) == 20)

What it costs

structure depth for 10⁶ keys disk reads per lookup
balanced binary BST ~20 ~20
2-3 tree (max_keys=2, Knuth order 3) ~13 ~13 — better, but each node is still a tiny fraction of a disk page
B-tree, 100 keys/node (Knuth order 101) 3 3 — one node fills a whole disk page
plain sorted array (binary search) ~20 comparisons up to ~20 reads, unless the whole array is cached

🎯 Your turn

Build tree_depth_comparison(n, order): report the depth of a B-tree of the given order (max keys per node) over n keys, alongside a binary BST's worst case (n, fully degenerate) and best case (balanced). Return {"btree_depth": ..., "bst_worst_depth": ..., "bst_balanced_depth": ...}.

  • tree_depth_comparison(1000000, 100) → {"btree_depth": 3, "bst_worst_depth": 1000000, "bst_balanced_depth": 20}
  • tree_depth_comparison(7, 2) → {"btree_depth": 2, "bst_worst_depth": 7, "bst_balanced_depth": 3}

Hint: btree_depth grows the tree's capacity by a factor of order + 1 per level until it reaches n + 1; bst_balanced_depth is ceil(log2(n + 1)); bst_worst_depth is just n.

Press ▶ Run, then ✓ Check.

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA balanced binary index

Write bst_balanced_depth(n): the depth of a perfectly balanced binary search tree holding n keys.

complexity
DrillA B-tree's depth

Write btree_depth(n, order): the depth of a B-tree with the given order (max keys per node, so order + 1 max children) over n keys.

balanced-trees
BuildHow many keys fit in d levels?

Write max_keys_at_depth(order, depth): the most keys a B-tree of the given order could hold in exactly depth levels (a full tree).

balanced-trees
BossHow wide do nodes need to be?

Write min_order_for_depth(n, max_depth): the smallest order such that a B-tree over n keys fits within max_depth levels.

balanced-treescomplexity
CapstoneTurn depth into milliseconds

Write compare_read_cost(n, order, ms_per_read): given the cost of one disk read, return {"btree_ms": ..., "bst_ms": ..., "speedup": ...} comparing a B-tree lookup against a balanced binary BST lookup over n keys. speedup is bst_ms / btree_ms, rounded to 2 decimals.

balanced-treescomplexity
tree_depth_comparison.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
tree_depth_comparison(n, order) → dictA B-tree's depth next to a binary BST's worst and balanced depths.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.