skip to content

In external merge sort, why can run generation start before the total input size is known?

level: middleimportance: nice to knowfreq 30%

answer

  1. look for what the loop actually checks
  2. the total input size never appears
  3. the memory bound is per run
  4. the run count is an output, not an input
  5. the merge plan is made after phase one

basics

~20 s

Run generation only needs to know when the sort buffer is full, never how much input remains. It fills, sorts, spills and repeats, so the run count is discovered as the stream is consumed rather than planned.

solid answer

~50 s

Phase one is a streaming loop: read records until the sort buffer is full or the input ends, sort what you have, write it out as a run, repeat. Nothing in that loop refers to the total input size — the memory bound is per run, so peak memory is constant no matter whether the stream turns out to be 5 GB or 5 TB. The run count is an *output* of phase one, not an input to it. Only the merge phase needs the run count, and by then it is known exactly. If the runs happen to exceed the fan-in, you merge them in groups and repeat; nothing about the plan had to be fixed up front. That is why external merge sort works on a live event stream, a pipe, or a compressed archive whose decompressed length nobody knows.

code

pseudocode · 13 lines
pseudocode
runs = 0
while not end_of_input(stream):
    m = 0
    while m < capacity and not end_of_input(stream):
        buffer[m] = next(stream)
        m = m + 1
    sort(buffer[0..m-1])
    write_run(buffer[0..m-1])
    runs = runs + 1
...
// invariant after each outer iteration:
// every record consumed so far lies in some sorted
// run of at most `capacity` records on storage

go deeper

for a junior

Remember the shape of phase one: fill the buffer, sort it, write it out, repeat until the input ends. Note that the loop stops on end-of-input, never on a precomputed count.

for a middle

State the loop invariant — everything consumed so far lies in a sorted run of at most one bufferful — and explain why peak memory is constant while the run count is discovered rather than planned.

for a senior

Show judgment about the storage side: runs must be allocated as you go, the stream may be far larger than forecast, and you should say plainly that a total order over a live stream requires the stream to be closed.

for a principal

Weigh whether a longer-run technique like replacement selection is worth the extra complexity for your team, given that it only pays when the run count sits near a pass boundary or the input arrives partly ordered.

## The loop, and what it does not reference Run generation in an external merge sort is a plain streaming loop: ``` runs = 0 while not end_of_input(stream): m = 0 while m < capacity and not end_of_input(stream): buffer[m] = next(stream) m = m + 1 sort(buffer[0..m-1]) write_run(buffer[0..m-1]) runs = runs + 1 ``` Read it looking for the total input size. It is not there. The loop consults exactly two conditions: has the stream ended, and is the buffer full. The invariant that holds after every outer iteration is: *every record consumed so far now lives on durable storage inside some sorted run of at most `capacity` records, and the buffer is free.* Peak memory is `capacity` regardless of how long the stream turns out to be. This matters more than it first looks, because the natural mental model of an offline algorithm is "measure the input, plan the work, execute the plan". Applied here it produces a wrong answer that sounds reasonable: *first find `n`, divide by memory to get the run count, then decide the merge shape.* You do not need any of that, and on some inputs you cannot get it. A live event stream has no length. A pipe has no length. A compressed archive's decompressed size is unknown until it is decompressed — and measuring it means a full extra read of the whole thing, which is precisely the pass you were trying to avoid. ## The run count is discovered, not planned The merge phase does need the run count, but it runs *after* phase one, when the count is a known constant. Nothing about the merge has to be committed to in advance: - **Fan-in** is set by memory and buffer size, not by the number of runs. It is known before phase one even starts. - **The number of merge rounds** falls out of `ceil(log_fan-in(runs))` once `runs` is known. - **Grouping** is decided at merge time. If the runs exceed the fan-in, merge them in groups of at most fan-in each, producing longer runs, and repeat until one remains. A related misconception is that the run count should be a power of the fan-in, or that all runs must be the same length. Neither is true. The last run is almost always partial, and when the run count is not an exact multiple of the fan-in the merge simply handles a smaller final group. Runs of unequal length merge correctly and cost, in bytes moved, exactly what their sizes say they cost. ## Making runs longer than memory The fill-sort-spill loop produces runs of exactly `capacity`. There is a classic refinement — **replacement selection** — that produces longer ones. Keep the buffer full and, instead of sorting and dumping it wholesale, repeatedly emit the smallest record that is still greater than or equal to the last one emitted, refilling the vacated slot from the input stream. Records that arrive too small to belong in the current run are held aside for the next one. On randomly ordered input this yields runs averaging about **twice** the sort memory, and on input that is already nearly ordered it can produce one enormous run and finish the whole sort in effectively a single merge — which is the point: fewer runs means a lower chance of needing an extra merge pass. The tradeoff is real. Replacement selection interleaves reads with per-record selection work and gives up the wholly sequential fill-then-sort pattern, so it is less friendly to prefetching and to memory locality; it is also more code to get right, particularly the boundary between the current run and the held-aside next one. On modern hardware, where fan-in is often large enough that one merge pass suffices anyway, doubling the run length frequently buys nothing — which loops right back to the pass-count arithmetic. It earns its keep when the run count sits just above a pass boundary, or when the input is known to arrive partly ordered, as timestamped event streams usually do. ## What still needs care on an unknown-length stream Streamwise generation is not entirely free of surprises. You cannot preallocate output capacity, so the storage for runs has to grow as you go, and you should be prepared for the input to be far larger than anyone predicted — the run count is unbounded from the algorithm's point of view. You also cannot report progress as a percentage, only as bytes consumed. And if the stream is genuinely live, "sorted" only ever means sorted over what has been consumed: a total order requires the stream to be closed. That is a different problem from external sorting, and the honest answer in an interview is to name the distinction rather than pretend the merge can emit final results early.

  • If run generation is streamwise, when is the merge fan-in decided?
    Fan-in is fixed before phase one by memory and buffer size — it does not depend on the data at all. What waits until after phase one is the number of merge rounds, which is ceil(log base fan-in of the run count). If the runs exceed the fan-in, you merge in groups of at most fan-in and repeat until one run remains.
  • Can you generate runs longer than the sort buffer?
    Yes, with replacement selection: keep the buffer full and repeatedly emit the smallest record not less than the last one emitted, refilling from the stream and holding smaller arrivals for the next run. On random input this averages runs of about twice the sort memory, and on nearly-ordered input it can produce one huge run. The cost is a less sequential access pattern and trickier code.
  • Does the run count need to be a multiple of the fan-in?
    No. Runs may be unequal in size and arbitrary in count. When the count is not a multiple of the fan-in, the last merge group is simply smaller. The final run from phase one is almost always partial anyway, since the stream rarely ends exactly on a buffer boundary.

saying these in an interview costs you the question

  • Claims you must read the whole input first to plan the merge
  • Says the run count must be a power of the fan-in
  • Thinks memory use grows with the input size
  • Assumes all runs must be exactly the same length
  • Says an unknown-length input rules out external sorting

context