Why B/B+ Tree External-Memory Search Matters
A dictionary with millions of entries should not require a separate storage read for every binary-tree comparison. B-trees and B+ trees place many ordered keys and child pointers in one page. One page read can therefore choose among many subtrees, keeping the number of storage levels small.
The key distinction is where records live. In a classical B-tree, an internal key can own a record, so equality may finish the search before a leaf. In a B+ tree, internal keys are routing separators; actual records live in leaves. Linked B+ leaves then support ordered range scans after one initial descent.
This lesson implements point search for both structures and an inclusive B+ range query. Static builders create valid balanced trees from sorted unique keys, using an explicit logical byte layout. An LRU buffer-pool simulator distinguishes page visits, cache hits, and page reads, exposing why a short warm path can cost no transfers while a repeated longer path can still miss.
The code is a read-only search laboratory, not an insertion/deletion engine. It includes misses, separator equality, root/leaf cases, range boundaries, cache eviction, and identical Python, C++, and Java output. The prior tree and sorting lessons provide ordering and balancing basics; here the focus is routing correctness and external-memory cost.
B/B+ trees reduce storage reads by keeping many ordered keys in each page and following one child per level. A B-tree may return an internal record; a B+ tree routes through internal separators to a leaf and can scan linked leaves for a range. The buffer pool turns a logical page visit into a transfer only when that page is absent from RAM.
Core Concepts and Terminology
A page is the unit transferred between simulated storage and the buffer pool. Let $P$ be bytes per page and $C$ the number of LRU cache frames, each holding one page. The modeled resident-page capacity is $CP$ bytes per index. Page IDs identify nodes; the root ID is known metadata, so no extra header-page read is charged to discover it.
For a concrete logical layout, use a $16$-byte node header, $8$-byte keys, $8$-byte values, and $8$-byte child/leaf pointers. Values are stored as $10k$ for key $k$ so lookup results are easy to verify. These are conceptual encoded widths, not Python object sizes or native C++/Java array layouts.
B-tree convention. A node with $m$ keys has $m$ corresponding values and, if internal, $m+1$ children. An internal page uses $$ 16+16m+8(m+1)=24+24m $$ bytes. Choose minimum degree $t=\lfloor P/48\rfloor$, so at most $2t-1$ keys and $2t$ children fit. Nonroot nodes contain at least $t-1$ keys; the root has a special lower bound. All leaves have equal depth. A common degree is used for both leaf and internal nodes, even though a B-tree leaf has no child pointers and could physically hold more records under a different design.
B+ convention. Internal nodes contain separators and children, with no values. A page with $f$ children uses $16+8(f-1)+8f=8+16f$ bytes. A linked leaf with $\ell$ key/value records uses $24+16\ell$ bytes. Therefore $$ F=\left\lfloor\frac{P-8}{16}\right\rfloor, \qquad L=\left\lfloor\frac{P-24}{16}\right\rfloor $$ are maximum internal fanout and leaf capacity. Nonroot internal nodes have at least $\lceil F/2\rceil$ children, and nonroot leaves have at least $\lceil L/2\rceil$ records. The bulk builder balances the last groups to respect those minima. A leaf root may contain fewer records; an empty index has no root page.
Every B+ separator is the minimum key of its right child subtree. With separators $s_0,\ldots,s_{m-1}$, child intervals are $$ (-\infty,s_0),\ [s_0,s_1),\ldots,\ [s_{m-1},+\infty). $$ Equality must go right: choose the number of separators at most the query key, an upper-bound search. At the final leaf, a lower-bound search finds the first key at least the query. In a B-tree, a lower bound instead identifies either an internal record equal to the query or the child interval containing it.
Cache accounting. Every fetch is one logical visit. A resident page gives a hit and moves to the most-recent end of the LRU order. A missing page gives one read, evicting the least-recent page if all $C$ frames are occupied. No pages are pinned and no updates occur. Search writes are zero. Static layout construction is an uncounted preload, representing an index already on storage; index-building I/O is not measured.
With $P=96$, the B-tree has $t=2$, at most three keys/four children per node. The B+ tree has $F=5$ and $L=4$. With $C=2$, each index models $192$ resident bytes. The sample uses two independent pools, one per tree, and sums their reads; it is not one shared two-frame pool.
| Term | Meaning in this lesson | Example or role |
|---|---|---|
| Page / block | One logical $P$-byte node transfer | $P=96$ bytes for hand traces |
| Frame | Space for one resident page | $C=2$ gives $192$ bytes per pool |
| B-tree degree $t$ | Minimum nonroot child count | $t=2$; at most $3$ keys |
| B+ fanout $F$ | Maximum internal children | $F=5$ under the stated byte layout |
| Leaf capacity $L$ | Maximum B+ key/value records | $L=4$; leaf link costs $8$ bytes |
| Separator | Minimum of the B+ right subtree | Internal $27$ routes equality to its right |
| Lower bound | First key at least the target | Leaf $[27,30,33]$, target $27$ → slot $0$ |
| Upper bound | First separator greater than target | $[15,27,36,45]$, target $27$ → child $2$ |
| Visit versus read | Request versus cache miss | Two visits can cause zero warm reads |
| LRU order | Least-recent page to most-recent page | $[5,2]$: evict page $5$ first |
| Leaf chain | Next-leaf IDs in key order | B+ pages $0\to1\to2\to3\to4$ |
| Returned value | Stored payload, not just membership | Key $33$ returns value $330$ |
How B/B+ Tree External-Memory Search Works, Step by Step
1. Prepare a valid static index. Supply strictly increasing unique keys. The teaching API accepts at most $4096$ keys between $-10^6$ and $10^6$, page sizes from $96$ to $1024$ bytes in multiples of eight, and $1$ through $16$ frames. Its builders respect page capacity, minimum occupancy, child ordering, equal leaf depth, and the B+ separator/leaf-link rules. These bounds simplify experiments; they are not restrictions of the general data structures.
2. Start at the root ID. An empty index returns no records without visiting a page. Otherwise request the root through the pool. Count a hit or a one-page read, update LRU order, and record the visit in the query path.
3. Apply the correct routing rule. In a B-tree, compute the lower-bound slot. If that key equals the target, return its stored value immediately, even in an internal node. If no equal key exists and the node is internal, follow the child at that slot. At a leaf, absence means the key is not present.
In a B+ internal node, compute the upper-bound child index and descend. Do not return merely because the query equals a separator: this page has no associated record value. The separator only identifies the correct interval. The diagram below shows equality at separator twenty-seven.
4. Confirm at the B+ leaf. Compute a lower bound in the leaf keys. Return the stored value only when that slot is within the page and equals the target. Otherwise report absence. Every nonempty B+ point search in this implementation reaches a leaf, for both hits and misses.
5. Extend to an inclusive range. For $[a,b]$, first descend using $a$ under the same B+ routing rule. Begin at the first leaf key at least $a$ and emit key/value records through $b$. Follow next-leaf links when the current leaf is exhausted. Stop immediately after emitting key $b$, on seeing a key greater than $b$, or when there is no next leaf. The initial root descent is performed once, not once per record.
6. Keep the pool across queries. Each query returns its own read/hit deltas and complete visit path. Cache contents persist between queries. Calling pool.clear() removes residents and resets counters to make a cold experiment. LRU may evict the root during a long range scan; the root is not implicitly pinned or free.
Only the current page needs inspection during routing; remember a child/page ID before requesting the next node. The simulator returns a view into immutable host-backed page data, with logical residency tracked by the pool. Actual page bytes, pinning, and concurrency would need a real buffer manager.
flowchart TD
A["Validate point key or B+ range"] --> B{"Empty index?"}
B -->|Yes| Z["Return empty result with zero visits"]
B -->|No| C["Fetch root: hit or one page read"]
C --> D{"Internal page?"}
D -->|Yes| E{"B+ tree?"}
E -->|Yes| F["Upper bound chooses child; equality goes right"]
E -->|No| G{"Lower-bound key equals target?"}
G -->|Yes| Y["Return internal B-tree record"]
G -->|No| H["Choose lower-bound child"]
F --> I["Fetch selected child through pool"]
H --> I
I --> D
D -->|No| J{"Point query?"}
J -->|Yes| K["Lower bound confirms leaf hit or absence"]
J -->|No| L["Emit leaf records within inclusive range"]
L --> M{"Need another leaf?"}
M -->|Yes| N["Fetch next-leaf page through pool"]
N --> L
M -->|No| O["Return records, visits, reads, and hits"]
K --> O
Y --> O
Worked Example with a Full Trace
Use keys $[3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51]$, with stored value $10k$ at key $k$. Set $P=96$ and $C=2$ for each independent index pool. Both pools begin empty.
Complete B-tree layout. The bulk builder produces nine pages and three levels. Each internal key below owns its stored value.
- Page 0: leaf keys $[3,6]$.
- Page 1: leaf keys $[12,15]$.
- Page 2: leaf keys $[21,24]$.
- Page 3: internal keys $[9,18]$, children $[0,1,2]$.
- Page 4: leaf keys $[30,33]$.
- Page 5: leaf keys $[39,42]$.
- Page 6: leaf keys $[48,51]$.
- Page 7: internal keys $[36,45]$, children $[4,5,6]$.
- Page 8: root key $[27]$, children $[3,7]$.
Complete B+ layout. There are six pages and two levels. Only leaves store values; the root's keys are routing copies.
- Page 0: leaf $[3,6,9,12]$, next $1$.
- Page 1: leaf $[15,18,21,24]$, next $2$.
- Page 2: leaf $[27,30,33]$, next $3$.
- Page 3: leaf $[36,39,42]$, next $4$.
- Page 4: leaf $[45,48,51]$, next $-1$ (end).
- Page 5: root separators $[15,27,36,45]$, children $[0,1,2,3,4]$.
A full B+ root uses $16+4\cdot8+5\cdot8=88$ bytes; a four-record linked leaf uses $24+4\cdot16=88$ bytes. Both fit the $96$-byte page. The B-tree's two-key internal pages use $16+2\cdot16+3\cdot8=72$ bytes. Page IDs are local to their own index: B-tree page 5 is unrelated to B+ page 5.
Query 1: B-tree get(27). Fetch root page 8: miss, cache $[8]$. Its lower-bound slot is zero and the key matches. Return stored $270$ without descending. Path $[8]$; reads $1$, hits $0$.
Query 2: B-tree get(33). Fetch page 8: hit, still $[8]$. Lower-bound search compares root key $27\lt33$, returns slot one, and selects page 7. Fetch 7: miss, cache $[8,7]$. In its keys $[36,45]$, binary search checks middle key $45$, then $36$, giving slot zero. Follow page 4. Fetch 4: miss, evict 8, cache $[7,4]$. Its keys are $[30,33]$; lower-bound search checks $33$, then $30$, giving slot one. Return $330$. Path $[8,7,4]$; reads $2$, hits $1$.
Query 3: repeat B-tree get(33). Start at $[7,4]$. Fetch 8: miss, evict 7 → $[4,8]$. Fetch 7: miss, evict 4 → $[8,7]$. Fetch 4: miss, evict 8 → $[7,4]$. The same record $330$ is returned, but every visit misses: reads $3$, hits $0$. Repetition alone does not make a path fit in cache.
Query 4: B+ get(27). Its separate pool is cold. Fetch root page 5: miss → $[5]$. Upper-bound binary search in $[15,27,36,45]$ checks $36\gt27$ and then $27\le27$, selecting child two. Fetch leaf 2: miss → $[5,2]$. Lower bound in $[27,30,33]$ checks $30$ and $27$, returning slot zero; return stored $270$. Path $[5,2]$; reads $2$, hits $0$. The root's separator match did not itself return a record.
Query 5: B+ get(33). Fetch 5: hit → $[2,5]$. Upper bound again selects child two. Fetch 2: hit → $[5,2]$. Leaf lower bound is slot two; return $330$. Reads $0$, hits $2$.
Query 6: B+ get(34). The same pages hit in the same order and leave cache $[5,2]$. Root routing still chooses child two; leaf lower bound is slot three, past its end. Return absence, with reads $0$ and hits $2$. A failed lookup can also be entirely cached.
Query 7: B+ range(21,45), inclusive. Start at cache $[5,2]$.
- Fetch root 5: hit → $[2,5]$. Upper bound at $21$ selects child one.
- Fetch leaf 1: miss, evict 2 → $[5,1]$. Lower bound at $21$ is slot two. Emit $21,24$, with their stored values.
- Follow next to leaf 2: miss, evict 5 → $[1,2]$. Emit $27,30,33$.
- Follow next to leaf 3: miss, evict 1 → $[2,3]$. Emit $36,39,42$.
- Follow next to leaf 4: miss, evict 2 → $[3,4]$. Emit $45$ and stop at the inclusive high endpoint; do not emit $48$ or $51$.
The range returns nine records with keys $[21,24,27,30,33,36,39,42,45]$, path $[5,1,2,3,4]$, reads $4$, and hits $1$. It descends once and then uses the leaf chain. The root has been evicted by the end.
Totals. B-tree queries use six reads and one hit; B+ queries use six reads and five hits. Across both independent pools, eighteen logical visits equal twelve page reads plus six hits. Query transfers are twelve pages, or $12\cdot96=1152$ logical bytes, with zero writes. Preloading the trees is outside these totals.
Trace It by Hand
At B+ root page 5, predict the child selected for search key $27$. Separators are $[15,27,36,45]$, and each is the minimum of the subtree on its right.
Reveal: choose child two, since two separators are at most $27$. Leaf two contains $[27,30,33]$ and returns $270$. A lower-bound child index of one would inspect $[15,18,21,24]$ and incorrectly report absence.
Now increase the B-tree pool to three frames, clear it, and repeat get(33) twice. The first lookup reads pages $8,7,4$; all three then fit, so the second lookup has zero reads and three hits. With the B+ pool reduced to one frame, repeating path $[5,2]$ misses twice each time because each fetch evicts the other page.
Finally, search an empty index. Its root is $-1$, so point search returns no records, makes no page visits, and causes no transfers. A B+ range on an empty index behaves likewise after validating its bounds.
Implementation in Python, C++, and Java
Each panel is standalone. Save Python as main.py and run python3 main.py. Save C++ as main.cpp and compile with g++ -std=c++17 -O2 main.cpp -o tree-search. Save Java as Main.java and run javac Main.java followed by java Main. Python 3.7 or newer supplies dataclasses; the C++ panel targets C++17; Java uses ordinary classes.
Construct Index(keys, page_bytes, frames, plus) in Python/C++, or new Index(keys, pageBytes, frames, plus) in Java. The boolean plus selects B+ when true and B-tree when false. Supply a Python list, C++ vector<int>, or Java int[]; null/nonsequence inputs are outside the contract. Keys must be strictly increasing and unique, and the builders do not mutate them. Each key stores value $10k$; this is an illustrative payload chosen during construction, not a value recomputed by search.
Both modes expose point(key). B+ mode also exposes range_query(lo, hi) in Python/C++ and rangeQuery(lo, hi) in Java. The range is inclusive. Each query returns a list of stored key/value records, a path of every page visit, and read/hit deltas. Absence is an empty list, so zero and negative payloads remain ordinary valid data. pool.clear() empties that index's cache and resets its counters without changing the tree.
Invalid page size/frame parameters raise PARAMETER; more than $4096$ keys raises SIZE; a key outside the declared range raises VALUE; duplicates or descending adjacent keys raise ORDER. Parameters, size, all values, then order are checked in that sequence. Point queries validate their key before any fetch. A range on B-tree mode raises MODE; B+ range endpoints are validated before RANGE rejects reversed bounds. Invalid queries do not change cache state. Python additionally checks exact integer types and an exact boolean mode, rejecting booleans/floats used as keys or integer parameters.
Static layout preparation. The B-tree builder chooses the smallest height whose maximum capacity can hold all keys, then allocates feasible child subtree sizes and consumes the sorted keys in order. The B+ builder balances records across leaves, links those leaves, and builds balanced internal layers with right-subtree minimum separators. These are fixture/bulk-layout builders for read-only search; there is no insertion, split, deletion, or crash-recovery implementation.
Page and cache model. Disk.add checks the conceptual encoded byte size of every generated node. Construction is uncounted preload. Pool.fetch is the only query page-access path; LRU residency metadata determines whether that fetch counts a hit or one $P$-byte read. Python uses OrderedDict, C++ uses a list plus hash map, and Java uses access-ordered LinkedHashMap. No root is pinned and no background writes occur.
Simulated disk nodes are stored in host RAM. The cache tracks logical resident page IDs rather than allocating and serializing actual page bytes; record/path result arrays and builder metadata are also outside the $CP$ cache budget. The program is a page-transfer simulator, not a real memory-capped storage engine. Actual file reads, byte serialization, page guards, and pin/unpin lifetimes would replace these abstract accesses. Inline stored values require no extra table-page read in this model; an index storing record IDs could require additional row fetches.
Expected standard output, identical in all three languages:
page_bytes=96 cache_frames=2B t=2 pages=9 root=8 levels=3B+ leaf_capacity=4 fanout=5 pages=6 root=5 levels=2B get(27) records=[27:270] path=[8] reads=1 hits=0B get(33) records=[33:330] path=[8,7,4] reads=2 hits=1B get(33) records=[33:330] path=[8,7,4] reads=3 hits=0B+ get(27) records=[27:270] path=[5,2] reads=2 hits=0B+ get(33) records=[33:330] path=[5,2] reads=0 hits=2B+ get(34) records=[] path=[5,2] reads=0 hits=2B+ range(21,45) records=[21:210,24:240,27:270,30:300,33:330,36:360,39:390,42:420,45:450] path=[5,1,2,3,4] reads=4 hits=1totals reads=12 hits=6 transfers=12 writes=0
Complexity, Correctness, and Invariants
Height and point-search transfers. Let $H$ be the number of page levels, with an empty index having $H=0$. In a B-tree of minimum degree $t\ge2$, a nonempty height-$H$ tree has at least $2t^{H-1}-1$ records, with the usual root exception already included. Consequently $$ H\le1+\log_t\left(\frac{N+1}{2}\right). $$ For a leaf root this gives $H=1$ when $N=1$; larger root-only trees also respect the inequality. Uniform leaf depth and minimum occupancy keep worst-case point descent logarithmic in page fanout. Open Data Structures' B-tree chapter develops this external-memory view.
B+ height is similarly logarithmic because nonroot internal fanout is at least $\lceil F/2\rceil$ and leaf occupancy at least $\lceil L/2\rceil$. For $H\ge2$, the tree has at least $2\lceil F/2\rceil^{H-2}$ leaves and at least $$ 2\left\lceil\frac{L}{2}\right\rceil \left\lceil\frac{F}{2}\right\rceil^{H-2} $$ records. Treat a leaf root and empty index separately. With fixed-size keys and constant occupancy, this gives the usual $O(1+\log_F N)$ page-level search scale.
A cold B-tree point hit can take just one read at the root, or up to $H$ reads at a leaf; a miss descends to a leaf. Every nonempty cold B+ point query here makes exactly $H$ visits and reads, whether the key exists or not. With caching, actual reads can be any count from zero through the number of visited pages. Best/average/worst physical I/O therefore depends on query targets and cache history; there is no universal average without a workload model. The root is not automatically free.
CPU work. Binary search within pages costs $O(\log(2t))$ comparisons per B-tree node and $O(\log F)$ per B+ internal node, with $O(\log(L+1))$ at a B+ leaf. Thus B-tree point CPU cost is $O(H\log(2t))$, and B+ point cost is $O(H\log(F+1)+\log(L+1))$ as a convenient upper bound. LRU hash operations are expected constant time per visit, not a worst-case guarantee for adversarial hash behavior. Cache hits still require routing/comparison work. Worst-case point cost is logarithmic in $N$ comparisons under these capacities; an early B-tree hit can finish sooner.
B+ range queries. Let $Z$ be the number of returned records and $V$ the number of leaf pages visited, including boundary leaves with no qualifying record if needed. A nonempty range visit path has $H-1$ internal visits plus $V$ leaf visits. Minimum leaf occupancy and the two boundary effects imply $$ O\left(H+\frac{Z}{L}+1\right) $$ page visits and at most that many cold reads. The sample has $H=2$, $V=4$, and five visits. Warm reads depend on LRU state; linked leaves are not assumed physically consecutive, so sequential leaf order does not guarantee adjacent disk blocks or low device latency.
The simple range loop performs a lower-bound search in each visited leaf, so its CPU upper bound is $O(H\log(F+1)+V\log(L+1)+Z)$. Starting later leaves at slot zero could remove their repeated lower-bound work. Returned results use $O(Z)$ host space here; a streaming consumer could process them incrementally.
Memory and accounting. The modeled cache budget is $CP$ bytes per index, plus LRU metadata for at most $C$ page IDs. The read-only routing state is constant-sized, apart from the returned visit log and results. A point path log uses $O(H)$ IDs; a range log uses $O(H+V)$ IDs. All simulated disk pages and builder input are retained in host RAM outside the cache model. A cache miss transfers one page; a hit transfers none; no query writes or dirty-page evictions occur. Building and serializing an actual index would incur separate costs not measured here.
B-tree correctness invariant. At every descent, if the key exists and was not matched in the current node, it is contained in the chosen child interval. The ordered internal records split children into strict intervals because keys are unique. Lower bound either finds the stored record or selects exactly the interval that can contain it. A missing leaf match proves absence.
B+ correctness invariant. An internal separator equals the minimum of its right subtree. Upper bound selects the unique half-open child interval containing the target, including equality on the right. Each descent preserves membership in the possible subtree. Leaf lower bound then confirms a record or proves absence. Returning a separator's position alone would not prove the existence of a record value on that internal page.
Range correctness. The initial descent and lower bound find the earliest possible qualifying position. Within-leaf keys and next-leaf links increase globally, so scanning emits each matching key once in sorted order. Encountering a key greater than the high endpoint rules out every later key; equality permits immediate stopping under the unique-key contract. A next-leaf ID of $-1$ proves the right edge of the index was reached.
Why the static builders are valid. A B-tree subtree of edge-height $h$ has nonroot capacity between $t^{h+1}-1$ and $(2t)^{h+1}-1$ records. For an internal node, the builder chooses a legal child count $c$ such that its $c-1$ records plus $c$ feasible child subtrees can hold the requested total; balanced child sizes stay within those bounds. In-order consumption preserves intervals and every record exactly once. B+ balanced groups satisfy half occupancy, equal-height layer construction preserves depth, and right-child minima define the separators. The leaf chain follows the original sorted record order.
The cache does not change routing correctness: every fetch returns the same immutable page contents whether it hits or misses. It only changes the counted storage work.
| Structure or access path | Point-search storage view | Range behavior | Key assumptions |
|---|---|---|---|
| B-tree | At most $H$ cold reads; internal equality may stop early | Ordered traversal must include internal records | Balanced multiway nodes; internal values exist |
| B+ tree | $H$ cold reads to a leaf in this implementation | $O(H+Z/L+1)$ page visits via leaf links | Data in leaves; consistent separator convention |
| Page-oblivious binary search tree | Up to tree height in page reads without clustering | Depends on traversal and layout | Balance gives $O(\log N)$ nodes, not high page fanout |
| Sorted file / array | Binary search has $O(\log N)$ page-read upper bound | After locating start, scan packed records | Static sorted layout; middle insertions are costly |
| Hash index | Expected few page probes under a suitable load/layout | Does not inherently provide ordered range scans | Hash distribution, overflow, and collisions matter |
| Cached B/B+ path | Zero reads if every needed page remains resident | Scan misses depend on cache size and history | Visits still occur; LRU may evict the root |
| Index followed by row fetch | Index descent plus possible table-page reads | Returned record IDs may cause many extra reads | Payload is not inline or covering |
The chosen child interval must contain every possible matching key: B-tree lower bound can first match an internal record, while this B+ minimum-of-right-subtree convention requires upper bound and equality to the right. A leaf verifies B+ membership, and ordered leaf links preserve range output. Cache hits and misses change transfer counts, not the immutable page contents or search invariant.
Variants, Pitfalls, and Optimizations
Match equality routing to separator meaning. This B+ model uses the minimum of the right subtree, so equality goes right. Other valid layouts may use different fences or truncated separators and require different comparisons. Memorizing “use lower bound” or “use upper bound” without the interval invariant produces subtle false negatives. The diagram below shows the failure for key twenty-seven.
Separate routing keys from stored records. A B-tree internal key owns a value here and can answer a point query. A B+ internal separator has no payload and is only a guide to a leaf. Likewise, scanning only B-tree leaves would omit internal records; the linked-leaf range implementation is deliberately B+ only.
Treat warm cache claims carefully. A repeated three-page path can miss every time in a two-frame LRU pool. Roots and upper levels are often valuable cache residents, but this code does not pin them, and a range scan can evict them. Production policies may reserve/pin upper pages, isolate scan traffic, prefetch, or use more sophisticated replacement. Each changes residency assumptions and must be reflected in measurements.
Page size means bytes, not an arbitrary key count. Larger values, longer keys, slot arrays, alignment, free space, compression metadata, and sibling links affect capacity. This model fixes widths for clarity. Real variable-length pages require space accounting rather than just testing a maximum number of keys. The SQLite file-format specification illustrates concrete headers, cell layouts, and different B-tree page types; its actual format is not the simplified layout implemented here.
Unique keys and composite keys. The code rejects duplicate input keys. To index repeated user keys, use a total composite key such as $(\text{user key},\text{record ID})$, or a correctly designed duplicate list/posting representation. Define how separators and lower/upper bounds handle that representation. Composite keys also occupy more bytes and can reduce fanout. Range queries over a prefix need bounds for the full comparison order.
Leaf links and range endpoints. A correct point-search tree can still have a broken leaf chain. A missing link loses range results, an out-of-order link breaks sorting, and a cycle prevents termination. Validate link coverage separately. Keep endpoint inclusivity explicit; this API includes both bounds and rejects reversed bounds before touching the cache.
Payload and table access. Values are inline in the model, so finding a leaf record completes the query. A real secondary index may return row identifiers requiring additional heap/table reads. Covering indexes can avoid some of them. Do not interpret the index's page count as the total query I/O without checking where requested columns live.
Static search versus updates. Insertion/deletion requires splitting, redistribution/merging, separator maintenance, root changes, and valid leaf links. Persistence adds serialization, write-ahead/recovery or other durability mechanisms, and safe publication of changed pages. Concurrent readers/writers need latches, page guards, or a suitable versioning protocol. CMU's B+ tree project connects leaf/internal page access, buffer-pool fetching, ordered iteration, and update/concurrency responsibilities. The read-only sample intentionally isolates search.
Alternative layouts and refinements. Prefix/fence-key compression can raise internal fanout; bulk loading can improve occupancy and leaf layout; interpolation or SIMD-assisted page search can change CPU costs. B-link sibling/fence mechanisms address concurrency in specific designs. Cache-oblivious layouts, buffered tree variants, and LSM-based indexes explore different locality/write tradeoffs. Their guarantees and page formats must be analyzed on their own assumptions rather than borrowed from this static model.
Do not treat a B+ separator match as a payload hit, and route equality according to the stated child intervals. Count reads only on cache misses, without assuming the root stays resident. Preserve occupancy, byte capacity, and the entire leaf chain before trusting logarithmic descent or complete range output.
Practice Problems and Real-World Use
Verify structure before performance: keys are strictly ordered, every B-tree record appears exactly once across all nodes, every B+ record appears once across leaves, children satisfy their key intervals, and all leaves have the same depth. Check nonroot minimum occupancy, root exceptions, every page's encoded size, separator equality to right-subtree minima, and the complete acyclic next-leaf chain.
Use an independent sorted-map or array oracle for point membership and inclusive range results. For each returned visit path, replay LRU with a simple list and verify per-query reads/hits, final residency order, and cumulative totals. A repeated query can produce the same records and path while its transfer count changes. Test cold, warm, and intentionally undersized pools.
All three programs agree on $7042$ construction-and-query workloads: $7014$ valid layouts and $28$ rejected constructions, covering $71900$ query/reset operations. The checks compare complete page layouts, returned records, visit paths, LRU order, and every read/hit counter. A separate flat key/value oracle passed $7760$ point/range comparisons. Structural validation checks degree/half occupancy, equal depths, record coverage, byte limits, separators, and leaf links. Inputs include empty and single-page indexes, exhaustive small key subsets, many page-capacity boundaries, randomized sorted keys, the maximum $4096$ records, page sizes up to $1024$, key/range misses, duplicate/unsorted keys, invalid limits, and cache capacities that trigger eviction. Python also passed $28$ exact-type rejection checks. C++ ran with address and undefined-behavior sanitizers, and sample stdout matches byte for byte across languages.
For a file-backed extension, serialize the stated page format, route all reads through a bounded buffer manager, and stream range results to an independent verifier. Measure logical visits, page-cache misses, bytes, and elapsed time separately. Add table-record fetches only when the payload model requires them, and measure index construction separately from search.
| Exercise or system | What to implement or verify | Difficulty |
|---|---|---|
| Equality-routing laboratory | Contrast B-tree internal hit with B+ right-child routing at $27$ | Beginner |
| Cold versus warm path | Reproduce two-frame thrashing and a fully cached B+ path | Beginner |
| Inclusive linked-leaf range | Trace $[21,45]$ with one descent and exact cache states | Intermediate |
| Structural checker | Validate occupancy, depth, intervals, byte sizes, and leaf links | Intermediate |
| Composite-key index | Preserve duplicate user keys through a unique total order and correct range bounds | Advanced |
| File-backed search engine | Serialize pages and enforce an actual bounded buffer pool | Advanced |
| Secondary versus covering index | Include or eliminate extra table-page reads for requested columns | Advanced |
| Concurrent/durable update extension | Maintain separators, sibling links, page guards, and recovery across changes | Advanced |