Algorithm Search⌘K

Kinetic heap in Python

Heap over moving keys that maintains the minimum as keys change continuously over time.

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

"""Kinetic binary min-heap over items whose keys are linear functions of time.

The key of item e at time t is a + b * t. All times are exact fractions. Each
parent-child edge has a certificate "parent <= child" with a failure time, and
the event queue holds the certificates in order of failure time.
"""

import heapq
from fractions import Fraction
from typing import Dict, Hashable, List, Optional, Tuple, Union

Num = Union[int, Fraction]
INF = None  # a certificate that never fails


class KineticHeap:
    """Binary min-heap kept in an array; keys move as a + b * t."""

    def __init__(self, now: Num = 0) -> None:
        self.now = Fraction(now)
        self.heap: List[Hashable] = []
        self.pos: Dict[Hashable, int] = {}
        self.line: Dict[Hashable, Tuple[Fraction, Fraction]] = {}
        self.queue: List[Tuple[Fraction, int, int]] = []  # (failure time, version, child index)
        self.cert: Dict[int, Tuple[Optional[Fraction], int]] = {}  # child index -> (time, version)
        self.version = 0
        self.events = 0

    def __len__(self) -> int:
        return len(self.heap)

    def value(self, e: Hashable, t: Optional[Fraction] = None) -> Fraction:
        a, b = self.line[e]
        return a + b * (self.now if t is None else t)

    def _order(self, e: Hashable) -> Tuple[Fraction, Fraction]:
        """The order just after now: value at now, then slope."""
        a, b = self.line[e]
        return (a + b * self.now, b)

    # -- certificates -------------------------------------------------------

    def _cert(self, i: int) -> None:
        """Compute the certificate of the edge from parent(i) to i."""
        if i <= 0 or i >= len(self.heap):
            self.cert.pop(i, None)
            return
        ap, bp = self.line[self.heap[(i - 1) // 2]]
        ac, bc = self.line[self.heap[i]]
        self.version += 1
        if bp > bc:  # @line L1
            fail: Optional[Fraction] = (ac - ap) / (bp - bc)
            assert fail is not None and fail >= self.now
            heapq.heappush(self.queue, (fail, self.version, i))  # @line L2
        else:
            fail = INF
        self.cert[i] = (fail, self.version)

    def _recert_around(self, i: int) -> None:
        """Recompute every certificate that involves position i."""
        for j in (i, 2 * i + 1, 2 * i + 2):
            self._cert(j)

    def _swap(self, i: int, j: int) -> None:
        h = self.heap
        h[i], h[j] = h[j], h[i]
        self.pos[h[i]], self.pos[h[j]] = i, j

    # -- kinetic operations -------------------------------------------------

    def _next(self) -> Optional[Tuple[Fraction, int]]:
        """The earliest certificate that is still current, or None."""
        while self.queue:
            t, ver, i = self.queue[0]
            c = self.cert.get(i)
            if c is not None and c[1] == ver:
                return t, i
            heapq.heappop(self.queue)
        return None

    def advance(self, t: Num) -> None:
        """Move time forward to t and process every event on the way."""
        t = Fraction(t)
        if t < self.now:
            raise ValueError("time cannot go back")
        while True:
            nxt = self._next()
            if nxt is None or nxt[0] > t:  # @line L3
                break
            tau, i = nxt
            heapq.heappop(self.queue)
            self.now = tau  # @line L4
            self.events += 1
            p = (i - 1) // 2
            self._swap(p, i)  # @line L5
            for j in (p, i, 2 * p + 1, 2 * p + 2, 2 * i + 1, 2 * i + 2):  # @line L6
                self._cert(j)
        self.now = t  # @line L7

    def find_min(self) -> Hashable:
        """Return the item of smallest key at the current time."""
        if not self.heap:
            raise IndexError("the heap is empty")
        return self.heap[0]  # @line L8

    def _sift(self, i: int, touched: List[int]) -> int:
        """Move the item at i up or down to its place in the order just after now."""
        h = self.heap
        while i > 0 and self._order(h[i]) < self._order(h[(i - 1) // 2]):
            p = (i - 1) // 2
            self._swap(i, p)
            touched.append(p)
            i = p
        while True:
            m = i
            for c in (2 * i + 1, 2 * i + 2):
                if c < len(h) and self._order(h[c]) < self._order(h[m]):
                    m = c
            if m == i:
                return i
            self._swap(i, m)
            touched.append(m)
            i = m

    def _recert_all(self, touched: List[int]) -> None:
        """Recompute the certificates of the edges at every touched position."""
        for j in set(touched):
            self._recert_around(j)

    def insert(self, e: Hashable, a: Num, b: Num) -> None:
        """Insert item e with the key a + b * t."""
        if e in self.pos:
            raise KeyError("the item is already in the heap")
        self.line[e] = (Fraction(a), Fraction(b))
        self.heap.append(e)  # @line L9
        self.pos[e] = len(self.heap) - 1
        touched = [len(self.heap) - 1]
        self._sift(len(self.heap) - 1, touched)
        self._recert_all(touched)  # @line L10

    def delete(self, e: Hashable) -> None:
        """Remove item e."""
        i = self.pos.pop(e)
        last = self.heap.pop()
        del self.line[e]
        n = len(self.heap)
        self.cert.pop(n, None)
        if i < n:
            self.heap[i] = last  # @line L11
            self.pos[last] = i
            touched = [i]
            self._sift(i, touched)
            self._recert_all(touched)  # @line L12

    def delete_min(self) -> Hashable:
        """Remove and return the item of smallest key now."""
        e = self.find_min()
        self.delete(e)
        return e

    def check_invariant(self) -> None:
        """Heap order just after now, and a current certificate for every edge."""
        h = self.heap
        assert len(self.pos) == len(h) == len(self.line)
        for i, e in enumerate(h):
            assert self.pos[e] == i
            if i > 0:
                assert not self._order(e) < self._order(h[(i - 1) // 2])
                ap, bp = self.line[h[(i - 1) // 2]]
                ac, bc = self.line[e]
                want = (ac - ap) / (bp - bc) if bp > bc else INF
                assert self.cert[i][0] == want
                if want is not None:
                    assert want > self.now
        assert set(self.cert) == set(range(1, len(h)))
test_kinetic_heap.py 123 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/kinetic-heap/. The authors are listed in prov/NF6DCE.jsonl.

import random
from fractions import Fraction

import pytest

from kinetic_heap import KineticHeap


def depth_sum(n: int) -> int:
    return sum((i + 1).bit_length() - 1 for i in range(n))


def test_page_example() -> None:
    h = KineticHeap(now=0)
    lines = {"A": (0, 3), "B": (1, 1), "C": (2, 0), "D": (5, -1), "E": (4, -2)}
    for e, (a, b) in lines.items():
        h.insert(e, a, b)
    assert h.heap == ["A", "B", "C", "D", "E"]
    assert {i: h.cert[i][0] for i in h.cert} == {
        1: Fraction(1, 2), 2: Fraction(2, 3), 3: Fraction(2), 4: Fraction(1)}
    trace = []
    while True:
        nxt = h._next()
        if nxt is None:
            break
        h.advance(nxt[0])
        h.check_invariant()
        trace.append((nxt[0], "".join(map(str, h.heap)), h.find_min()))
    assert trace == [
        (Fraction(1, 2), "BACDE", "B"),
        (Fraction(4, 5), "BECDA", "B"),
        (Fraction(1), "ECBDA", "E"),
        (Fraction(3), "EDBCA", "E"),
    ]
    assert h.events == 5 < 6 == depth_sum(5)
    h.advance(100)
    assert h.events == 5 and h.find_min() == "E"


def test_tangent_lines_meet_the_bound() -> None:
    for n in [1, 2, 7, 15, 40]:
        h = KineticHeap(now=-10**6)
        for s in range(n):
            h.insert(s, s * s, -2 * s)
        h.advance(10**6)
        assert h.events == depth_sum(n)
        h.check_invariant()


def test_parallel_and_equal_lines() -> None:
    h = KineticHeap()
    for e in range(10):
        h.insert(e, 3, 1)
    h.advance(50)
    assert h.events == 0 and h.value(h.find_min()) == 53
    h.insert("x", 60, -1)
    h.advance(Fraction(103, 2))
    h.check_invariant()
    assert h.find_min() == "x" and h.value("x") == Fraction(17, 2) and h.events == 0


def test_errors() -> None:
    h = KineticHeap()
    with pytest.raises(IndexError):
        h.find_min()
    h.insert(1, 0, 0)
    with pytest.raises(KeyError):
        h.insert(1, 0, 0)
    h.advance(3)
    with pytest.raises(ValueError):
        h.advance(2)
    assert h.delete_min() == 1 and len(h) == 0


def test_random_against_brute_force() -> None:
    rng = random.Random(1999)
    for case in range(600):
        h = KineticHeap(now=rng.randint(-5, 5))
        alive: dict[int, tuple[int, int]] = {}
        nxt = 0
        for _ in range(rng.randint(1, 60)):
            op = rng.random()
            if op < 0.4 or not alive:
                a, b = rng.randint(-20, 20), rng.randint(-4, 4)
                h.insert(nxt, a + b * h.now, b)
                alive[nxt] = (a, b)
                nxt += 1
            elif op < 0.55:
                e = rng.choice(list(alive))
                h.delete(e)
                del alive[e]
            elif op < 0.65:
                m = min(h.value(x) for x in h.heap)
                e = h.find_min()
                assert h.value(e) == m and h.delete_min() == e
                del alive[e]
            else:
                start = h.events
                bound = sum(
                    1
                    for i in range(1, len(h.heap))
                    for j in _ancestors(i)
                    if h.line[h.heap[i]][1] < h.line[h.heap[j]][1]
                )
                h.advance(h.now + Fraction(rng.randint(0, 12), rng.randint(1, 3)))
                assert h.events - start <= bound
            if h.heap:
                m = min(h.value(x) for x in h.heap)
                assert h.value(h.find_min()) == m
            if case % 4 == 0:
                h.check_invariant()
        h.check_invariant()


def _ancestors(i: int) -> list[int]:
    out = []
    while i > 0:
        i = (i - 1) // 2
        out.append(i)
    return out

What the next level needs

  • the tests did not run in this build

Read the full page about Kinetic heap