Algorithm Search⌘K

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
funnelsort.pyRawGitHub
# 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

Read the full page about Funnelsort