Why External Merge Sort Matters
Suppose a database must sort a log larger than its RAM allowance. Loading the entire log into an array fails, while repeatedly fetching individual records from storage wastes transfers. External merge sort turns the job into bounded-memory sorting followed by sequential merging: sort manageable chunks into temporary runs, then repeatedly merge several runs until one sorted run remains.
The expensive unit is a storage block, not an individual comparison. One page read brings several records into a memory buffer; many comparisons can then reuse them. A well-designed external sort does more sequential work per transfer and avoids needing all records in its sorting buffers at once.
This lesson develops the block model, run generation, buffered multiway merging, stability, exact I/O accounting, and a correctness argument. The complete Python, C++, and Java programs simulate disk pages and record buffers explicitly. They expose every block read and write, handle duplicate keys and partial pages, and print identical results.
The prerequisites are ordinary comparison sorting and the page-oriented view of B-trees/B+ trees. A B+ tree maintains order across updates; an external sort organizes a whole batch, often before bulk loading an index or executing a query. The simulator uses host RAM to represent its disk, so it teaches the transfer algorithm rather than acting as a production file sorter.
External merge sort sorts data beyond a record-buffer budget by writing sorted chunks and merging them through bounded input and output buffers. Its central resource is block transfers: fetch a page, reuse its records, and write output in pages while preserving every record.
Core Concepts and Terminology
Let $N$ be the number of fixed-size records, $B$ the number of records per storage page, and $C$ the number of available record-buffer frames. The modeled data-buffer capacity is $M=BC$ records. The input already occupies $D=\lceil N/B\rceil$ disk pages; an empty input occupies zero pages. A partly filled page still costs one transfer.
The input is a sequence of integer keys. Each becomes a record $(k,i)$, where $i$ is its original zero-based position. All sorting compares the pair lexicographically: smaller key first, then smaller original position. The final keys are nondecreasing, every record appears once, and equal keys keep their original order. This supplies stability, even though the chunk sorter itself is heapsort.
A run is one sorted sequence, represented by its ordered page IDs and its record count. Its pages need not have adjacent physical IDs in this simulator. Run boundaries matter: two individually sorted runs cannot be concatenated into one sorted run without checking their key ranges or merging them.
A merge group of $g$ runs reserves one $B$-record input frame per run and one $B$-record output frame. Therefore $$ (g+1)B\le M,\qquad 2\le g\le f\le C-1, $$ where $f$ is the configured maximum fan-in. The heap stores run indices, whose comparisons inspect current buffered heads. Heap entries, page IDs, run descriptors, runtime objects, and scalar temporaries are metadata outside this record-slot budget. A real byte budget must also pay for them and for record representation.
Accounting convention. Each actual call to the simulated disk's read or write method counts one transfer. Creating the already-resident source image, allocating/freeing pages, manipulating metadata, and comparing buffered keys do not count. A page is read again if it is needed in another pass; the model has no cross-pass cache. The sort materializes its final disk run, then performs a separately labeled, counted readback for display. Returned observer records are outside the sorting-buffer budget.
For $N=17$, $B=2$, $C=3$, and $f=2$, three frames hold six records. Two input frames plus one output frame fit exactly. Increasing fan-in to three would require four frames and is rejected, even though a heap of three indices itself is tiny.
| Term | Exact meaning here | Example or purpose |
|---|---|---|
| Record | Key plus original position $(k,i)$ | 5@2 precedes 5@4 |
| Page/block | Up to $B$ valid records on disk | $B=2$; final page may hold one |
| Frame | Space for one page of records in RAM | A refill costs one read |
| Capacity | $M=BC$ record slots, excluding metadata | $C=3$, $B=2$ gives $M=6$ |
| Initial run | Sorted chunk of at most $M$ records | Lengths $6,6,5$ for $N=17$ |
| Fan-in | Maximum input runs merged together | $f=2$ needs three frames |
| Merge pass | Group all current runs into successor runs | $[6,6,5]\to[12,5]$ |
| Carry | Reuse one unpaired run's page list | Length $5$ costs no merge I/O in pass one |
| Transfer | One page read or page write | Readback of $17$ records costs $9$ reads |
| Stable result | Equal keys retain original order | 0@7 precedes 0@16 |
How External Merge Sort Works, Step by Step
1. Validate the model. Require $1\le B\le32$, $3\le C\le32$, and $2\le f\le C-1$. The teaching API accepts at most $4096$ integer keys between $-10^6$ and $10^6$, inclusive. These finite caps keep traces and counters manageable; they are not mathematical restrictions of external sorting. Invalid parameters are rejected even for empty input.
2. Build the uncounted source image. Pack input records in original order into pages of size $B$, with a possibly partial last page. Give every record its original index. This represents a file that was on disk before the timed sort began.
3. Generate initial runs. Read at most $C$ consecutive source pages, giving at most $M$ records. Reserve only the chunk's valid record count, heapsort the chunk in place by $(k,i)$, and write its records in $B$-sized pages. The sorted chunk itself supplies these writes; generation does not need an additional output record frame. Continue until the source ends, then release all source pages. Keeping the source until generation finishes makes the peak disk space easy to audit.
4. Form a merge group. Take up to $f$ consecutive runs. Carry a singleton group forward unchanged. For a real merge, reserve $(g+1)B$ slots, create one reader per input run, and read each first page. Insert each reader's index into the min-heap. Every run is nonempty, so its first head exists.
5. Emit the smallest head. Remove the heap's winning index and append that reader's head to the output frame. Write and clear the frame whenever it reaches $B$ records. Then advance that reader: continue within its current page, or clear/refill its frame from its next page. Reinsert its index only if another valid record exists. No other reader changes, so the other heap comparisons remain valid. Mutating a reader while its index is still in the heap would break this argument.
6. Finish the group and pass. When the heap is empty, flush any nonempty partial output frame. Record the new run's page list and total length. Only after its output exists, erase the input group pages and release its reservation. Repeat for every group. The next pass sees only the newly merged runs and any carried singleton.
7. Stop at one run, then observe it. Repeated passes reduce the run count until it is at most one. Use one input frame to read the final run into the returned demonstration list, counting these reads under readback. Empty input produces no pages, no records, and no transfers; its history still contains runs and readback phases.
The figure isolates one emission that fills an output page and exhausts an input page. These are independent events: an output flush is a write, and an input refill is a read.
flowchart TD
A["Validate parameters and keys"] --> B["Pack the already-resident source image"]
B --> C["Read and sort bounded chunks; write runs"]
C --> D["Release source pages"]
D --> E{"More than one run?"}
E -->|No| O["Read back final run and report counters"]
E -->|Yes| F["Begin next pass"]
F --> G{"Another run group?"}
G -->|No| H["Replace runs with successor runs"]
H --> E
G -->|Yes| I{"Singleton group?"}
I -->|Yes| J["Carry run metadata unchanged"]
J --> G
I -->|No| K["Reserve frames; read heads into buffers"]
K --> L{"Heap empty?"}
L -->|No| M["Emit minimum; flush if full; advance and reinsert"]
M --> L
L -->|Yes| N["Flush partial page; release input pages and frames"]
N --> G
Worked Example with a Full Trace
Use $B=2$, $C=3$, $M=6$, and $f=2$. The keys are $$ [9,1,5,3,5,2,8,0,7,3,6,4,2,9,1,8,0]. $$ Write key@index for a record. The nine source pages, in order, are [9@0,1@1], [5@2,3@3], [5@4,2@5], [8@6,0@7], [7@8,3@9], [6@10,4@11], [2@12,9@13], [1@14,8@15], and [0@16].
Initial run generation. Read three source pages per full chunk. Sorting and writing them gives these exact pages; a semicolon separates pages:
- $R_0$, length $6$:
[1@1,2@5]; [3@3,5@2]; [5@4,9@0]. - $R_1$, length $6$:
[0@7,3@9]; [4@11,6@10]; [7@8,8@6]. - $R_2$, length $5$:
[0@16,1@14]; [2@12,8@15]; [9@13].
The chunks cost $3+3+3=9$ reads and $3+3+3=9$ writes. Run $R_2$ has five valid records spread across three pages. The live disk reaches $9+9=18$ pages before the source is deleted; the largest chunk reserves six record slots.
First merge pass. Merge $R_0$ and $R_1$. The winning record sequence is 0@7,1@1,2@5,3@3,3@9,4@11,5@2,5@4,6@10,7@8,8@6,9@0. Pairing consecutive winners yields six output pages:
[0@7,1@1]; [2@5,3@3]; [3@9,4@11]; [5@2,5@4]; [6@10,7@8]; [8@6,9@0].
This group reads its six input pages once and writes six output pages. Carry $R_2$ unchanged, giving lengths $[12,5]$. That carry incurs zero reads and zero writes. The sorted contents of every run after this pass are now explicitly known.
Second merge pass. Call the twelve-record run L and the carried five-record run R. Name L's pages L0 through L5, and R's pages R0 through R2 in the orders just shown. Initially read L0 and R0: two reads. The following list records every winning head, output-buffer state, flush, and subsequent refill. Each step first emits and possibly flushes, then advances the winning reader.
- L wins
0@7; output[0@7]; no refill. - R wins
0@16; write[0@7,0@16]; output empty; no refill. - L wins
1@1; output[1@1]; read L1, headed by2@5. - R wins
1@14; write[1@1,1@14]; output empty; read R1, headed by2@12. - L wins
2@5; output[2@5]; no refill. - R wins
2@12; write[2@5,2@12]; output empty; no refill. - L wins
3@3; output[3@3]; read L2, headed by3@9. - L wins
3@9; write[3@3,3@9]; output empty; no refill. - L wins
4@11; output[4@11]; read L3, headed by5@2. - L wins
5@2; write[4@11,5@2]; output empty; no refill. - L wins
5@4; output[5@4]; read L4, headed by6@10. - L wins
6@10; write[5@4,6@10]; output empty; no refill. - L wins
7@8; output[7@8]; read L5, headed by8@6. - L wins
8@6; write[7@8,8@6]; output empty; no refill. - R wins
8@15; output[8@15]; read R2, headed by9@13. - L wins
9@0; write[8@15,9@0]; output empty; L is exhausted. - R wins
9@13; output[9@13]; R is exhausted. The heap is empty: write the final partial page and clear the frame.
This pass has $2+5+2=9$ reads and nine writes: eight full pages plus one partial page. The nine flushed pages above are the final disk run. Their concatenation is $$ [0,0,1,1,2,2,3,3,4,5,5,6,7,8,8,9,9]. $$ For each repeated key, the original indices increase. Both sorted order and stability are visible, and all seventeen original IDs remain.
Final readback and totals. Read those nine final pages once for display: nine reads, no writes. Sorting alone used $9+9+6+6+9+9=48$ transfers. Including readback gives $33$ reads, $24$ writes, and $57$ transfers. Peak record-buffer reservation is six slots, and peak live disk storage is eighteen pages.
Trace It by Hand
Pause immediately after final-merge step 3. L's head is 2@5, R's head is 1@14, and output holds [1@1]. Predict the next record, whether a page is written, whether a page is read, and the next winning head.
Reveal: emit 1@14, write [1@1,1@14], clear output, and read R's next page [2@12,8@15]. The next winner is 2@5 because its original index is earlier than 2@12. This step costs one write and one read.
Now change $C$ to $4$ and $f$ to $3$, keeping $B=2$. Initial run lengths become $[8,8,1]$; one three-way merge makes the final run. Generation costs $18$ transfers, merging costs $18$, and readback costs $9$: total $45$, with a peak reservation of eight slots. Extra memory changes both chunk size and available fan-in.
Finally, predict empty input: no source pages, no runs, no merges, no readback pages, and all counters zero. The parameter constraints still apply.
Implementation in Python, C++, and Java
Each panel is a complete program. Save the Python panel as main.py and run python3 main.py; save C++ as main.cpp and compile with g++ -std=c++17 -O2 main.cpp -o external-sort; save Java as Main.java and run javac Main.java followed by java Main. Python needs a version with dataclasses, available from Python 3.7 onward. Java uses ordinary classes, so no Java record feature is required.
The public teaching functions are external_sort(keys, b, memory_blocks, fan_in) in Python/C++, and externalSort(keys, b, memoryBlocks, fanIn) in Java. They do not mutate the caller's key sequence. Pass a Python list, C++ vector<int>, or Java int[]; null/nonsequence arguments are outside the API contract. Python checks exact integer types and rejects booleans and floats. C++ and Java expose integer-typed parameters. Across the shared integer input domain, invalid model parameters raise PARAMETER, more than $4096$ keys raises SIZE, and a key outside the declared range raises VALUE. Size is checked before key values.
Implementation map. Disk owns simulated pages and counters. Its source setup is uncounted; each later read/write method increments exactly one counter. Freed page slots are reused. Reader keeps one input page and refills only at its boundary. Ram tracks reserved record slots and asserts that reservations fit. heap_sort/heapSort sorts generation chunks in place, and the merge heap stores reader indices, comparing their buffered heads.
The returned result contains the observed records, read/write totals, peak record-buffer reservation, peak live disk pages, and a history with each phase's output run lengths and counter deltas. The last history entry is the optional readback. Subtracting its reads from total transfers gives sort_transfers.
Simulator boundary. Simulated disk pages, input keys, output observations, page IDs, heap indices, and history are stored in host memory. This implementation therefore uses $O(N)$ host storage overall, plus metadata; it is not an actual RAM-capped disk sorter. The $M$ limit applies to reserved sorting record buffers. In a real implementation, replace Disk with page I/O, budget metadata and runtime overhead in bytes, and stream final records to a consumer instead of accumulating the observer list. Copying a page into simulated disk storage represents a write destination, not an extra algorithm input/output frame.
Expected standard output, identical in all three languages:
B=2 memory_blocks=3 M=6 fan_in=2runs lengths=[6,6,5] reads=9 writes=9merge1 lengths=[12,5] reads=6 writes=6merge2 lengths=[17] reads=9 writes=9readback lengths=[17] reads=9 writes=0sorted=[0@7,0@16,1@1,1@14,2@5,2@12,3@3,3@9,4@11,5@2,5@4,6@10,7@8,8@6,8@15,9@0,9@13]reads=33 writes=24 transfers=57 sort_transfers=48peak_buffer_records=6 peak_live_pages=18
Complexity, Correctness, and Invariants
Run counts and passes. For nonempty input, generation produces $$ R_0=\lceil N/M\rceil. $$ A pass maps $R$ runs to $\lceil R/f\rceil$ runs, including any carried singleton. Thus the number of merge passes is $h=0$ when $R_0\le1$, and otherwise $h=\lceil\log_f R_0\rceil$. Handle $N=0$ separately: it uses no transfers. The fan-in is at least two, so the pass count is finite.
Exact I/O. Generation reads and writes $D=\lceil N/B\rceil$ pages because chunk boundaries align to page boundaries. For a real merge group with run lengths $n_1,\ldots,n_g$, the exact counts are $$ \text{reads}=\sum_{j=1}^{g}\left\lceil\frac{n_j}{B}\right\rceil, \qquad \text{writes}=\left\lceil\frac{\sum_{j=1}^{g}n_j}{B}\right\rceil. $$ A singleton group contributes zero. Readback adds $D$ reads. All runs except possibly the last have page-aligned record lengths at each pass, so each full pass costs at most $2D$ transfers in this implementation. Therefore sort-only I/O is at most $2D(1+h)$, and reported I/O at most $D(3+2h)$. These are upper bounds; the worked example saves six transfers by carrying a three-page run in pass one.
Asymptotically, sort-only I/O is $O(D(1+h))$. Taking $f$ close to $M/B-1$ yields the familiar external-sort scale $O((N/B)(1+\log_{M/B}(N/M)))$ for $N\gt M$, ignoring ceilings and constant factors; use the explicit $D$, $R_0$, and $f$ formulas for small inputs and exact counts. A larger fan-in reduces passes only when it crosses a run-count threshold. The CMU sorting lecture presents bounded-memory run generation and merging; its buffer notation differs from this lesson's $B$ records per page.
CPU work. In-place heapsort generation costs $O(N\log(\min(N,M)+1))$ comparisons as an upper bound. Each heap-based merge pass costs $O(N\log f)$, giving $O(N\log(\min(N,M)+1)+Nh\log f)$ total CPU work, plus linear setup/readback and metadata traversal. In the worst case this is $O(N\log(N+1))$. The code does not detect already sorted input or exploit equal keys to skip passes. For fixed $N,B,C,f$, its I/O counts and buffer reservations are the same for best, average, and worst key orders; comparison counts may differ, but all share these CPU upper bounds. There is no assumed input distribution or claim of an exact average comparison count.
Buffer and storage space. Generation uses at most $M$ record slots. A merge reserves $(g+1)B\le M$, and readback reserves one frame when nonempty. Heap metadata has $O(f)$ indices; run and page descriptors can occupy $O(D)$ metadata in this simulator, with phase-history storage also linear in the number of descriptors across its geometrically shrinking passes. For nonempty input the peak live disk payload is exactly $2D$ pages: generation retains the $D$ source pages while writing $D$ initial-run pages. Later group writes cannot exceed the pages they replace, so they never exceed that peak. Freed payload pages are released and page slots reused. The returned observer list stores $N$ records in host memory. Metadata, object layout, spare container capacity, and garbage collection are not measured by the record-slot tracker.
Correctness of generation. The max-heap chunk sorter places the largest remaining pair at the end on each extraction. Its finished suffix is ordered and contains exactly the extracted records; at termination the entire chunk is sorted. Reading disjoint source page chunks and writing each sorted permutation preserves every original record exactly once across initial runs.
Correctness of a merge. At the start of each emission, every nonexhausted reader exposes the smallest unconsumed record of its sorted run, and the heap contains exactly those reader indices. The smallest current head is therefore the smallest record remaining anywhere in the group. Appending it preserves the sorted output prefix. Advancing only that reader restores the head invariant, and exhausted readers are omitted. Full-page writes and the final partial flush partition this prefix without omissions or duplication. Every iteration consumes one record, so the merge terminates with the group's exact sorted union.
Whole-sort argument. Generation establishes sorted, disjoint runs covering the input. Merging replaces a group by its sorted union; carrying preserves the same run. Each pass keeps exact record coverage and reduces the run count until at most one remains. Pair ordering puts equal keys in increasing original-index order, proving stability. Readback returns the final run without reordering it.
| Method | CPU upper bound | Storage transfers / access | Working data and assumptions |
|---|---|---|---|
| This $f$-way external sort | $O(N\log(M+1)+Nh\log f)$ | At most $2D(1+h)$ sort-only; optional $D$ readback | At most $M$ record-buffer slots, plus metadata; temporary disk |
| Two-way external merge | $O(N\log(N+1))$ | At most $2D(1+\lceil\log_2 R_0\rceil)$ for $R_0\gt1$ | Three page frames suffice for each merge; more passes than a feasible larger fan-in |
| Ordinary in-memory comparison sort | $O(N\log(N+1))$ worst case with heapsort | Not an external-I/O guarantee; needs data resident | $N$ records must fit; auxiliary space depends on sorter |
| Existing B+ tree ordered scan | $O(N)$ scan work plus index access | Scan leaf pages in key order; layout/cache affect I/O | Requires an already maintained suitable index; building it has a cost |
| External distribution/radix sort | Depends on key width and digit scheme | Several bucket read/write passes | Needs a suitable key representation and buffered bucket budget |
| Top-$k$ heap scan | $O(N\log(k+1))$ | One input scan, then optional sort of $k$ results | Stores $k$ candidates; computes only top-$k$, not a full sorted file |
Every run is sorted by $(\text{key},\text{original index})$, and the current runs partition the original records exactly once. A merge heap contains one buffered head per nonexhausted run, so its minimum is the globally next record; advancing just that reader restores the invariant. Flushing both full and final partial output pages preserves the entire sorted union.
Variants, Pitfalls, and Optimizations
Partial pages and sentinels. A run's last page may contain fewer than $B$ valid records. Preserve that length and flush a nonempty output buffer after the heap empties. Padding a block with an ordinary key, especially zero, can fabricate records or corrupt order. An exhausted reader should leave the heap; do not use a legal maximum key as an infinity sentinel.
Run boundaries and carry groups. A lone last run can be carried by metadata without any transfers. Do not delete its pages when cleaning up merged inputs, and do not discard it because it lacks a partner. The configured fan-in is a maximum: the last real merge can have fewer inputs. Use actual group size when reserving frames and computing merge costs.
Stability and heap mutation. Equal-key tie handling must be consistent through chunk sorting and every merge. This code decorates each record with a unique original index; merely using an unstable chunk sort on keys would lose the guarantee. Pop a reader index before changing its head, then reinsert it. Comparator arithmetic should compare keys directly rather than subtracting them, which can overflow in a wider input domain.
Buffer the storage access. Fetching a record should usually mean advancing inside a loaded page, not issuing one disk call per key comparison. Multiple pages per run, prefetching, double buffering, and asynchronous I/O can improve throughput, but consume more memory and change the budget. The maximum feasible fan-in need not give the best latency when it leaves too little buffering per stream or exceeds file-descriptor limits.
Production choices. PostgreSQL's tuplesort source describes sorted temporary runs and balanced multiway merging, with prereading to improve locality. It can perform a final merge on demand when random access is unnecessary, avoiding a materialize-and-read cycle. This lesson intentionally materializes the final run and separately counts readback so every phase is visible; an integrated query pipeline may choose differently.
Longer initial runs. Replacement selection maintains a heap while streaming input and can produce runs averaging about $2M$ records under suitable independent random-key assumptions. That is not a worst-case guarantee: descending inputs can yield runs near $M$, while favorable order can yield much longer runs. Natural-run detection can exploit existing order. Either changes the run schedule and must be reflected in the I/O analysis.
Merge selection and scheduling. A loser tree can reduce comparison overhead for multiway merging. Unequal run lengths can motivate length-aware scheduling; historical tape-oriented polyphase schemes distribute runs differently from balanced passes. These are separate implementation choices, not permission to ignore buffer counts or valid run lengths. Parallel run generation and merging also need an aggregate memory and temporary-space budget.
Actual files and failures. A production implementation needs record serialization, stable record identifiers or equivalent tie rules, reliable page reads/writes, disk-space management, cleanup on failure, and publication of a finished output only after it is complete. Retaining originals until replacement output is complete protects against premature deletion but is not by itself a crash-recovery protocol. This simulator has no file errors, OS caching, device latency, compression, or concurrency.
Choose the operation you need. If the input fits comfortably in memory, sort it there. If a suitable ordered index already exists, scan it. If only the largest few records are required, consider a top-$k$ method. External radix/distribution sorting can suit fixed-width keys. External merge sort is particularly useful when a complete comparison-ordered batch must be produced under a buffer budget.
Budget input frames and the output frame together; preserve partial-page lengths and flush the last nonempty output buffer. Carry singleton runs without deleting their data, and compare equal keys consistently across generation and every merge. Keep sort-only I/O, optional readback, and the simulator's host-memory storage distinct when interpreting the counters.
Practice Problems and Real-World Use
Start with three independent properties: final pairs equal an independently sorted copy, every original ID appears exactly once, and equal keys retain increasing IDs. Sortedness alone cannot detect lost records. Then derive expected transfers from run lengths rather than from the simulator's heap operations: generation costs $D$ reads and $D$ writes, a real group uses the ceiling formulas above, a singleton costs zero, and readback adds $D$ reads.
Compare configurations using the same input. Reduce fan-in while keeping $M$ fixed to isolate merge-pass effects; increase $C$ to change both chunk capacity and feasible fan-in. Test a length exactly at $M$, just over $M$, and a run count congruent to one modulo $f$ to exercise a carry. Use all-equal, increasing, decreasing, mixed-sign, and duplicate-heavy keys; fixed-length inputs should have identical transfer histories for the same parameters.
The three implementations were checked on $3633$ shared inputs: $3616$ valid cases and $17$ rejected cases. The independent oracle compares every output record, phase name, run-length vector, read/write count, buffer peak, and live-page peak. Cases include exhaustive short arrays over three keys, randomized configurations, empty and partial pages, singleton carries, maximum fan-in, length $4096$, and invalid limits. Python also passed $16$ exact-type rejection checks and $400$ direct chunk-sort comparisons. C++ ran with address and undefined-behavior sanitizers; all sample outputs match byte for byte.
For a real storage experiment, replace simulated pages with fixed-size file blocks, stream the final output to a verifier, and measure actual bytes, requests, and elapsed time separately. Vary buffer allocation and prefetch depth as well as fan-in. The abstract count predicts data movement, while device layout, caching, and concurrency determine how quickly those transfers complete.
| Exercise or system | Idea to implement or verify | Difficulty |
|---|---|---|
| Three-frame hand trace | Merge two runs with $B=2$; count refills and flushes | Beginner |
| Partial final page | Sort $M+1$ records and preserve all original IDs | Beginner |
| Transfer-count oracle | Compute histories from run lengths; carry singletons for free | Intermediate |
| Stable duplicate records | Keep payload order for equal keys through multiple passes | Intermediate |
Database ORDER BY | Materialize sorted runs or stream a final merge to a query consumer | Intermediate |
| B+ tree bulk loading | Feed sorted key-record pairs into sequential leaf construction | Advanced |
| Large log aggregation | Sort by group key, then reduce adjacent equal-key records | Advanced |
| Real file-backed sorter | Enforce byte budgets, page serialization, cleanup, and independent output verification | Advanced |