Funnelsort in Python
A cache-oblivious sort that merges with a recursive k-merger, achieving the optimal block transfer bound without knowing M or B.
- File
funnelsort.py, 111 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/funnelsort/. The authors are listed in prov/VCKGXK.jsonl.
import math
from collections import deque
from typing import Generic, Optional, Protocol, TypeVar
class Ordered(Protocol):
def __lt__(self, other: "Ordered", /) -> bool: ...
T = TypeVar("T", bound=Ordered)
BASE = 8
class Node(Generic[T]):
"""A node of a lazy funnel: a leaf holds an input run, an inner node a bounded buffer."""
__slots__ = ("buf", "cap", "left", "right", "done")
def __init__(self, left: "Optional[Node[T]]" = None, right: "Optional[Node[T]]" = None) -> None:
self.buf: deque[T] = deque()
self.cap = 0
self.left, self.right = left, right
self.done = left is None # a leaf is complete once its run is loaded
def build_funnel(runs: list[list[T]]) -> Node[T]:
"""A complete binary funnel with the runs as leaves, padded with empty runs.
The buffer sizes follow the rule of Frigo et al.: a funnel with k = 2^h
leaves is a top funnel of height h // 2 and bottom funnels of height
h - h // 2, and the buffers between them hold ceil(k^(3/2)) items. The
same rule applies inside the top and the bottom funnels.
"""
h = 0
while (1 << h) < len(runs):
h += 1
level: list[Node[T]] = []
for i in range(1 << h):
leaf: Node[T] = Node()
if i < len(runs):
leaf.buf.extend(runs[i])
level.append(leaf)
while len(level) > 1:
level = [Node(level[i], level[i + 1]) for i in range(0, len(level), 2)]
root = level[0]
def assign(v: Node[T], height: int) -> None:
if height <= 1:
return
top = height // 2
cap = math.isqrt((1 << height) ** 3 - 1) + 1 # ceil(k^(3/2)), k = 2^height
frontier = [v]
for _ in range(top):
frontier = [c for u in frontier for c in (u.left, u.right) if c is not None]
for u in frontier:
if u.left is not None:
u.cap = cap
assign(u, height - top)
assign(v, top)
assign(root, h)
return root
def fill(v: Node[T]) -> None:
"""Fill the buffer of v from its children, filling a child whenever its buffer is empty."""
assert v.left is not None and v.right is not None
kids = (v.left, v.right)
while len(v.buf) < v.cap: # @line L6
for c in kids: # @line L7
if not c.buf and not c.done:
fill(c)
live = [c for c in kids if c.buf]
if not live: # @line L8
v.done = True
return
c = live[0] if len(live) == 1 or not live[1].buf[0] < live[0].buf[0] else live[1]
v.buf.append(c.buf.popleft()) # @line L9
def funnelsort(xs: list[T]) -> None:
"""Sort xs in place with funnelsort, a cache-oblivious stable merge sort.
Precondition: the items are mutually comparable. The algorithm does not know
the cache size M or the line size B. With a tall cache, M = Omega(B^2), it
makes O((N/B) log_M N) cache misses; the work is O(N log N).
"""
n = len(xs)
if n <= BASE: # @line L1
for i in range(1, n):
x, j = xs[i], i
while j > 0 and x < xs[j - 1]:
xs[j] = xs[j - 1]
j -= 1
xs[j] = x
return
k = 1
while k ** 3 < n: # @line L2
k += 1
size = -(-n // k)
parts = [xs[i:i + size] for i in range(0, n, size)]
for p in parts: # @line L3
funnelsort(p)
root = build_funnel(parts) # @line L4
root.cap = n
fill(root)
xs[:] = list(root.buf) # @line L5
test_funnelsort.py 65 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/funnelsort/. The authors are listed in prov/VCKGXK.jsonl.
import random
from funnelsort import Node, build_funnel, fill, funnelsort
class Rec:
def __init__(self, key: int, pos: int) -> None:
self.key, self.pos = key, pos
def __lt__(self, other: "Rec") -> bool:
return self.key < other.key
def inner_nodes(v: Node) -> list[Node]:
if v.left is None:
return []
return [v] + inner_nodes(v.left) + inner_nodes(v.right)
def test_example() -> None:
f = [21, 4, 17, 9, 26, 1, 12, 23, 6, 15, 19, 3, 25, 8, 11, 14, 2, 20, 27, 5, 10, 16, 24, 7, 13, 18, 22]
funnelsort(f)
assert f == list(range(1, 28))
def test_edges() -> None:
for f in ([], [1], [2, 1], [3, 3, 3], list(range(9, 0, -1))):
want = sorted(f)
funnelsort(f)
assert f == want
def test_buffer_sizes() -> None:
for k in (2, 3, 4, 5, 8, 9, 16, 30, 64):
root = build_funnel([[i] for i in range(k)])
nodes = inner_nodes(root)
assert all(v.cap >= 1 for v in nodes[1:])
root.cap = k
fill(root)
assert list(root.buf) == list(range(k))
root = build_funnel([[i] for i in range(16)])
# k = 16 is a 4-funnel on top of four 4-funnels; the middle buffers hold 16^(3/2) = 64 items
assert root.left.left.cap == 64 and root.left.left.left.cap == 8
def test_stable() -> None:
rng = random.Random(7)
for _ in range(100):
n = rng.randint(0, 400)
f = [Rec(rng.randint(0, 5), i) for i in range(n)]
funnelsort(f)
assert [(r.key, r.pos) for r in f] == sorted((r.key, r.pos) for r in f)
def test_random_against_sorted() -> None:
rng = random.Random(1999)
for _ in range(600):
n = rng.randint(0, 700)
f = [rng.randint(-50, 50) for _ in range(n)]
want = sorted(f)
funnelsort(f)
assert f == want
What the next level needs
- the tests did not run in this build