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
# 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