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
# 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