K-way merge in Python
Merge k sorted input streams into one output stream, using a heap or tournament tree to pick the minimum.
- File
k_way_merge.py, 52 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/k-way-merge/. The authors are listed in prov/04PNZQ.jsonl.
import heapq
from typing import Protocol, TypeVar
class Ordered(Protocol):
def __lt__(self, other: "Ordered", /) -> bool: ...
T = TypeVar("T", bound=Ordered)
class _Head:
"""The head of run i, at position j. Equal keys order by the run number i."""
__slots__ = ("x", "i", "j")
def __init__(self, x: T, i: int, j: int) -> None:
self.x, self.i, self.j = x, i, j
def __lt__(self, other: "_Head") -> bool:
if self.x < other.x:
return True
if other.x < self.x:
return False
return self.i < other.i
def k_way_merge(runs: list[list[T]]) -> list[T]:
"""Merge k sorted runs into one sorted list with a binary heap of the run heads.
Precondition: each run is sorted in non-decreasing order. Equal items come
out in the order of their runs, and in their order inside a run, so the
merge is stable. With n items in total, the merge uses O(k + n log k)
comparisons and time, and O(k) extra space besides the output.
"""
heap: list[_Head] = [] # @line L1
for i, run in enumerate(runs): # @line L2
if run: # @line L3
heap.append(_Head(run[0], i, 0))
heapq.heapify(heap)
out: list[T] = [] # @line L4
while heap: # @line L5
h = heap[0] # @line L6
out.append(h.x) # @line L7
if h.j + 1 < len(runs[h.i]): # @line L8
heapq.heapreplace(heap, _Head(runs[h.i][h.j + 1], h.i, h.j + 1))
else:
heapq.heappop(heap) # @line L9
return out # @line L10
test_k_way_merge.py 47 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/k-way-merge/. The authors are listed in prov/04PNZQ.jsonl.
import random
from k_way_merge import k_way_merge
def test_example() -> None:
runs = [[3, 17, 25], [8, 9], [1, 12, 30], [4, 4, 20]]
assert k_way_merge(runs) == [1, 3, 4, 4, 8, 9, 12, 17, 20, 25, 30]
def test_edges() -> None:
assert k_way_merge([]) == []
assert k_way_merge([[], [], []]) == []
assert k_way_merge([[1, 2, 3]]) == [1, 2, 3]
assert k_way_merge([[], [5], []]) == [5]
assert k_way_merge([[2, 2], [2], [2, 2, 2]]) == [2] * 6
assert k_way_merge([["b", "d"], ["a", "c"]]) == ["a", "b", "c", "d"]
def test_stable() -> None:
class Key:
def __init__(self, k: int, tag: tuple[int, int]) -> None:
self.k, self.tag = k, tag
def __lt__(self, other: "Key") -> bool:
return self.k < other.k
rng = random.Random(11)
runs = []
for i in range(6):
ks = sorted(rng.randint(0, 3) for _ in range(rng.randint(0, 8)))
runs.append([Key(k, (i, j)) for j, k in enumerate(ks)])
out = k_way_merge(runs)
flat = [x for r in runs for x in r]
want = sorted(flat, key=lambda x: (x.k, x.tag))
assert [x.tag for x in out] == [x.tag for x in want]
def test_random_against_sorted() -> None:
rng = random.Random(2024)
for _ in range(800):
k = rng.randint(0, 12)
runs = [sorted(rng.randint(-20, 20) for _ in range(rng.randint(0, 15))) for _ in range(k)]
assert k_way_merge(runs) == sorted(x for r in runs for x in r)
What the next level needs
- the tests did not run in this build