Algorithm Search⌘K

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
k_way_merge.pyRawGitHub
# 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

Read the full page about K-way merge