Algorithm Search⌘K

Valiant's parallel merge in Python

Merges two sorted lists in O(log log n) parallel rounds using comparisons.

File
valiant_parallel_merge.py, 93 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
valiant_parallel_merge.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/valiant-parallel-merge/. The authors are listed in prov/HPT34J.jsonl.

"""Valiant's merge in the parallel comparison model, simulated round by round.

All subproblems of one level run at the same time. The simulation counts
the comparisons of each round, to check that m + n processors suffice, and
the number of rounds. Ties go to A: an item a of A comes before an item b
of B unless b < a, so every comparison asks "b < a" for some a in A, b in B.
"""

import math
from typing import Protocol, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: "Ordered", /) -> bool: ...


T = TypeVar("T", bound=Ordered)

# A subproblem: merge a[i0:i1] and b[j0:j1] into out[i0 + j0 : i1 + j1].
Task = tuple[int, int, int, int]


class Counter:
    def __init__(self) -> None:
        self.n = 0

    def b_before_a(self, bx: Ordered, ax: Ordered) -> bool:
        self.n += 1
        return bx < ax


def valiant_merge(a: list[T], b: list[T], stats: dict | None = None) -> list[T]:
    """Return the stable merge of the sorted lists a and b."""
    if stats is None:
        stats = {}
    m, n = len(a), len(b)
    out: list = [None] * (m + n)
    stats.update(rounds=0, max_per_round=0)
    tasks: list[Task] = [(0, m, 0, n)]
    while tasks:
        r1, r2 = Counter(), Counter()
        nxt: list[Task] = []
        sampled = False
        for i0, i1, j0, j1 in tasks:
            mm, nn = i1 - i0, j1 - j0
            if mm == 0 or nn == 0:  # @line L1
                for k in range(i0, i1):
                    out[k + j0] = a[k]
                for k in range(j0, j1):
                    out[i0 + k] = b[k]
                continue
            if mm * nn <= mm + nn:  # @line L2
                lt = {(i, j): r1.b_before_a(b[j], a[i]) for i in range(i0, i1) for j in range(j0, j1)}
                for i in range(i0, i1):
                    out[i + j0 + sum(lt[i, j] for j in range(j0, j1))] = a[i]
                for j in range(j0, j1):
                    out[j + i0 + sum(not lt[i, j] for i in range(i0, i1))] = b[j]
                continue
            sampled = True
            s, t = math.isqrt(mm - 1) + 1, math.isqrt(nn - 1) + 1  # @line L3
            sa = list(range(i0 + s - 1, i1, s))
            sb = list(range(j0 + t - 1, j1, t))
            # Round 1: block of each sample among the samples of the other list.  # @line L4
            lt = {(i, j): r1.b_before_a(b[j], a[i]) for i in sa for j in sb}
            blk_a = {i: sum(lt[i, j] for j in sb) for i in sa}
            blk_b = {j: sum(not lt[i, j] for i in sa) for j in sb}
            splits = [(i0, j0, ""), (i1, j1, "")]
            for i in sa:
                lo = j0 + blk_a[i] * t  # first index of the block of B
                hi = min(j1, lo + t - 1)
                rank = lo + sum(r2.b_before_a(b[j], a[i]) for j in range(lo, hi))  # @line L5
                out[i + rank] = a[i]  # @line L6
                splits.append((i, rank, "a"))
            for j in sb:
                lo = i0 + blk_b[j] * s
                hi = min(i1, lo + s - 1)
                rank = lo + sum(not r2.b_before_a(b[j], a[i]) for i in range(lo, hi))
                out[j + rank] = b[j]
                splits.append((rank, j, "b"))
            splits.sort(key=lambda p: p[0] + p[1])  # @line L7
            for (i, j, kind), (i2, j2, _) in zip(splits, splits[1:]):
                # The sample that starts a piece is already in place.
                i += kind == "a"
                j += kind == "b"
                if i < i2 or j < j2:
                    nxt.append((i, i2, j, j2))  # @line L8
        stats["rounds"] += 2 if sampled else (1 if r1.n else 0)
        stats["max_per_round"] = max(stats["max_per_round"], r1.n, r2.n)
        tasks = nxt
    return out
test_valiant_parallel_merge.py 63 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/valiant-parallel-merge/. The authors are listed in prov/HPT34J.jsonl.

import itertools
import math
import random

from valiant_parallel_merge import valiant_merge


def test_example() -> None:
    stats: dict = {}
    out = valiant_merge([1, 4, 6, 9], [2, 3, 7, 8, 10], stats)
    assert out == [1, 2, 3, 4, 6, 7, 8, 9, 10]
    assert stats["rounds"] == 3
    assert stats["max_per_round"] <= 9


def test_edges() -> None:
    assert valiant_merge([], []) == []
    assert valiant_merge([1], []) == [1]
    assert valiant_merge([], [1, 2]) == [1, 2]
    assert valiant_merge([2], [1]) == [1, 2]


def test_small_exhaustive_stable() -> None:
    for m in range(5):
        for n in range(5):
            for a in itertools.product(range(3), repeat=m):
                for b in itertools.product(range(3), repeat=n):
                    ta = [(x, 0, i) for i, x in enumerate(sorted(a))]
                    tb = [(x, 1, i) for i, x in enumerate(sorted(b))]
                    assert valiant_merge(ta, tb) == sorted(ta + tb)


def test_rounds_and_processors() -> None:
    rng = random.Random(11)
    for m, n in [(16, 16), (100, 100), (1000, 1000), (50, 3000), (4096, 4096)]:
        a = sorted(rng.randrange(m + n) for _ in range(m))
        b = sorted(rng.randrange(m + n) for _ in range(n))
        stats: dict = {}
        assert valiant_merge(a, b, stats) == sorted(a + b)
        assert stats["max_per_round"] <= m + n
        big = max(m, n)
        assert stats["rounds"] <= 2 * math.ceil(math.log2(math.log2(big))) + 1


def test_stable_custom_keys() -> None:
    class K:
        def __init__(self, k: int, tag: str) -> None:
            self.k, self.tag = k, tag

        def __lt__(self, other: "K") -> bool:
            return self.k < other.k

    a = [K(i // 3, f"a{i}") for i in range(9)]
    b = [K(i // 2, f"b{i}") for i in range(8)]
    out = valiant_merge(a, b)
    keys = [x.k for x in out]
    assert keys == sorted(keys)
    for k in range(4):
        tags = [x.tag for x in out if x.k == k]
        assert tags == sorted(tags, key=lambda s: (s[0], int(s[1:])))

What the next level needs

  • the tests did not run in this build

Read the full page about Valiant's parallel merge