Algorithm Search⌘K

Spreadsort in Python

Hybrid of MSD radix distribution and comparison sort that adapts bucket counts to the input distribution.

File
spreadsort.py, 104 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
spreadsort.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/spreadsort/. The authors are listed in prov/ER6NF4.jsonl.
import struct
from typing import Callable, Union

Key = Union[int, float, str]


def _float_bits(x: float) -> int:
    """An integer with the same order as the float x (IEEE 754 bit trick)."""
    u = struct.unpack(">Q", struct.pack(">d", x))[0]
    return u ^ 0xFFFFFFFFFFFFFFFF if u >> 63 else u | (1 << 63)


def integer_key(a: list[Key]) -> tuple[Callable[[Key], int], bool]:
    """Return an order-preserving map from the keys of a to non-negative integers.

    The second value says whether the map is one-to-one. Integers map to
    themselves, floats to their ordered bit patterns, and strings to the
    number with the digits ord(c) + 1, padded with 0 to the longest length.
    """
    if all(isinstance(x, int) for x in a):
        return (lambda x: x), True  # type: ignore[return-value]
    if all(isinstance(x, str) for x in a):
        width = max(len(s) for s in a)  # type: ignore[arg-type]
        base = 2 + max((ord(c) for s in a for c in s), default=0)  # type: ignore[union-attr]

        def code(s: str) -> int:
            v = 0
            for c in s:
                v = v * base + ord(c) + 1
            return v * base ** (width - len(s))

        return code, True  # type: ignore[return-value]
    exact = all(isinstance(x, float) for x in a)
    return (lambda x: _float_bits(float(x))), exact  # type: ignore[arg-type]


def _insertion_sort(p: list[tuple[int, Key]], lo: int, hi: int) -> None:
    """Stable insertion sort of p[lo:hi] by the original keys."""
    for i in range(lo + 1, hi):
        item = p[i]
        j = i - 1
        while j >= lo and item[1] < p[j][1]:  # type: ignore[operator]
            p[j + 1] = p[j]
            j -= 1
        p[j + 1] = item


def spreadsort(a: list[Key], max_splits: int = 11, small: int = 16, log_bin: int = 2) -> None:
    """Sort the list a in place, in non-decreasing order. The sort is not stable.

    Precondition: the keys are all numbers or all strings. Each step spreads
    a range of at least `small` keys into at most 2**max_splits bins by the top
    bits of (key - min); small ranges use insertion sort. The cost is O(n K / b)
    for K-bit keys and at least b >= 1 bits per step, and O(n) on random keys
    for a fixed key length; the extra space is O(2**max_splits) per open range.
    """
    if len(a) <= 1:
        return
    f, injective = integer_key(a)
    p = [(f(x), x) for x in a]
    stack = [(0, len(p))]  # @line L1
    while stack:  # @line L2
        lo, hi = stack.pop()  # @line L3
        if hi - lo < small:  # @line L4
            _insertion_sort(p, lo, hi)
            continue
        mn = min(p[i][0] for i in range(lo, hi))  # @line L5
        mx = max(p[i][0] for i in range(lo, hi))
        if mn == mx:  # @line L6
            if not injective:
                _insertion_sort(p, lo, hi)
            continue
        r = (mx - mn).bit_length()  # @line L7
        b = max(1, min(max_splits, (hi - lo).bit_length() - 1 - log_bin, r))  # @line L8
        shift = r - b  # @line L9
        nb = 1 << b
        count = [0] * nb  # @line L10
        for i in range(lo, hi):
            count[(p[i][0] - mn) >> shift] += 1
        start = [0] * (nb + 1)
        pos = lo
        for j in range(nb):
            start[j] = pos
            pos += count[j]
        start[nb] = hi
        nxt = start[:nb]
        for j in range(nb):  # @line L11
            end = start[j + 1]
            while nxt[j] < end:
                k = (p[nxt[j]][0] - mn) >> shift
                if k == j:
                    nxt[j] += 1
                else:
                    t = nxt[k]
                    p[nxt[j]], p[t] = p[t], p[nxt[j]]
                    nxt[k] = t + 1
        if shift == 0 and injective:  # @line L12
            continue
        for j in range(nb):  # @line L13
            if start[j + 1] - start[j] >= 2:
                stack.append((start[j], start[j + 1]))
    a[:] = [x for _, x in p]  # @line L14
test_spreadsort.py 68 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/spreadsort/. The authors are listed in prov/ER6NF4.jsonl.
import random
import unittest

from spreadsort import integer_key, spreadsort


class TestSpreadsort(unittest.TestCase):
    def test_page_example(self):
        a = [37, 12, 58, 3, 45, 21, 60, 9, 33, 50]
        self.assertEqual([(x - 3) >> 3 for x in a], [4, 1, 6, 0, 5, 2, 7, 0, 3, 5])
        spreadsort(a, small=4, log_bin=0)
        self.assertEqual(a, [3, 9, 12, 21, 33, 37, 45, 50, 58, 60])

    def test_edges(self):
        for a in ([], [1], [2, 1], [5, 5, 5], [0, -5, 3, -1, -5, 2], [2**63 - 1, -(2**63), 0, -1, 1]):
            b = list(a)
            spreadsort(b, small=2, log_bin=0)
            self.assertEqual(b, sorted(a))

    def test_key_map_order(self):
        for a in (["b", "", "a", "ab", "ba"], [-1.5, 0.0, 2.25, -1e308, 1e308]):
            f, _ = integer_key(a)
            self.assertEqual(sorted(a, key=f), sorted(a))

    def test_floats_and_mixed(self):
        a = [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68, 1e308, -1e308] * 3
        b = list(a)
        spreadsort(b)
        self.assertEqual(b, sorted(a))
        c = [3, 1.5, -2, 0.25, 2**63, 2**63 - 1] * 5
        d = list(c)
        spreadsort(d, small=2)
        self.assertEqual(d, sorted(c))

    def test_strings(self):
        for a in (["pear", "fig", "apple"] * 9, ["b", "", "a", "ab", ""] * 7, ["same"] * 40, ["xxxxxxb", "xxxxxxa", "xxxxxx"] * 8):
            b = list(a)
            spreadsort(b, small=2)
            self.assertEqual(b, sorted(a))

    def test_skewed_and_patterns(self):
        for a in ([10**18] + list(range(500, 0, -1)), [i % 7 for i in range(400)], [2**k for k in range(62)] * 3):
            b = list(a)
            spreadsort(b)
            self.assertEqual(b, sorted(a))

    def test_random(self):
        rng = random.Random(2002)
        for _ in range(600):
            n = rng.randint(0, 120)
            kind = rng.randrange(4)
            if kind == 0:
                a = [rng.randint(-1000, 1000) for _ in range(n)]
            elif kind == 1:
                a = [rng.random() for _ in range(n)]
            elif kind == 2:
                a = [rng.randint(-(2**63), 2**63 - 1) for _ in range(n)]
            else:
                a = ["".join(rng.choice("abc") for _ in range(rng.randint(0, 5))) for _ in range(n)]
            b = list(a)
            spreadsort(b, small=rng.choice([2, 4, 16]), log_bin=rng.choice([0, 2]))
            self.assertEqual(b, sorted(a))


if __name__ == "__main__":
    unittest.main()

What the next level needs

  • the tests did not run in this build

Read the full page about Spreadsort