Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Sorting at Scale  ›  Capstone Project

Capstone: Quarry's Compactor

28 minGenerate memory-sized runs, then merge fan_in runs at a time until one remains, counting every disk pass
You're building a piece ofQuarry — the ingest sorter
This piece — compactor(): Folds a whole day's arrivals into one sorted file, round by round, and reports the disk passes it cost.
Scenario Quarry's nightly compactor folds a whole day's unsorted arrivals into one sorted file, and the ops team wants to know how many disk passes that actually costs before they provision more memory.
Your task
Build compactor(records, memory_limit, fan_in). Generate sorted runs bounded by memory_limit, then repeatedly merge fan_in runs at a time until one remains. Return {"sorted": [...], "runs": <runs after generation>, "passes": <total disk passes>}. Empty records -> {"sorted": [], "runs": 0, "passes": 0}. Example: compactor([5, 3, 8, 1, 9, 2, 7, 4, 6], 3, 3) -> {"sorted": [1,2,3,4,5,6,7,8,9], "runs": 3, "passes": 2}.

Capstone: Quarry's Compactor

In short: Every lesson in this section answered one piece of "sort more, and sort it smarter": heap sort's in-place O(n log n), non-comparison sorts that skip comparing entirely, quickselect's one-sided search, the proof that no comparison sort can do better than n log n in the worst case, and external sorting's runs-then-merge shape for data bigger than memory. Quarry's compactor is where the last piece — external sorting — becomes a real, repeatable job: it doesn't just merge once, it keeps merging in rounds until a growing batch of records collapses into a single sorted file, and it reports exactly how many disk passes that cost.

What you're building

compactor(records, memory_limit, fan_in) -> {"sorted": [...], "runs": r, "passes": p}
compactor([5, 3, 8, 1, 9, 2, 7, 4, 6], 3, 3)
# {"sorted": [1, 2, 3, 4, 5, 6, 7, 8, 9], "runs": 3, "passes": 2}
  • sorted: every record, fully sorted ascending.
  • runs: how many sorted runs records split into after generation — ceil(len(records) / memory_limit).
  • passes: total disk passes — 1 for generation, plus one more per merge round, where each round folds fan_in runs into one until a single run remains.

This is external sorting from the last lesson, generalized: instead of one merge over every run at once, the compactor merges fan_in runs per round — useful when there genuinely isn't room to hold every run's front item open at the same time.

Generate, then round-robin merge

python

The same 9 records cost 2 passes with fan_in=3 and 3 passes with fan_in=2 — a narrower merge always costs more disk passes for the same data, which is exactly the tradeoff merge_pass_count measured in the last lesson.

step through it
1import heapq
2 
3def generate_sorted_runs(records, memory_limit):
4 return [sorted(records[i:i + memory_limit]) for i in range(0, len(records), memory_limit)]
5 
6def merge_round(runs, fan_in):
7 out = []
8 for i in range(0, len(runs), fan_in):
9 group = runs[i:i + fan_in]
10 heap = [(run[0], r, 0) for r, run in enumerate(group) if run]
11 heapq.heapify(heap)
12 merged = []
13 while heap:
14 value, r, idx = heapq.heappop(heap)
15 merged.append(value)
16 if idx + 1 < len(group[r]):
17 heapq.heappush(heap, (group[r][idx + 1], r, idx + 1))
18 out.append(merged)
19 return out
20 
21records = [5, 3, 8, 1, 9, 2, 7, 4, 6]
22runs = generate_sorted_runs(records, 3)
23print("pass 1 (generation):", runs)
24passes = 1
25while len(runs) > 1:
26 runs = merge_round(runs, 2)
27 passes += 1
28 print(f"pass {passes} (merge):", runs)
29print("final:", runs[0], "passes:", passes)

Why more memory (almost) always wins

Every one of Quarry's engines from this course trades memory for fewer disk passes, and the compactor makes that trade explicit: a bigger memory_limit means fewer, bigger runs after generation, and a bigger fan_in means fewer merge rounds to collapse them:

What does this print?
def runs_needed(n, memory_limit):
    import math
    return 0 if n == 0 else math.ceil(n / memory_limit)

print(runs_needed(1000, 100), runs_needed(1000, 50))

Quick practice

What does this print?
def passes_needed(num_runs, fan_in):
    import math
    return 1 if num_runs <= 1 else 1 + math.ceil(math.log(num_runs, fan_in))

print(passes_needed(27, 3))

🎯 Your turn

Build compactor(records, memory_limit, fan_in): generate sorted runs bounded by memory_limit, then repeatedly merge fan_in runs at a time until one remains. Return {"sorted": [...], "runs": <runs after generation>, "passes": <total disk passes>}. Empty records → {"sorted": [], "runs": 0, "passes": 0}.

  • compactor([5, 3, 8, 1, 9, 2, 7, 4, 6], 3, 3) → {"sorted": [1, 2, 3, 4, 5, 6, 7, 8, 9], "runs": 3, "passes": 2}

Hint: generate_sorted_runs gives pass 1 and the runs count to report. Then loop merge_round while more than one run remains, incrementing passes each time — the final single run is the answer.

Press ▶ Run, try the Live App chips, then ✓ Check. Green = every piece of Quarry's ingest sorter is built. ✅

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upHow many runs after generation?

Write runs_needed(n, memory_limit), the number of sorted runs n records split into, given memory_limit records per run. n = 0 gives 0. runs_needed(1000, 100) -> 10.

complexity
DrillHow many merge passes?

Write passes_needed(num_runs, fan_in): 1 if num_runs <= 1, else 1 + ceil(log_fan_in(num_runs)). passes_needed(27, 3) -> 4.

complexity
BuildPass 1: generate the runs

Write generate_sorted_runs(records, memory_limit), splitting records into chunks of at most memory_limit and sorting each one. generate_sorted_runs([5, 3, 8, 1, 9, 2, 7, 4, 6], 3) -> [[3, 5, 8], [1, 2, 9], [4, 6, 7]].

sorting
BossOne merge round

Write merge_round(runs, fan_in): group runs into chunks of fan_in, k-way merge each chunk with a heap, and return the (shorter) list of merged runs. merge_round([[1, 4], [2, 5], [3, 6]], 3) -> [[1, 2, 3, 4, 5, 6]].

heapsk-way-merge
CapstoneTotal records moved across every pass

Write bytes_moved(n, memory_limit, fan_in): every disk pass rewrites all n records once, so the total is n * passes_needed(runs_needed(n, memory_limit), fan_in). bytes_moved(1000, 10, 4) -> 5000.

external-sortcomplexity
compactor.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
compactor(records, memory_limit, fan_in) → dictThe fully sorted records, the run count after generation, and the total disk passes used.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.