Algorithm Search⌘K

Relaxed heap in Python

Heap with worst-case bounds matching Fibonacci heaps for decrease-key and delete.

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

from typing import Any, Dict, Generic, Iterator, List, Optional, Protocol, TypeVar


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


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


class Node(Generic[K]):
    """A node of a relaxed binomial tree.

    children[i] is the child of rank i, so the rank is len(children) and the
    last child is children[-1]. An active node may have a smaller key than
    its parent.
    """

    __slots__ = ("key", "parent", "children", "active")

    def __init__(self, key: K) -> None:
        self.key = key
        self.parent: Optional[Node[K]] = None
        self.children: List[Node[K]] = []
        self.active = False

    @property
    def rank(self) -> int:
        return len(self.children)


class RelaxedHeap(Generic[K]):
    """Rank-relaxed heap: binomial trees in which a few nodes may violate heap order.

    Invariants when no operation runs: (a) at most one active node of each
    rank; (b) every active node is the last child of its parent; (c) every
    other non-root node has a key at least the key of its parent.
    """

    def __init__(self) -> None:
        self.roots: Dict[int, Node[K]] = {}
        self.actives: Dict[int, List[Node[K]]] = {}
        self.n = 0
        self.transformations = 0
        self._work: List[Node[K]] = []

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

    # -- marks -------------------------------------------------------------

    def _mark(self, v: Node[K]) -> None:
        if not v.active:
            v.active = True
            self.actives.setdefault(v.rank, []).append(v)

    def _unmark(self, v: Node[K]) -> None:
        if v.active:
            v.active = False
            lst = self.actives[v.rank]
            lst.remove(v)
            if not lst:
                del self.actives[v.rank]

    def _set_rank_change(self, v: Node[K], f: Any) -> None:
        """Run f, which changes the rank of v, and keep the active lists right."""
        was = v.active
        self._unmark(v)
        f()
        if was:
            self._mark(v)

    # -- tree primitives ---------------------------------------------------

    def _place(self, new: Node[K], parent: Optional[Node[K]], slot: int) -> None:
        """Put new in the slot of rank slot under parent, or in the root list."""
        new.parent = parent
        if parent is None:
            self._unmark(new)
            self.roots[slot] = new
        else:
            parent.children[slot] = new
        self._work.append(new)

    def _link(self, a: Node[K], b: Node[K]) -> Node[K]:
        """Link two loose trees of equal rank and return the new root."""
        if b.key < a.key:
            a, b = b, a
        self._unmark(b)
        if a.children:
            self._work.append(a.children[-1])
        self._set_rank_change(a, lambda: a.children.append(b))
        b.parent = a
        return a

    def _add_root(self, t: Node[K]) -> None:
        t.parent = None
        self._unmark(t)
        while t.rank in self.roots:
            u = self.roots.pop(t.rank)
            t = self._link(t, u)
        self.roots[t.rank] = t

    # -- transformations ---------------------------------------------------

    def _parent_transformation(self, s: Node[K]) -> None:
        """s is the last child of p. Link s and p in the place of p."""
        self.transformations += 1
        p = s.parent
        assert p is not None and p.children[-1] is s
        self._set_rank_change(p, lambda: p.children.pop())
        s.parent = None
        self._work.extend(p.children[-1:])
        if s.key < p.key:
            g, slot = p.parent, p.rank + 1
            self._unmark(p)
            self._unmark(s)
            if s.children:
                self._work.append(s.children[-1])
            s.children.append(p)
            p.parent = s
            self._place(s, g, slot)
        else:
            self._unmark(s)
            self._set_rank_change(p, lambda: p.children.append(s))
            s.parent = p

    def _pair(self, x: Node[K], y: Node[K]) -> None:
        """x and y are active last children of rank r of different parents."""
        self.transformations += 1
        p, q = x.parent, y.parent
        assert p is not None and q is not None and p is not q
        self._unmark(x)
        self._unmark(y)
        self._set_rank_change(p, lambda: p.children.pop())
        self._set_rank_change(q, lambda: q.children.pop())
        x.parent = y.parent = None
        self._work.extend(p.children[-1:] + q.children[-1:])
        if q.key < p.key:
            p, q, x, y = q, p, y, x
        g, slot = q.parent, q.rank + 1
        self._unmark(q)
        if p.children:
            self._work.append(p.children[-1])
        self._set_rank_change(p, lambda: p.children.append(q))
        q.parent = p
        w = self._link(x, y)
        self._place(w, g, slot)

    def _triple(self, v: Node[K], s: Node[K], c: Node[K]) -> None:
        """v (slot r of p) and c (last child of s, slot r + 1 of p) are active."""
        self.transformations += 1
        p = v.parent
        assert p is not None
        r = v.rank
        self._unmark(v)
        self._unmark(c)
        self._set_rank_change(s, lambda: s.children.pop())
        c.parent = v.parent = None
        p.children[r] = s
        self._work.append(s)
        if s.children:
            self._work.append(s.children[-1])
        w = self._link(v, c)
        self._place(w, p, r + 1)

    def _fix(self) -> None:
        """Restore the invariants for the nodes in the work list."""
        while self._work:
            v = self._work.pop()
            p = v.parent
            if p is None or not v.key < p.key:  # @line L5
                self._unmark(v)
                continue
            self._mark(v)  # @line L6
            r = v.rank
            if p.children[-1] is not v:  # @line L7
                s = p.children[r + 1]
                if s.key < p.key:  # @line L8
                    if p.children[-1] is not s:
                        self._work.extend([v, s])
                        continue
                    self._parent_transformation(s)
                if p.children[-1] is not v:
                    s = p.children[r + 1]
                    c = s.children[-1]
                    if c.active or c.key < s.key:  # @line L9
                        self._triple(v, s, c)
                        continue
                    self.transformations += 1  # @line L10
                    p.children[r], s.children[r] = c, v
                    c.parent, v.parent = p, s
                    self._work.extend([c, v])
                    continue
            others = [y for y in self.actives.get(r, []) if y is not v]
            if others:  # @line L11
                y = others[0]
                yp = y.parent
                if yp is None or yp.children[-1] is not y or not y.key < yp.key:
                    self._work.extend([v, y])
                    continue
                self._pair(v, y)

    # -- operations --------------------------------------------------------

    def insert(self, key: K) -> Node[K]:
        """Insert key and return its node, the handle for decrease_key."""
        x = Node(key)  # @line L1
        self._add_root(x)
        self.n += 1
        self._fix()
        return x

    def _min_node(self) -> Node[K]:
        best: Optional[Node[K]] = None
        for v in list(self.roots.values()) + [a for lst in self.actives.values() for a in lst]:
            if best is None or v.key < best.key:
                best = v
        if best is None:
            raise IndexError("the heap is empty")
        return best

    def find_min(self) -> K:
        """Scan the roots and the active nodes."""
        return self._min_node().key  # @line L2

    def decrease_key(self, x: Node[K], key: K) -> None:
        """Lower the key of node x to key, which must not be larger."""
        if x.key < key:
            raise ValueError("new key is larger than the current key")
        x.key = key  # @line L3
        self._work.append(x)  # @line L4
        self._fix()

    def delete_min(self) -> K:
        """Remove and return a smallest key."""
        key = self._min_node().key
        m = self._min_node()  # @line L12
        extra: Optional[Node[K]] = None
        if m.parent is not None:  # @line L13
            k = min(self.roots)
            t = self.roots.pop(k)
            kids, t.children = t.children, []
            for c in kids:
                self._add_root(c)
            if m.parent is not None:
                self._replace_by_tree(m, t)  # @line L14
                m = None  # type: ignore[assignment]
            else:
                extra = t
        if m is not None:
            del self.roots[m.rank]  # @line L15
            self._unmark(m)
            kids, m.children = m.children, []
            for c in kids:
                self._add_root(c)
            if extra is not None:
                self._add_root(extra)
        self.n -= 1
        self._fix()
        return key

    def _replace_by_tree(self, m: Node[K], e: Node[K]) -> None:
        """Rebuild the subtree of m without m, from e and the children of m."""
        p, r = m.parent, m.rank
        assert p is not None
        self._unmark(m)
        kids, m.children = m.children, []
        u = e
        for c in kids:
            self._unmark(c)
            c.parent = None
            u = self._link(u, c)
        self._place(u, p, r)
        m.parent = None

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

    def nodes(self) -> Iterator[Node[K]]:
        stack = list(self.roots.values())
        while stack:
            v = stack.pop()
            yield v
            stack.extend(v.children)

    def check_invariant(self) -> None:
        """Check the binomial shape, the parents and the invariants (a) to (c)."""
        count = 0
        seen: Dict[int, int] = {}
        for k, t in self.roots.items():
            assert t.rank == k and t.parent is None and not t.active
        for v in self.nodes():
            count += 1
            for i, c in enumerate(v.children):
                assert c.parent is v and c.rank == i
            if v.parent is not None:
                if v.active:
                    assert v.parent.children[-1] is v
                    seen[v.rank] = seen.get(v.rank, 0) + 1
                else:
                    assert not v.key < v.parent.key
        assert all(c == 1 for c in seen.values())
        assert sum(len(lst) for lst in self.actives.values()) == sum(seen.values())
        for r, lst in self.actives.items():
            assert all(a.active and a.rank == r for a in lst)
        assert count == self.n
        assert not self._work
test_relaxed_heap.py 119 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/relaxed-heap/. The authors are listed in prov/KN6FTW.jsonl.

import heapq
import random
from typing import Dict, List

import pytest

from relaxed_heap import Node, RelaxedHeap


def show(x: Node[int]) -> str:
    """Write a tree as key(children in order of rank), with * for an active node."""
    s = f"{x.key}" + ("*" if x.active else "")
    return s if not x.children else s + "(" + ", ".join(show(c) for c in x.children) + ")"


def dump(h: RelaxedHeap[int]) -> str:
    return "  ".join(show(h.roots[k]) for k in sorted(h.roots))


def test_page_example() -> None:
    h: RelaxedHeap[int] = RelaxedHeap()
    nd = {k: h.insert(k) for k in range(10, 170, 10)}
    assert dump(h) == "10(20, 30(40), 50(60, 70(80)), 90(100, 110(120), 130(140, 150(160))))"
    trace = []
    for old, new in [(60, 5), (120, 15), (140, 25)]:
        t = h.transformations
        h.decrease_key(nd[old], new)
        h.check_invariant()
        trace.append((h.transformations - t, dump(h)))
    assert trace == [
        (1, "10(20, 30(40), 50(80, 70(5*)), 90(100, 110(120), 130(140, 150(160))))"),
        (2, "10(20, 30(40), 50(80, 70(110)), 90(100, 150(160), 130(140, 5*(15))))"),
        (1, "10(20, 30(40), 50(80, 70(110)), 90(100, 150(160), 5*(15, 130(25*))))"),
    ]
    assert h.find_min() == 5
    assert h.delete_min() == 5
    assert dump(h) == "20  30(40)  50(80, 70(110))  90(100, 150(160), 10*(15, 130(25*)))"
    h.check_invariant()
    assert [h.delete_min() for _ in range(len(h))] == [10, 15, 20, 25, 30, 40, 50, 70, 80, 90, 100, 110, 130, 150, 160]


def test_empty_and_errors() -> None:
    h: RelaxedHeap[int] = RelaxedHeap()
    with pytest.raises(IndexError):
        h.find_min()
    with pytest.raises(IndexError):
        h.delete_min()
    x = h.insert(4)
    with pytest.raises(ValueError):
        h.decrease_key(x, 5)
    h.decrease_key(x, 4)
    assert h.delete_min() == 4 and len(h) == 0
    h.check_invariant()


def test_duplicates() -> None:
    h: RelaxedHeap[int] = RelaxedHeap()
    xs = [h.insert(3) for _ in range(9)]
    for x in xs[::2]:
        h.decrease_key(x, 1)
        h.check_invariant()
    assert [h.delete_min() for _ in range(9)] == [1] * 5 + [3] * 4


def test_many_new_minima() -> None:
    rng = random.Random(7)
    h: RelaxedHeap[int] = RelaxedHeap()
    nodes = [h.insert(rng.randint(0, 10**6)) for _ in range(1024)]
    low = 0
    for _ in range(3000):
        low -= 1
        h.decrease_key(rng.choice(nodes), low)
        assert sum(len(a) for a in h.actives.values()) <= 11
    h.check_invariant()
    out = [h.delete_min() for _ in range(len(h))]
    assert out == sorted(out)


def test_random_against_heapq() -> None:
    rng = random.Random(19881101)
    for case in range(600):
        h: RelaxedHeap[int] = RelaxedHeap()
        ref: List[tuple[int, int]] = []
        handles: Dict[int, Node[int]] = {}
        keys: Dict[int, int] = {}
        nxt = 0
        scale = rng.choice([1, 10, 1000])
        for _ in range(rng.randint(1, 120)):
            op = rng.random()
            if op < 0.45 or not keys:
                k = rng.randint(0, 1000)
                handles[nxt] = h.insert(k)
                keys[nxt] = k
                heapq.heappush(ref, (k, nxt))
                nxt += 1
            elif op < 0.8:
                i = rng.choice(list(keys))
                k = keys[i] - rng.randint(0, 30) * scale
                h.decrease_key(handles[i], k)
                keys[i] = k
                heapq.heappush(ref, (k, i))
            else:
                while ref[0][0] != keys.get(ref[0][1]):
                    heapq.heappop(ref)
                m = ref[0][0]
                assert h.find_min() == m
                assert h.delete_min() == m
                alive = {id(v) for v in h.nodes()}
                gone = [i for i in keys if id(handles[i]) not in alive]
                assert len(gone) == 1 and keys[gone[0]] == m
                del keys[gone[0]]
            if case % 10 == 0:
                h.check_invariant()
        h.check_invariant()
        out = [h.delete_min() for _ in range(len(h))]
        assert out == sorted(keys.values())

What the next level needs

  • the tests did not run in this build

Read the full page about Relaxed heap