DSA DSA In Depth
0%
lesson external-sortingk-way-mergeio-complexity

External Memory Sorting: K-Way Merge & Replacement Selection

7 min read Module 2.2: Sorting Mechanics, Hybrid Sorts & Linear-Time Order Statistics Updated Oct 23, 2026
📄 Cheat Sheet
CMU 15-445 Lecture 10: External Merge Sort & K-Way Replacement Aggregation
CMU Database Group (Andy Pavlo) CMU 15-445 Lecture 10: External Merge Sort & K-Way Replacement Aggregation Watch on YouTube ↗

Erik Demaine introduces the Aggarwal-Vitter External-Memory (DAM) I/O model, proves why binary Merge Sort is suboptimal on disk/SSD, and derives the optimal multi-way merge bound Θ((N/B) log_{M/B}(N/B)).

0:00 / --:--

The Aggarwal-Vitter External-Memory (DAM) Model

In the Disk Access Machine (DAM) model (Aggarwal & Vitter, 1988), the system has:

  • $N$: Total number of records to sort (stored on external disk/NVMe).
  • $M$: Number of records that fit in fast internal memory (RAM).
  • $B$: Number of records transferred in a single contiguous block I/O ($1 \le B \le M/2$, e.g., a $4\text{ KB}$ or $64\text{ KB}$ page).

The cost metric is purely the number of block I/O transfers between disk and RAM. Notice why naive binary Merge Sort is disastrous here: a 2-way Merge Sort performs $\log_2(N/M)$ merge passes over the entire file, costing $2\frac{N}{B}\log_2\!\left(\frac{N}{M}\right)$ block I/Os. Instead, by allocating $K = \lfloor M / B \rfloor - 1$ input block buffers ($1$ block buffer per sorted run) and $1$ output block buffer in RAM, $K$-Way External Merge Sort merges $K = \Theta(M/B)$ runs in a single pass—shrinking the logarithm base from $2$ to $M/B$!

External Sorting I/O Complexity & Knuth's $2M$ Snowplow Theorem

1. Optimal I/O Lower & Upper Bound (Aggarwal & Vitter): External Multi-Way Merge Sort achieves the information-theoretically optimal I/O complexity:

$$\text{I/Os} = \Theta\!\left(\frac{N}{B} \log_{M/B}\!\left(\frac{N}{B}\right)\right)$$

In practice, with $M = 1\text{ GB}$ of RAM and $B = 1\text{ MB}$ read-ahead blocks, the fan-in is $K \approx 1{,}000$. Thus $(M/B)^2 \approx 10^6$ initial runs—up to $1\text{ PB}$ of data—can be sorted in just $1$ run-generation pass + $1$ or $2$ merge passes!

2. Replacement Selection ($2M$ Expected Run Length, Knuth TAOCP Vol. 3): Standard run generation loads $M$ records into RAM, sorts them, and flushes a run of exact length $M$. Can a priority queue of capacity $M$ produce runs longer than $M$? Maintain a min-heap of size $M$ in RAM and track the last_key_output:

  • Pop the minimum element $x$ from the heap and write it to the current output run ($\text{last\_key} \leftarrow x$).
  • Read the next record $y$ from the input stream. If $y \ge \text{last\_key}$, insert $y$ directly into the active min-heap (it can still belong to the current sorted run!).
  • If $y < \text{last\_key}$, $y$ is too small for the current run; place $y$ in the "dead space" at the back of the heap array (shrinking the active heap size by 1) to seed the next run.

By Knuth's Snowplow Analogy (modeling a snowplow driving around a circular track of circumference $M$ while snow falls uniformly), for random input keys the steady-state volume swept before the active heap empties is $2M$ records on average—cutting the number of initial runs in half!

External k-Way Merge Tournament Loser Tree & Snowplow Run Generation
O((N/B) log_{M/B}(N/B)) I/Os

Trace a k-way Tournament Loser Tree (only 1 comparison per level on replay, no sibling reads!) and Knuth’s Replacement Selection "Snowplow" generating 2M-length runs.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Loser Tree Invariant: Every internal node caches the LOSER of its match, while the winner advances up. Replaying leaf R2 requires exactly log₂(k) comparisons with zero sibling fetches.
Champ: 11 (R2) Loser L0: 15 (R3) Loser L1: 24 (R0) Loser L1: 30 (R1) Replay Path from R2 compares ONLY against stored parent losers (1 cmp/level vs 2 in heap)
Merge Fan-In k
k = 4 sorted disk runs
Comparisons / Output
log₂(k) (1 per level)
Sibling Memory Reads
0 (Stored in parent node)
[Step 1] Emitted champion 11 from Run 2; replayed replacement up leaf-to-root path against stored losers.
Phase / Priority Queue Structure Block I/O Transfers CPU Comparisons per Record Run Length / Notes
2-Way Merge Sort on Disk $2\frac{N}{B}\lceil\log_2(N/M)\rceil$ $\lceil \log_2(N/M) \rceil$ Suboptimal I/O base $2$
$K$-Way Merge ($K = M/B - 1$, Binary Heap) $2\frac{N}{B}\lceil\log_{M/B}(N/M)\rceil$ $\approx 2 \log_2 K$ per sift-down Optimal I/O; 2 comparisons/level
$K$-Way Merge (Tournament Loser Tree) $2\frac{N}{B}\lceil\log_{M/B}(N/M)\rceil$ Exactly $\lceil \log_2 K \rceil$ ($2\times$ fewer!) Register-friendly bottom-up path
Replacement Selection Run Generation $2\frac{N}{B}$ (1 Read + 1 Write) $\log_2 M$ Avg run length $2M$ (random), $\infty$ (sorted)
K-Way External Merge Architecture in RAM (Capacity M = (K + 1) × Block Size B) Run 1 (Disk Blocks) Run 2 (Disk Blocks) Run K (Disk Blocks) In-Buf 1 (Size B) In-Buf 2 (Size B) In-Buf K (Size B) Tournament Loser Tree Internal Nodes Store LOSERS Winner Bubble Up: log₂(K) cmps (No sibling comparisons!) Out-Buf (Size B) Sequential Disk Flush Why a Loser Tree beats a Binary Min-Heap for K-way merging: updating a leaf in a Loser Tree only compares the new key against the stored parent loser (1 cmp/level vs 2 cmps/level in a binary heap).

Why Tournament Loser Trees Beat Binary Heaps in $K$-Way Merging

When merging $K$ sorted runs, every step extracts the global minimum from run $r^*$ and replaces it with the next key from run $r^*$. In a standard binary min-heap, sifting the replacement element down requires comparing the two children against each other and then against the parent—$2 \log_2 K$ comparisons per element.

In Knuth's Tournament Loser Tree, the $K$ current run heads sit at the leaves of a complete binary tree, while each internal node stores the loser of the match played at that node (and the overall winner is stored in tree[0] above the root). When the winner from leaf $r^*$ is replaced by a new element, we walk strictly upward from leaf $r^*$ to the root: at each ancestor node, the previous winner of the other subtree is already sitting right there as the stored loser! Thus we perform exactly $1$ comparison per level ($log_2 K$ total) and never touch sibling nodes in memory.

flowchart LR A["Pop Overall Winner from tree[0] (Run r*)"] --> B["Load Next Record x from Run r*"] B --> C["Walk p = (r* + K)/2 up to 1: If tree[p] < x, swap(x, tree[p]) (Loser stays at tree[p], Winner x rises!)"] C --> D["Store Final Winner into tree[0]"]
Hardware Reality: Double Buffering, NVMe Queue Depth & Cache-Oblivious Funnel Sort

On modern NVMe SSDs capable of $7\text{ GB/s}$ sequential bandwidth, a single synchronous read blocks the CPU while waiting ~50 $\mu$s for PCIe completion. Production external sorters (such as PostgreSQL's tuplesort.c, RocksDB SSTable compaction, and ClickHouse external merge) allocate $2K$ input buffers and $2$ output buffers (Double Buffering / io_uring async I/O) so the CPU merges from the active buffer set while the NVMe DMA engine prefetches the next block for whichever run will exhaust its buffer soonest (predicted by inspecting the last key of each in-memory block!). Furthermore, Frigo et al.'s Cache-Oblivious Funnel Sort achieves the optimal $\Theta\!\left(\frac{N}{B}\log_{M/B}\frac{N}{B}\right)$ bound across L1, L2, L3, and Disk simultaneously without even knowing $M$ or $B$ by recursively arranging $\sqrt{N}$-way mergers in a van Emde Boas tree layout.

Production C++20 Implementation: Tournament Loser Tree $K$-Way Merger & Replacement Selection

#include <concepts>
#include <cstddef>
#include <limits>
#include <utility>
#include <vector>

// Tournament Loser Tree for K-Way Merging in exactly ceil(log2 K) comparisons per output
template <std::totally_ordered T>
class LoserTree {
public:
    explicit LoserTree(const std::vector<T>& initial_heads, T sentinel_max)
        : k_(1), sentinel_(sentinel_max) {
        while (k_ < initial_heads.size()) k_ <<= 1;
        keys_.assign(k_, sentinel_);
        for (std::size_t i = 0; i < initial_heads.size(); ++i) {
            keys_[i] = initial_heads[i];
        }
        tree_.assign(k_, 0);
        // Initialize by replaying matches from all leaves
        std::vector<std::size_t> winners(2 * k_, 0);
        for (std::size_t i = 0; i < k_; ++i) winners[k_ + i] = i;
        for (std::size_t p = k_ - 1; p >= 1; --p) {
            std::size_t left_w  = winners[2 * p];
            std::size_t right_w = winners[2 * p + 1];
            if (keys_[right_w] < keys_[left_w]) {
                tree_[p]   = left_w;  // Loser stays in tree_[p]
                winners[p] = right_w; // Winner advances
            } else {
                tree_[p]   = right_w;
                winners[p] = left_w;
            }
        }
        tree_[0] = winners[1]; // Overall champion stored at tree_[0]
    }

    std::size_t winner_run() const noexcept { return tree_[0]; }
    const T& winner_key() const noexcept { return keys_[tree_[0]]; }

    // Replace the head of the winning run and replay up to root in log2(K) comparisons
    void replay(std::size_t run_idx, T next_key) noexcept {
        keys_[run_idx] = std::move(next_key);
        std::size_t champ = run_idx;
        for (std::size_t p = (k_ + run_idx) >> 1; p >= 1; p >>= 1) {
            // If current champ is larger than the stored loser at tree_[p], they swap roles!
            if (keys_[tree_[p]] < keys_[champ]) {
                std::swap(champ, tree_[p]);
            }
        }
        tree_[0] = champ;
    }

private:
    std::size_t k_;
    T sentinel_;
    std::vector<T> keys_;
    std::vector<std::size_t> tree_; // tree_[0] = overall winner, tree_[1..k-1] = internal losers
};

// Multi-way merge of K sorted vectors using LoserTree
template <std::totally_ordered T>
std::vector<T> k_way_merge(const std::vector<std::vector<T>>& runs, T sentinel_max) {
    const std::size_t k = runs.size();
    if (k == 0) return {};

    std::vector<std::size_t> pos(k, 0);
    std::vector<T> heads(k, sentinel_max);
    std::size_t total = 0;
    for (std::size_t r = 0; r < k; ++r) {
        total += runs[r].size();
        if (!runs[r].empty()) heads[r] = runs[r][0];
    }

    LoserTree<T> lt(heads, sentinel_max);
    std::vector<T> out;
    out.reserve(total);

    for (std::size_t step = 0; step < total; ++step) {
        const std::size_t r = lt.winner_run();
        out.push_back(lt.winner_key());
        ++pos[r];
        T next_val = (pos[r] < runs[r].size()) ? runs[r][pos[r]] : sentinel_max;
        lt.replay(r, std::move(next_val));
    }
    return out;
}
Choosing Block Size $B$: Don't Make $K$ So Large That $B$ Becomes Tiny!

A classic systems mistake is setting $K = 50{,}000$ runs inside $M = 200\text{ MB}$ of RAM, which shrinks each run's input buffer to $B = 4\text{ KB}$. On HDDs or cloud network block storage (EBS/GCS), reading $4\text{ KB}$ random blocks across $50{,}000$ streams incurs millisecond seek/request latency per block, destroying throughput! In practice, size each buffer $B$ to at least $1\text{ MB} \text{–} 4\text{ MB}$ so sequential read bandwidth saturates the storage bus, even if that means doing 2 merge passes of fan-in $K = 128$ instead of 1 pass of fan-in $K = 16{,}384$.

Check yourself Question 1 of 4
Concept Check Optimal I/O Complexity in the External-Memory (DAM) Model recall

A dataset of $N$ records is sorted on disk using an internal RAM capacity of $M$ records and a disk block transfer size of $B$ records.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why does a Tournament Loser Tree perform exactly $\log_2 K = 8$ comparisons per record whereas a standard Binary Min-Heap performs $\approx 2\log_2 K = 16$ comparisons?

Key Takeaways

In the External-Memory (DAM) model with RAM size M and block size B, K-way Merge Sort achieves the optimal I/O bound Θ((N/B) log_{M/B}(N/B)) by merging K = Θ(M/B) runs simultaneously.
Knuth's Replacement Selection uses a size-M min-heap to stream records >= the last output key into the active run, producing sorted runs of expected length 2M on random inputs.
A Tournament Loser Tree stores the loser at each internal node and bubbles the winner upward, performing exactly log_2(K) comparisons per output (half the 2 log_2(K) comparisons of a binary heap).
Double buffering with large block sizes (B >= 1 MB) overlaps CPU tournament merging with asynchronous NVMe/disk prefetching.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue