skip to content

Your billion-record merge sort blows its memory ceiling — how do you shrink the auxiliary space without losing the O(n log n) guarantee?

level: seniorimportance: should knowfreq 48%

answer

  1. peak bytes and total allocations are different numbers
  2. where is the temporary array created?
  3. does every merge need its own buffer?
  4. you only have to copy one of the two runs
  5. in-place merging exists but is not free

basics

~20 s

Hoist one buffer above the recursion instead of allocating a fresh temporary in every merge call, and copy only the left run so the buffer is half-sized. If the data itself no longer fits, sort chunks that do and merge the sorted chunks as streams.

solid answer

~50 s

First find out what is actually consuming the memory. Merge sort's auxiliary space is `O(n)`, not `O(n log n)` — but a naive implementation allocates a fresh temporary inside every recursive call, which keeps peak live memory at `O(n)` while generating `O(n log n)` allocations' worth of churn and fragmentation. Fix one: allocate a single buffer of size n once, above the recursion, and pass it down. Fix two: copy only the left run into the buffer and merge back down into the array, halving the buffer to `n/2`. Fix three: ping-pong between two arrays, alternating source and destination each level, which removes the copy-back at the cost of a permanent second array. The recursion stack is `O(log n)` frames — about thirty at a billion records — so it is never the problem. If the ceiling is hard and the dataset itself does not fit, that is external sorting: sort chunks in memory, spill them, then merge the sorted streams sequentially.

go deeper

for a junior

Know the headline number: array merge sort needs about n extra element slots, on top of the array itself. That linear buffer is the price paid for the guaranteed worst-case bound.

for a middle

Explain why the space is O(n) and not O(n log n), and where the temporary belongs — one buffer above the recursion, not one per call. Be able to describe copying only the left run.

for a senior

Diagnose before prescribing: distinguish peak live memory from allocation churn, check whether the buffer is hoisted, and give the honest account of what in-place merging costs. Know when the answer is external sorting instead.

for a principal

Own the tradeoff under a fleet-wide memory ceiling: a linear buffer that every engineer understands versus an in-place scheme nobody on the team can safely modify. Say what you would give up first — stability, simplicity, or the memory.

## Separate three different memory costs When a merge sort is accused of using too much memory, three distinct quantities get conflated: 1. **Peak live auxiliary memory** — the most extra bytes alive at any instant. For array merge sort this is `Theta(n)` element slots. 2. **Total allocation churn** — how many bytes pass through the allocator over the run. This can be far larger than the peak, and it is often the real symptom (allocator pressure, fragmentation, pauses). 3. **Recursion stack** — `O(log n)` frames, roughly 30 at a billion elements. Real, bounded, and never the cause of a memory ceiling breach in this algorithm. The common misconception is that merge sort needs `O(n log n)` extra space, arrived at by multiplying "a buffer per level" by "log n levels". It does not: buffers at different levels are not simultaneously live in the way that reasoning assumes, and the sizes down a root-to-leaf path form a geometric series `n + n/2 + n/4 + ... = 2n`, which is still `O(n)`. ## Fix 1 — one buffer, hoisted above the recursion The worst common implementation allocates a fresh temporary array at the top of every merge. That is roughly `2n` slots allocated at each of `log n` levels, so on the order of `n log n` slots passing through the allocator, all of it short-lived garbage. Peak live memory is unchanged, but throughput suffers badly and memory fragments. The fix is structural: allocate one buffer of size n before the first recursive call and thread it through, letting each merge use the slice `buf[lo..hi-1]`. Zero allocations inside the recursion, identical output, and the merges no longer touch the allocator at all. At a billion records this is usually the entire difference between a job that runs and one that thrashes. ## Fix 2 — the half-buffer trick The merge does not need both runs copied out. Copy only the **left** run into the buffer, then merge the buffer against the right run — which is still sitting in the array — writing results back into the array starting at `lo`. The write cursor never overtakes the right run's read cursor, because every write consumes exactly one element from one of the two sources and the right run is already positioned ahead. The buffer drops to `n/2`, and the tie-break rule that preserves stability is unchanged: on a tie, take from the buffered left run. ## Fix 3 — ping-pong between two arrays An alternative that removes the copy-back entirely: keep two arrays of size n and alternate their roles level by level, merging from A into B at one level and from B into A at the next. This costs a permanent `2n` footprint rather than `1.5n`, but every element is written exactly once per level instead of copied twice. It is the natural formulation for the bottom-up variant, where levels are explicit passes. ## "Can't you just merge in place?" This is the follow-up that separates a rehearsed answer from a real one. In-place merging of two adjacent sorted runs **is** possible — but it is not free: - The obvious approach, shifting or rotating elements to open a slot whenever the right run's head must move left, is easy to write and degrades to quadratic work in the merge. - Rotation-based schemes such as symmerge do the merge with `O(1)` auxiliary space but raise the merge cost to `O(n log n)`, pushing the total higher than the classic algorithm. - Block-merge schemes get much closer to linear merging with `O(1)` extra space by carving an internal buffer out of the array itself, and stable variants exist — at the price of large constant factors, subtle correctness arguments and a great deal of code that few teams want to own. So the honest answer is: yes, but you are trading a well-understood linear buffer for constants, complexity and maintenance risk. Do it only when the memory ceiling is genuinely immovable, and consider first whether the workload actually needs a stable sort — if not, a guaranteed `O(n log n)` sort that is in-place by construction is a much cheaper way to get to `O(1)` auxiliary space. ## When the data itself does not fit At a billion records the question often is not the buffer at all: the array does not fit. That is external sorting, and merge sort is the natural fit because merging is purely sequential — it reads each input forward, once. Sort chunks that fit in memory, write each out as a sorted run, then merge the runs in a streaming pass. Sequential reads and writes are what storage is good at, and the algorithm never needs random access to the data it is merging. ## How to answer this in an interview Name the three costs, say `O(n)` peak with confidence, then diagnose rather than prescribe: where is the temporary allocated, is the buffer hoisted, is the copy-back needed, and does the dataset even fit? Finish with the honest in-place caveat. Confidently reciting `O(n)` and stopping is a middle-level answer; the diagnosis is the senior one.

  • Can't you drop the buffer entirely and merge the two runs in place?
    In-place merging is possible but not free. Naive shift-or-rotate merging degrades to quadratic work; rotation-based schemes reach `O(1)` extra space but raise the merge itself to `O(n log n)`; block-merge schemes get close to linear at the cost of large constants and intricate, hard-to-maintain code. You trade a well-understood linear buffer for constants and complexity.
  • Does the recursion itself contribute meaningfully to the memory problem?
    No. Depth is `O(log n)` — about thirty frames at a billion records — which is negligible next to a linear buffer. If deep recursion is a constraint for some other reason, such as a hard stack limit, the iterative bottom-up formulation removes the stack entirely without changing the buffer requirement at all.
  • The buffer fits, but the full dataset no longer does. What changes?
    It becomes an external sort. Read chunks that fit in memory, sort each, write it out as a sorted run, then merge the runs in a streaming pass. Merge sort suits this because merging reads each input sequentially and forward-only, so storage sees sequential access rather than random seeks.

saying these in an interview costs you the question

  • Says merge sort needs O(n log n) auxiliary space
  • Allocates a fresh temporary array inside every recursive call
  • Claims in-place merging is free if you swap as you go
  • Blames the O(log n) recursion stack for a memory ceiling breach
  • Thinks halving the buffer changes the asymptotic space class

context