Data Structures & Algorithms (DSA) Module 21 – Algorithms in practice
Data that does not fit: external sort, streams
Sort files larger than memory with sorted runs and a k-way heap merge, and summarise endless streams in one pass with counters, samples and sketches.
What you will learn
- Sort data larger than memory with external merge sort
- Summarise streams in one pass with bounded memory
- Choose exact or approximate summaries
Before you start
What you’ll learn · Before you start
What you will learn
- Sort data larger than memory with external merge sort
- Summarise streams in one pass with bounded memory
- Choose exact or approximate summaries
Before you start
On this page
Some data never fits in memory: a day of logs larger than the machine’s RAM, or a stream of events that never ends. Algorithms that load everything first fail on both, and two families of techniques take over. External algorithms work through files in a few sequential passes; the most important is external merge sort, which sorts memory-sized chunks into sorted files called runs and then merges all the runs at once with a heap. Streaming algorithms read each item once and keep a summary whose size does not grow with the stream: exact counters while the keys fit in memory, and approximate summaries, random samples and sketches, when they do not. This lesson runs both on real files and generated streams.
External merge sort in two phases
Suppose a 200 MB log must be sorted by time on a machine that can spare 20 MB. External merge sort does it in two phases:
- Runs. Read 20 MB of lines, sort them in memory and write them to a file, a sorted run. Repeat until the input ends: 10 runs.
- Merge. Open all 10 runs and keep the next unwritten line of each in a min-heap of 10 entries. Write the smallest line, then refill the heap from the run it came from. When every run is used up, the output is sorted.
External merge sort of 200 MB with 20 MB of memory
Text description of the diagram
The diagram is a column of five boxes joined by arrows from top to bottom.
- An unsorted file of 200 MB.
- Phase 1, done ten times: read 20 MB, sort it in memory and write it out as a sorted run. The arrow into this step says that pass 1 reads and writes everything once.
- Ten sorted runs of 20 MB each.
- Phase 2: a min-heap holds the next line of each run; the smallest line is written to the output and replaced by the next line of the same run. The arrow into this step says that pass 2 reads and writes everything once more.
- The sorted file of 200 MB.
Each phase reads all the data once and writes it once, so the sort moves 4 × 200 MB = 800 MB through the disk,
whatever the lines say. Disks and SSDs read long sequential stretches far faster than scattered blocks, and sorting in
memory is quick next to that, so the number of passes over the data decides the time. The merge holds only one line
of each run, plus a read buffer per file, so its memory grows with the number of runs, not with the size of the data.
Python’s heapq.merge performs phase 2: its documentation says it does not pull the data into memory all at once.
# External merge sort on a small scale: sort a log file of 50,000 lines while holding at most 5,000 lines in memory.
# Phase 1 cuts the file into sorted runs; phase 2 merges the runs with a heap (heapq.merge reads them lazily).
import heapq
import itertools
import os
import random
LINES = 50_000
BUDGET = 5_000 # the most lines phase 1 holds in memory at once
def make_log(path, n, seed):
"""A synthetic access log: the millisecond of the day, the service and a request id."""
rng = random.Random(seed)
services = ["auth", "cart", "pay", "search", "ship"]
with open(path, "w") as f:
for _ in range(n):
f.write(f"{rng.randrange(86_400_000):08d} {rng.choice(services):6} req{rng.randrange(10**6):06d}\n")
def make_runs(path, budget):
"""Phase 1: read `budget` lines at a time, sort them in memory, write each batch as a run file."""
runs = []
with open(path) as f:
while chunk := list(itertools.islice(f, budget)):
chunk.sort()
name = f"run{len(runs):03d}.txt"
with open(name, "w") as out:
out.writelines(chunk)
runs.append(name)
return runs
def merge_runs(runs, out_path):
"""Phase 2: one k-way merge; heapq.merge keeps one line of each run in a heap."""
files = [open(name) for name in runs]
try:
with open(out_path, "w") as out:
out.writelines(heapq.merge(*files))
finally:
for f in files:
f.close()
make_log("access.log", LINES, seed=3)
size = os.path.getsize("access.log")
runs = make_runs("access.log", BUDGET)
run_bytes = sum(os.path.getsize(r) for r in runs)
merge_runs(runs, "sorted.log")
with open("access.log") as f:
expected = sorted(f) # only to check the result: this small file fits in memory
with open("sorted.log") as f:
result = f.readlines()
print(f"input: {LINES:,} lines, {size:,} bytes")
print(f"phase 1: {len(runs)} runs of at most {BUDGET:,} lines, each sorted in memory")
print(f"phase 2: one {len(runs)}-way merge, holding one line of each run in a heap")
print(f"bytes read: {size + run_bytes:,} (the data twice); bytes written: {run_bytes + os.path.getsize('sorted.log'):,}")
print(f"first lines: {result[0].strip()} | {result[1].strip()}")
print(f"output sorted and complete: {result == expected}")
for name in runs + ["access.log", "sorted.log"]:
os.remove(name) Output
input: 50,000 lines, 1,300,000 bytes phase 1: 10 runs of at most 5,000 lines, each sorted in memory phase 2: one 10-way merge, holding one line of each run in a heap bytes read: 2,600,000 (the data twice); bytes written: 2,600,000 first lines: 00002015 cart req217182 | 00002550 pay req798609 output sorted and complete: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 external_sort.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The run sorted 50,000 lines while never holding more than 5,000 of them, read the data twice and wrote it twice, and
the result matches Python’s sorted() line for line. With 10 runs the heap had 10 entries; a file of any size would
give the same shape, with more or longer runs.
Practise: merge k sorted streams
Exercise · Medium · Python, JavaScript
Merge k sorted streams lazily with a heap
Phase 2 of an external merge sort reads k sorted runs at once and writes one sorted output, holding only a little of each run in memory. Write that merge as a generator (in JavaScript, a generator function mergeSorted):
merge_sorted(inputs)takes a list of iterators (or iterables), each already sorted from smallest to largest, and yields every item of all of them in sorted order.- It must be lazy: it reads an input only when it needs that input's next item, and holds at most a couple of items from each input at a time. The tests pass inputs that never end, so reading everything first never finishes.
- Equal items come out in input order: an item of an earlier input before an equal item of a later one.
Write the heap yourself with heapq.heappush and heapq.heappop (not heapq.merge); in JavaScript, keep the k fronts in an array and take the smallest, or write a small binary heap. For example, merging [1, 4, 9], [2, 3, 10] and [] yields 1, 2, 3, 4, 9, 10.
Python · Starter code · merge.py
import heapq
def merge_sorted(inputs):
"""Yield the items of the sorted iterators in `inputs` in sorted order, reading each one lazily."""
return iter([]) The sample tests · test_merge.py
from itertools import count, islice
from merge import merge_sorted
def test_three_inputs():
"""merges three sorted lists, one of them empty"""
assert list(merge_sorted([iter([1, 4, 9]), iter([2, 3, 10]), iter([])])) == [1, 2, 3, 4, 9, 10]
def test_no_inputs():
"""no inputs give no output"""
assert list(merge_sorted([])) == []
def test_duplicates_and_negatives():
"""keeps every copy of a repeated item"""
assert list(merge_sorted([iter([-5, 0, 0, 7]), iter([0, 7, 7])])) == [-5, 0, 0, 0, 7, 7, 7]
def test_strings():
"""works for anything that can be compared, such as log lines"""
out = merge_sorted([iter(["0815 pay", "0930 cart"]), iter(["0816 auth"]), iter(["0001 ship", "0999 pay"])])
assert list(out) == ["0001 ship", "0815 pay", "0816 auth", "0930 cart", "0999 pay"]
def test_equal_items_in_input_order():
"""an item of an earlier input comes before an equal item of a later one"""
a, b = [1.0, 2.0], [1, 2] # 1 == 1.0, but the two can be told apart by type
out = list(merge_sorted([iter(a), iter(b)]))
assert out == [1.0, 1, 2.0, 2] and [type(x) for x in out] == [float, int, float, int]
def test_lazy_with_endless_inputs():
"""reads only what it needs: these inputs never end"""
merged = merge_sorted([count(0, 3), count(1, 3), count(2, 3)])
assert list(islice(merged, 12)) == list(range(12))
def test_holds_little_of_each_input():
"""has read at most two items of an input when it yields the first item"""
pulled = [0, 0]
def tracked(items, i):
for x in items:
pulled[i] += 1
yield x
merged = merge_sorted([tracked([1, 2, 3, 4], 0), tracked([10, 20], 1)])
assert next(merged) == 1
assert pulled[0] <= 2 and pulled[1] == 1
assert list(merged) == [2, 3, 4, 10, 20] JavaScript · Starter code · merge.mjs
/** Yield the items of the sorted iterables in `inputs` in sorted order, reading each one lazily. */
export function* mergeSorted(inputs) {
yield* [];
} The sample tests · merge.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { mergeSorted } from './merge.mjs';
function* countFrom(start, step) {
for (let x = start; ; x += step) yield x;
}
function take(iterator, n) {
const out = [];
for (const x of iterator) {
if (out.length === n) break;
out.push(x);
}
return out;
}
test('merges three sorted lists, one of them empty', () => {
assert.deepEqual([...mergeSorted([[1, 4, 9], [2, 3, 10], []])], [1, 2, 3, 4, 9, 10]);
});
test('no inputs give no output', () => {
assert.deepEqual([...mergeSorted([])], []);
});
test('keeps every copy of a repeated item', () => {
assert.deepEqual([...mergeSorted([[-5, 0, 0, 7], [0, 7, 7]])], [-5, 0, 0, 0, 7, 7, 7]);
});
test('works for anything that can be compared, such as log lines', () => {
const out = [...mergeSorted([['0815 pay', '0930 cart'], ['0816 auth'], ['0001 ship', '0999 pay']])];
assert.deepEqual(out, ['0001 ship', '0815 pay', '0816 auth', '0930 cart', '0999 pay']);
});
test('reads only what it needs: these inputs never end', () => {
const merged = mergeSorted([countFrom(0, 3), countFrom(1, 3), countFrom(2, 3)]);
assert.deepEqual(take(merged, 12), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
});
test('has read at most two items of an input when it yields the first item', () => {
const pulled = [0, 0];
function* tracked(items, i) {
for (const x of items) {
pulled[i]++;
yield x;
}
}
const merged = mergeSorted([tracked([1, 2, 3, 4], 0), tracked([10, 20], 1)]);
assert.equal(merged.next().value, 1);
assert.ok(pulled[0] <= 2 && pulled[1] === 1);
assert.deepEqual([...merged], [2, 3, 4, 10, 20]);
}); A hint
Put one entry per input in the heap: (item, input_number, iterator). The input number breaks ties, so equal items come out in input order and two iterators are never compared. Pop the smallest entry, yield its item, then read the next item of the same iterator and push it back; when that iterator is exhausted, push nothing.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
One pass, bounded memory
A stream is data you see once, in order, as it arrives: requests to a server, sensor readings, clicks. A streaming algorithm updates a small summary for each item and can answer at any moment. Some questions have exact one-pass answers in constant memory: the count, the sum and the mean, the minimum and the maximum, and the variance with Welford’s method, which updates a running mean and a running sum of squared differences.
# One pass over a stream of a million response times, keeping five numbers: count, mean, the sum of squared
# differences from the mean (Welford's method, for the standard deviation), minimum and maximum. The stream is a
# generator, so no time is ever stored, and the memory used does not grow with the stream.
import math
import random
def response_times(n, seed):
"""A stream of n response times in milliseconds: at least 5 ms, mostly fast, with a slow tail."""
rng = random.Random(seed)
for _ in range(n):
yield 5 + rng.expovariate(1 / 40) + (rng.random() < 0.02) * rng.uniform(500, 2000)
count = 0
mean = 0.0
m2 = 0.0 # the sum of squared differences from the current mean
low = math.inf
high = -math.inf
for t in response_times(1_000_000, seed=5):
count += 1
delta = t - mean
mean += delta / count
m2 += delta * (t - mean)
low = min(low, t)
high = max(high, t)
print(f"times seen: {count:,}, numbers kept: 5")
print(f"mean {mean:.2f} ms, standard deviation {math.sqrt(m2 / (count - 1)):.2f} ms")
print(f"fastest {low:.2f} ms, slowest {high:,.1f} ms") Output
times seen: 1,000,000, numbers kept: 5 mean 70.24 ms, standard deviation 190.24 ms fastest 5.00 ms, slowest 2,287.6 ms
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_pass.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Other questions need memory that grows with the data if the answer must be exact. The number of distinct users needs every user seen so far, the most frequent terms need a counter for every term, and the median needs every value. When that memory is not available, a summary of fixed size answers approximately:
- Exact counters and hash maps give exact answers, with memory that grows with the number of distinct keys.
- Reservoir sampling keeps a uniform random sample of k items from a stream of unknown length.
- The Misra–Gries algorithm keeps k − 1 counters and never loses an item that makes up more than 1/k of the stream.
- A count-min sketch estimates how often any item appeared from a fixed table of counters, and is never too low.
- A Bloom filter answers “seen before?” in a few bits per item, with false positives but no false negatives.
- HyperLogLog estimates the number of distinct items from a few kilobytes, typically within 1.04/√m for m registers.
| Criterion | Exact hash map or set | Random sample | Sketch (Misra–Gries, count-min, HyperLogLog) |
|---|---|---|---|
| Answers | Anything about the keys, exactly | Questions about typical items: averages, shares | One kind of question each: frequent items, counts, distinct counts |
| Memory | Grows with the distinct keys | Fixed: k items | Fixed: a table chosen from the error you accept |
| Error | None | Sampling error, shrinking like 1/√k | Bounded, one-sided or relative, with a stated probability |
| Several machines | Merge the maps, if they fit | Possible, with weights | Merge counter by counter or register by register |
| When to choose | The keys fit in memory | Exploring or estimating shares of a huge stream | Fixed memory with a guaranteed error |
Check yourself
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
External merge sort at full size, with numbers Pro: opens with a Pro, Premium or Ultimate pass
The small run showed the two phases. Here a 200 MB file is sorted with a 20 MB budget and every byte read and written is counted, then two techniques cut the passes for data far larger than memory.
In this part:
- Sorting 200 MB with a 20 MB budget, measured
- Fewer, longer runs with replacement selection
- Passes, fan-in and what they cost, worked out
3 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Streaming summaries measured: samples, heavy hitters, distinct counts Pro: opens with a Pro, Premium or Ultimate pass
Each approximate summary comes with a guarantee. This part checks three of them on generated streams, reproduces the classic mistake in sampling, and ends with a table for choosing between exact and approximate answers.
In this part:
- Reservoir sampling, its invariant, and a biased imitation
- Heavy hitters with k − 1 counters, measured
- Distinct counts from 4 KB, measured
- Choosing exact or approximate, with numbers
3 runnable examples (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What does external merge sort do in each of its two phases, and why is it used?
It sorts data larger than memory. Phase 1 reads as much data as fits in memory, sorts it there and writes it out as a sorted file called a run, again and again until the input is used up. Phase 2 merges all the runs at once: a min-heap holds the next item of each run, the smallest is written to the output and replaced by the next item of the same run. Each phase reads and writes all the data once, and disks read long sequential stretches quickly, so the cost is about two passes over the data, and the merge needs memory for one buffer per run rather than for the data.
-
Mid to senior
You must sort a 500 GB file of 64-bit customer IDs on a machine with 16 GB of RAM and an SSD that reads and writes about 1 GB/s. How many runs and passes will external merge sort make, how much data moves, and roughly how long will it take?
-
Senior to staff
Forty servers each log 250 million requests a day. Report the 100 client IP addresses with the most requests, using a few megabytes of memory per server and one central job. Which summary do you keep, what can go wrong, and how accurate is the result?
-
Mid to senior
Pick 1,000 events uniformly at random from a stream whose length you do not know in advance, in one pass and with memory for 1,000 events. Give the algorithm and prove that every event has the same chance of being chosen.
Key takeaways
- External merge sort writes memory-sized sorted runs, then merges them with a heap holding one item per run; each pass reads and writes all the data, so passes decide the cost.
- A merge needs memory for one buffer per run, not for the data, and
heapq.mergereads its inputs lazily. - Streaming algorithms see each item once and keep a summary that does not grow with the stream: exact when the answer allows it, such as counts, means and extremes.
- When exact state does not fit, samples and sketches answer approximately from a fixed size, each with a stated error.
References
- heapq: heap queue algorithm (merge, and the theory section on runs for disk sorts) (Python Software Foundation)
- Finding repeated elements (J. Misra and D. Gries, Science of Computer Programming, volume 2, number 2) (Elsevier)
- Random sampling with a reservoir (J. S. Vitter, ACM Transactions on Mathematical Software, volume 11, number 1) (Association for Computing Machinery)
- An improved data stream summary: the count-min sketch and its applications (G. Cormode and S. Muthukrishnan, Journal of Algorithms, volume 55, number 1) (Elsevier)
- HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm (P. Flajolet, É. Fusy, O. Gandouet and F. Meunier, AofA 2007) (Discrete Mathematics & Theoretical Computer Science)
- hashlib: BLAKE2 (Python Software Foundation)
- resource: resource usage information (getrusage, ru_maxrss) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress