Context:FIT2004_MOC · Merge Sort’s combine step generalised from 2 lists to k · the exam shape is “the naive version is Θ(Nk) — make it better”, and the answer is an ADT swap to a min-Heap (LO3) · the engine of external sorting
Quick Revision
🎯 Objective: merge k sorted lists holding N items total into one sorted list ➔ the only question each step asks is “which head is smallest?”, so the cost of answering it is the algorithm.
📦 Core Components:linear scan of k heads ➔ Θ(k) per item, Θ(Nk) total | min-Heap of k heads ➔ Θ(logk) per item, Θ(Nlogk) total.
⚡ Key Constraint: the heap holds one item per list, never the whole input ➔ auxiliary space is Θ(k) and independent of N, which is exactly why this merges runs too large for memory.
📝 How It Works
1. Why k-way Is Harder Than 2-way
Two lists ⟹ one comparison ➔ Merge Sort’s merge compares a[ia] against b[ib], emits the smaller, advances that index — Θ(1) per output item, Θ(N) overall.
k lists ⟹ a minimum query ➔ “smaller of two” becomes “minimum of k”, which a linear scan answers in Θ(k) ⟹ Θ(Nk) total. The merge is no longer free; finding the minimum is the bottleneck.
Reframe as an ADT problem ➔ repeated extract-min with insert between extractions is the definition of a Priority Queue (ADT); swapping the linear scan for the right ADT is the entire optimisation.
2. The Min-Heap Solution
Seed ➔ push the head of each list as a triple (value,list id,index) ⟹ heap size exactly k.
Loop ➔ get_min emits the next output item in Θ(logk); then add that list’s successor, keeping the heap at size k ⟹ Θ(logk).
Total ➔ N items each paying one extract and one insert ⟹ Θ(Nlogk) time, Θ(k) auxiliary beyond the output.
Carrying the list id is mandatory ➔ without it you know what the minimum was but not which list to refill from; the index field is what makes the successor lookup O(1).
3. The Invariant That Proves It
Invariant ➔ at iteration i, the heap root is the correct value for output[i], because it is ≤ every item remaining in every list.
Why it holds ➔ each list is sorted, so a list’s unconsumed head is that list’s own minimum; the heap contains all k heads; the root is the minimum of the heads ⟹ the minimum of everything unconsumed ➔ Invariant.
Termination ➔ each iteration removes exactly one item from a finite total of N and refills only from a strictly advancing index ⟹ the heap empties after N iterations.
Stability comes free ➔ break heap ties by list id and the merge is stable, which matters when k-way merge is the combine step of a stable sort.
4. Θ(Nlogk) Is Optimal — the Reduction
Claim ➔ no comparison-basedk-way merge can beat O(Nlogk).
The reduction ➔ take any sequence of length N, split it into N lists of length 1 (each trivially sorted), and merge them with k=N. The output is the sorted sequence ⟹ the merge algorithm is a comparison sort.
The contradiction ➔ comparison sorting is Ω(NlogN); at k=N a merge faster than O(Nlogk)=O(NlogN) would therefore break that floor ➔ Sorting Problem.
Read the shape, not just the result ➔ this is a lower bound by reduction: you inherit a known bound by showing the new problem can simulate the old one. The same move proves optimality claims across the unit, so drill the argument, not the answer ➔ LO1.
The bound is on the comparison model only ➔ a non-comparison merge over bounded integer keys is not covered by it, exactly as Counting Sort escapes the sorting floor.
⚙️ Core Implementation
🔹 Heap-backed k-way merge
k_way_merge — min-Heap of (value,list id,index) triples
def k_way_merge(lists): # lists: k already-sorted lists. MinHeap is the mirror of the vault's # max-heap: parent <= child, add -> rise, get_min -> sink heap = MinHeap() total = 0 for lid in range(len(lists)): if len(lists[lid]) > 0: heap.add((lists[lid][0], lid, 0)) # seed with each list's HEAD total = total + len(lists[lid]) output = [None] * total write = 0 while write < total: value, lid, idx = heap.get_min() # O(log k) -- the whole cost output[write] = value write = write + 1 if idx + 1 < len(lists[lid]): # refill from the SAME list heap.add((lists[lid][idx + 1], lid, idx + 1)) return output
💡 Common Mistake:Pushing every item at seed time ➔ a heap of all N items costs Θ(N) space and Θ(NlogN) time — that is heapsort, not a merge, and it discards the fact that the lists are already sorted. The heap must never exceed k.
⚖️ Core Decision Matrix
Strategy
Time
Auxiliary space
Selection rule
Linear scan of the k heads
Θ(Nk)
Θ(k) indices
k tiny (2–4) — logk is not worth the heap’s constant
the default; also the only option when the lists stream from disk
Divide & conquer (recursive halving)
Θ(Nlogk)
Θ(N) scratch
lists are already resident and you want Merge Sort’s 2-way merge reused unchanged
Concatenate, then sort
Θ(NlogN)
Θ(N)
never for sorted input — it discards the sortedness you were given
Sequential accumulate (merge one at a time)
Θ(Nk)
Θ(N)
never — re-copies the growing accumulator k times
When It Flips: the heap wins once log2k<k in the constants, i.e. from roughly k≥8. Note that Θ(Nlogk)isMerge Sort when k=N: seeding N singleton lists reproduces Θ(NlogN) exactly — the two algorithms are the same recurrence read from opposite ends.
📊 Exam Execution Trace & Applied Exercises
Manual Execution Trace
k=3: A=[1,4,9], B=[2,3,8], C=[5,6,7]. Heap shown as sorted contents for readability; only the root is actually ordered.
Step
Heap (size k=3)
Root extracted
Refilled from
Output so far
0 (Seed)
(1,A),(2,B),(5,C)
—
—
[]
1
(2,B),(4,A),(5,C)
1
A→4
[1]
2
(3,B),(4,A),(5,C)
2
B→3
[1,2]
3
(4,A),(5,C),(8,B)
3
B→8
[1,2,3]
4
(5,C),(8,B),(9,A)
4
A→9
[1,2,3,4]
5
(6,C),(8,B),(9,A)
5
C→6
[1,2,3,4,5]
Invariant check at step 3: the root 3 is ≤ the other heads 4 and 5, and each head is ≤ everything left in its own list ⟹ 3 is the global minimum of all unconsumed items, so output[2] = 3 is final.
Applied Exercise
Problem: An external sort produced k=1000 sorted runs totalling N=106 records. Quantify the gain from replacing the linear-scan minimum with a heap.
TscanTheapSaux=N⋅Θ(k)=106×103=Θ(109) comparisons=N⋅Θ(log2k)=106×⌈log21000⌉=106×10=Θ(107)=Θ(k)=Θ(103) triples, independent of N
Final Extracted Output:Θ(Nlogk) against Θ(Nk) — a 100× reduction here, with auxiliary space unchanged at Θ(k). The Θ(k) bound is what permits N to exceed memory: only k records are ever resident.
⚠️ Common Mistakes
💡 Quoting Θ(NlogN) for the heap version ➔ the heap never holds more than k items, so each operation is Θ(logk), not Θ(logN) — the whole point is that k≪N.
💡 Losing the list id ➔ storing bare values makes the refill step ambiguous; the triple (value,list id,index) is not optional bookkeeping, it is what keeps the refill O(1).
💡 Reaching for build_heap ➔ bottom-up Θ(k) construction needs all k heads up front, which is fine at seed time but useless mid-merge; the refills are one-at-a-time add calls ➔ Heap.
🧠 Active Recall
State the invariant that proves k-way merge correct, and justify why the heap root alone settles the next output slot.
Hint: Use the fact that each input list is already sorted.
Answer
Short answer: At iteration i the root is ≤ every unconsumed item, so it is output[i].
Why:Heads dominate their own lists ➔ sortedness makes each list’s unconsumed head that list’s minimum; the heap holds all k heads, so the global minimum must be among them, and the heap root is their minimum. No item deeper in any list can undercut it ➔ Invariant.
Why is auxiliary space Θ(k) rather than Θ(N), and what capability does that buy?
Hint: Count how many records must be in memory simultaneously.
Answer
Short answer: Only one record per list is resident ⟹ Θ(k), independent of N ⟹ external sorting becomes possible.
Why:Streamable ➔ the algorithm touches each list strictly left-to-right and never looks back, so the lists can live on disk and be read as streams; a strategy that loads all N items to sort them cannot merge data larger than memory at any complexity ➔ Algorithmic Complexity.
Someone proposes merging the k lists into an accumulator one at a time. Give the bound and the flaw.
Hint: Ask how large the accumulator is on the i-th merge.
Answer
Short answer:Θ(Nk) — the same as the naive scan, and with Θ(N) copying on every round.
Why:The accumulator is re-walked k times ➔ merging list i into an accumulator of size ≈kiN costs that much again, summing to Θ(Nk) by the Arithmetic Series. Divide and conquer fixes it: recursively merge the first ⌊k/2⌋ lists and the remaining ⌈k/2⌉, then one ordinary 2-way merge ⟹ depth Θ(logk), Θ(N) work per level, Θ(Nlogk) total — matching the heap while reusing Merge Sort’s merge verbatim.
Prove that no comparison-based k-way merge can run faster than O(Nlogk).
Hint: Choose the value of k that turns merging into a problem you already have a bound for.
Answer
Short answer: At k=N with singleton lists, a k-way merge is a comparison sort, so beating O(Nlogk) would beat Ω(NlogN).
Why:Lower bound by reduction ➔ split any sequence of length N into N lists of one element each; every such list is trivially sorted, so it is a legal input to the merge, and its output is the sorted sequence. Any merge faster than O(Nlogk) would therefore sort faster than Ω(NlogN) — impossible in the comparison model ➔ Sorting Problem. Note this bounds the comparison model only.