Algorithm Search⌘K

Ford-Johnson algorithm in Python

Sorts with the fewest comparisons known for small n; close to the information-theoretic bound.

File
ford_johnson_algorithm.py, 81 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
ford_johnson_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ford-johnson-algorithm/. The authors are listed in prov/311MZG.jsonl.

from typing import Generic, Protocol, TypeVar


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


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


class _Pair(Generic[T]):
    """A winner a with the loser b of its first comparison; ordered by a."""

    __slots__ = ("a", "b")

    def __init__(self, a: T, b: T) -> None:
        self.a, self.b = a, b

    def __lt__(self, other: "_Pair[T]") -> bool:
        return self.a < other.a


def _group_ends(m: int) -> list[int]:
    """t_1 = 1, t_2 = 3, t_3 = 5, t_4 = 11, ...: t_k = (2^(k+1) + (-1)^k) / 3, up to m."""
    ends = [1]
    k = 2
    while ends[-1] < m:
        ends.append(min(m, (2 ** (k + 1) + (-1) ** k) // 3))
        k += 1
    return ends


def _merge_insertion(x: list[T]) -> list[T]:
    n = len(x)
    if n <= 1:  # @line L1
        return list(x)
    pairs: list[_Pair[T]] = []
    for i in range(1, n, 2):  # @line L2
        if x[i] < x[i - 1]:  # @line L3
            pairs.append(_Pair(x[i - 1], x[i]))
        else:
            pairs.append(_Pair(x[i], x[i - 1]))
    extra = x[-1] if n % 2 == 1 else None  # @line L4: the item with no partner
    pairs = _merge_insertion(pairs)  # @line L5
    m = len(pairs)
    a = [p.a for p in pairs]
    b = [p.b for p in pairs] + ([extra] if extra is not None else [])
    s: list[T] = [b[0]] + a  # @line L6
    ends = _group_ends(len(b))
    for k in range(1, len(ends)):  # @line L7
        for j in range(ends[k], ends[k - 1], -1):  # @line L8: b_j for j = t_k down to t_(k-1) + 1
            item = b[j - 1]
            if j <= m:  # @line L9
                partner = a[j - 1]
                u = next(i for i, v in enumerate(s) if v is partner)
            else:
                u = len(s)
            lo, hi = 0, u  # @line L10: binary search in s[0 .. u - 1]
            while lo < hi:
                mid = (lo + hi) // 2
                if item < s[mid]:
                    hi = mid
                else:
                    lo = mid + 1
            s.insert(lo, item)
    return s  # @line L11


def ford_johnson_algorithm(a: list[T]) -> None:
    """Sort a in place, in non-decreasing order, by merge insertion.

    Pair the items, sort the winners recursively, then insert the losers into
    the chain of winners by binary search, in the order b3 b2, b5 b4, b11 ... b6,
    b21 ... b12, ..., so that each binary search is over at most 2^k - 1 items.
    Worst case: F(n) = sum_{k=1..n} ceil(lg(3k/4)) comparisons, which equals
    ceil(lg n!) for n <= 11. Not stable.
    """
    a[:] = _merge_insertion(a)
test_ford_johnson_algorithm.py 90 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ford-johnson-algorithm/. The authors are listed in prov/311MZG.jsonl.

import itertools
import math
import random

from ford_johnson_algorithm import ford_johnson_algorithm


class Counted:
    count = 0

    def __init__(self, k: int) -> None:
        self.k = k

    def __lt__(self, other: "Counted") -> bool:
        Counted.count += 1
        return self.k < other.k


def comparisons(p: list[int]) -> int:
    a = [Counted(x) for x in p]
    Counted.count = 0
    ford_johnson_algorithm(a)
    assert [x.k for x in a] == sorted(p)
    return Counted.count


def worst_case(n: int) -> int:
    return sum(math.ceil(math.log2(3 * k / 4)) for k in range(1, n + 1))


def test_example() -> None:
    assert comparisons([5, 1, 4, 2, 8, 0, 3]) == 12
    a = [5, 1, 4, 2, 8, 0, 3]
    ford_johnson_algorithm(a)
    assert a == [0, 1, 2, 3, 4, 5, 8]


def test_edges() -> None:
    for a in ([], [1], [2, 1], [1, 2], [1, 1, 1], [3, 2, 1], [2**63 - 1, -(2**63)], [0, 2**63 - 1, -(2**63), 0]):
        b = list(a)
        ford_johnson_algorithm(b)
        assert b == sorted(a)


def test_worst_case_is_exact_for_small_n() -> None:
    for n in range(0, 9):
        worst = max(comparisons(list(p)) for p in itertools.permutations(range(n)))
        assert worst == worst_case(n)
        assert worst == math.ceil(math.log2(math.factorial(n)))


def test_never_above_the_bound() -> None:
    rng = random.Random(1959)
    for n in (12, 13, 16, 22, 50, 200):
        for _ in range(20):
            p = list(range(n))
            rng.shuffle(p)
            assert comparisons(p) <= worst_case(n)


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

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

    a = [Key(0, "a"), Key(1, "b"), Key(0, "c"), Key(0, "d")]
    ford_johnson_algorithm(a)
    assert [x.k for x in a] == [0, 0, 0, 1]
    assert [x.tag for x in a] == ["c", "d", "a", "b"]


def test_random_against_sorted() -> None:
    rng = random.Random(1965)
    for _ in range(600):
        a = [rng.randint(-30, 30) for _ in range(rng.randint(0, 60))]
        b = list(a)
        ford_johnson_algorithm(b)
        assert b == sorted(a)


def test_strings() -> None:
    a = ["pear", "fig", "apple", "fig", "kiwi"]
    ford_johnson_algorithm(a)
    assert a == ["apple", "fig", "fig", "kiwi", "pear"]

What the next level needs

  • the tests did not run in this build

Read the full page about Ford-Johnson algorithm