Algorithm Search⌘K

Soft heap in Python

Approximate meldable heap that corrupts a fixed fraction of keys to reach O(1) amortized operations.

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

"""Soft heap in the style of Kaplan, Tarjan and Zwick (2013).

Each node of a binary tree holds a list of items, which all carry the same
current key, the ckey of the node. Items in nodes of rank above T may be
corrupted: their ckey is larger than their own key.
"""

import math
from typing import Any, List, Optional, Protocol, Tuple, TypeVar, Generic


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


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


class Node(Generic[K]):
    """A node: ckey, rank, item list and two children."""

    __slots__ = ("ckey", "rank", "items", "left", "right")

    def __init__(self, key: K) -> None:
        self.ckey: K = key
        self.rank = 0
        self.items: List[K] = [key]
        self.left: Optional["Node[K]"] = None
        self.right: Optional["Node[K]"] = None

    def is_leaf(self) -> bool:
        return self.left is None and self.right is None


class SoftHeap(Generic[K]):
    """Soft heap with error rate eps: at most eps * (number of inserts) corrupted items.

    The threshold is T = ceil(lg(1/eps)) + 5, which is what the proof on the
    page needs. A value of t given directly overrides it.
    """

    def __init__(self, eps: float = 0.25, t: Optional[int] = None) -> None:
        if t is None:
            if not 0 < eps < 1:
                raise ValueError("eps must be in (0, 1)")
            t = math.ceil(math.log2(1 / eps)) + 5
        self.t = t
        self.roots: List[Node[K]] = []  # decreasing rank; the end has the smallest rank
        self.best: List[int] = []  # best[i]: index of the root of smallest ckey in roots[0..i]
        self.inserts = 0
        self.count = 0
        self.links = 0
        self.moves = 0

    def __len__(self) -> int:
        return self.count

    def size(self, rank: int) -> int:
        """Target size s_k: 1 for k <= T, then ceil(3 s_{k-1} / 2)."""
        s = 1
        for _ in range(self.t, rank):
            s = (3 * s + 1) // 2
        return s

    def _sift(self, x: Node[K]) -> None:
        """Fill the item list of x from below while it is shorter than s_rank."""
        target = self.size(x.rank)
        while len(x.items) < target and not x.is_leaf():  # @line L3
            if x.left is None or (x.right is not None and x.right.ckey < x.left.ckey):  # @line L4
                x.left, x.right = x.right, x.left
            c = x.left
            assert c is not None
            self.moves += 1
            x.items.extend(c.items)  # @line L5
            x.ckey = c.ckey
            c.items = []
            if c.is_leaf():  # @line L6
                x.left = None
            else:
                self._sift(c)

    def _link(self, x: Node[K], y: Node[K]) -> Node[K]:
        """Make a new node of rank r + 1 over two roots of rank r, and fill it."""
        self.links += 1
        z: Node[K] = Node(x.ckey)  # @line L1
        z.items = []
        z.rank = x.rank + 1
        z.left, z.right = x, y
        self._sift(z)  # @line L2
        return z

    def _fix_best(self, start: int) -> None:
        """Recompute best[start:]."""
        del self.best[start:]
        for i in range(start, len(self.roots)):
            if i == 0 or self.roots[i].ckey < self.roots[self.best[i - 1]].ckey:
                self.best.append(i)
            else:
                self.best.append(self.best[i - 1])

    def _add_root(self, x: Node[K]) -> None:
        """Add a root, linking it with a root of equal rank while there is one."""
        while True:
            i = len(self.roots)
            while i > 0 and self.roots[i - 1].rank < x.rank:
                i -= 1
            if i > 0 and self.roots[i - 1].rank == x.rank:
                x = self._link(x, self.roots.pop(i - 1))
                continue
            self.roots.insert(i, x)
            self._fix_best(min(i, len(self.best)))
            return

    def insert(self, key: K) -> None:
        """Insert key."""
        self.inserts += 1
        self.count += 1
        self._add_root(Node(key))  # @line L7

    def meld(self, other: "SoftHeap[K]") -> None:
        """Move all items of other into this heap. Both must use the same T."""
        if other.t != self.t:
            raise ValueError("the heaps have different thresholds")
        for x in reversed(other.roots):  # @line L8
            self._add_root(x)
        self.inserts += other.inserts
        self.count += other.count
        other.roots, other.best, other.count, other.inserts = [], [], 0, 0

    def find_min(self) -> Tuple[K, K]:
        """Return (item, current key) of an item of smallest current key."""
        if not self.roots:
            raise IndexError("the heap is empty")
        x = self.roots[self.best[-1]]  # @line L9
        return x.items[-1], x.ckey

    def delete_min(self) -> Tuple[K, K]:
        """Remove and return (item, current key) of an item of smallest current key."""
        if not self.roots:
            raise IndexError("the heap is empty")
        i = self.best[-1]
        x = self.roots[i]  # @line L10
        e = x.items.pop()  # @line L11
        ckey = x.ckey
        self.count -= 1
        if 2 * len(x.items) <= self.size(x.rank):  # @line L12
            if not x.is_leaf():
                self._sift(x)  # @line L13
            elif not x.items:
                self.roots.pop(i)  # @line L14
        self._fix_best(min(i, len(self.best)))  # @line L15
        return e, ckey

    # -- checking ----------------------------------------------------------

    def nodes(self) -> List[Node[K]]:
        out: List[Node[K]] = []
        stack = list(self.roots)
        while stack:
            v = stack.pop()
            out.append(v)
            stack.extend(c for c in (v.left, v.right) if c is not None)
        return out

    def corrupted(self) -> int:
        """The number of items whose current key is larger than their own key."""
        return sum(1 for v in self.nodes() for e in v.items if e < v.ckey)

    def check_invariant(self) -> None:
        """Check ranks, heap order of ckeys, item keys, the size bounds and the error bound."""
        ranks = [r.rank for r in self.roots]
        assert all(a > b for a, b in zip(ranks, ranks[1:]))
        assert len(self.best) == len(self.roots)
        for i in range(len(self.roots)):
            m = min(range(i + 1), key=lambda j: (self.roots[j].ckey, j))
            assert not self.roots[m].ckey < self.roots[self.best[i]].ckey
        total = 0
        for v in self.nodes():
            total += len(v.items)
            assert v.is_leaf() or v.items, "an internal node has an empty list"
            assert v.items or v.is_leaf()
            for e in v.items:
                assert not v.ckey < e
            if v.rank <= self.t:
                assert len(v.items) <= 1
            for c in (v.left, v.right):
                if c is not None:
                    assert c.rank == v.rank - 1
                    assert not c.ckey < v.ckey
        assert total == self.count
        assert all(r.items or not r.is_leaf() for r in self.roots)
        assert self.corrupted() * 2**self.t <= 18 * self.inserts
test_soft_heap.py 120 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/soft-heap/. The authors are listed in prov/C1G5PQ.jsonl.

import random
from collections import Counter

import pytest

from soft_heap import Node, SoftHeap


def show(v: Node[int]) -> str:
    """Write a node as ckey{items}(children)."""
    s = f"{v.ckey}{{{','.join(map(str, v.items))}}}"
    kids = [show(c) for c in (v.left, v.right) if c is not None]
    return s + ("(" + " ".join(kids) + ")" if kids else "")


def test_page_example() -> None:
    h: SoftHeap[int] = SoftHeap(t=2)
    assert [h.size(k) for k in range(7)] == [1, 1, 1, 2, 3, 5, 8]
    for k in [5, 3, 8, 1, 7, 2, 6, 4, 9]:
        h.insert(k)
    assert [show(r) for r in h.roots] == ["2{1,2}(4{4}(6{6} 7{7}) 3{3}(5{5} 8{8}))", "9{9}"]
    assert h.corrupted() == 1 and h.links == 7
    out = []
    for _ in range(4):
        out.append((h.delete_min(), [show(r) for r in h.roots]))
        h.check_invariant()
    assert out == [
        ((2, 2), ["3{1,3}(5{5}(8{8}) 4{4}(6{6} 7{7}))", "9{9}"]),
        ((3, 3), ["4{1,4}(6{6}(7{7}) 5{5}(8{8}))", "9{9}"]),
        ((4, 4), ["5{1,5}(8{8} 6{6}(7{7}))", "9{9}"]),
        ((5, 5), ["6{1,6}(7{7} 8{8})", "9{9}"]),
    ]
    rest = [h.delete_min() for _ in range(len(h))]
    assert rest == [(6, 6), (7, 7), (8, 8), (1, 8), (9, 9)]


def test_no_corruption_below_threshold() -> None:
    h: SoftHeap[int] = SoftHeap(eps=0.25)
    assert h.t == 7
    keys = list(range(100))
    random.Random(3).shuffle(keys)
    for k in keys:
        h.insert(k)
    assert h.corrupted() == 0
    assert [h.delete_min()[0] for _ in range(100)] == list(range(100))


def test_empty_and_errors() -> None:
    h: SoftHeap[int] = SoftHeap()
    with pytest.raises(IndexError):
        h.delete_min()
    with pytest.raises(IndexError):
        h.find_min()
    with pytest.raises(ValueError):
        SoftHeap(eps=0)
    h.insert(4)
    assert h.find_min() == (4, 4)
    assert h.delete_min() == (4, 4) and len(h) == 0 and h.roots == []
    with pytest.raises(ValueError):
        h.meld(SoftHeap(t=1))


def test_meld() -> None:
    a: SoftHeap[int] = SoftHeap(t=1)
    b: SoftHeap[int] = SoftHeap(t=1)
    for k in range(0, 40, 2):
        a.insert(k)
    for k in range(1, 40, 2):
        b.insert(k)
    a.meld(b)
    a.check_invariant()
    assert len(b) == 0 and len(a) == 40 and a.inserts == 40
    got = [a.delete_min() for _ in range(40)]
    assert sorted(e for e, _ in got) == list(range(40))
    assert [c for _, c in got] == sorted(c for _, c in got)


def test_error_bound_large() -> None:
    rng = random.Random(5)
    for eps in [0.5, 0.25, 0.1]:
        h: SoftHeap[float] = SoftHeap(eps=eps)
        for _ in range(5000):
            h.insert(rng.random())
        assert h.corrupted() <= eps * h.inserts
        h.check_invariant()


def test_random_sequences() -> None:
    rng = random.Random(20001101)
    for case in range(600):
        t = rng.choice([0, 1, 2, 3, 6])
        h: SoftHeap[int] = SoftHeap(t=t)
        present: Counter[int] = Counter()
        for _ in range(rng.randint(1, 150)):
            if rng.random() < 0.6 or not len(h):
                k = rng.randint(0, 300)
                h.insert(k)
                present[k] += 1
            elif rng.random() < 0.1:
                other: SoftHeap[int] = SoftHeap(t=t)
                for _ in range(rng.randint(0, 20)):
                    k = rng.randint(0, 300)
                    other.insert(k)
                    present[k] += 1
                h.meld(other)
            else:
                cur = min(v.ckey for v in h.nodes() if v.items)
                assert h.find_min()[1] == cur
                e, c = h.delete_min()
                assert c == cur and e <= c and present[e] > 0
                present[e] -= 1
            if case % 5 == 0:
                h.check_invariant()
        h.check_invariant()
        rest = [h.delete_min() for _ in range(len(h))]
        assert [c for _, c in rest] == sorted(c for _, c in rest)
        assert Counter(e for e, _ in rest) == +present

What the next level needs

  • the tests did not run in this build

Read the full page about Soft heap