External memory priority queue in Python
Priority queue that supports insert and delete-min with amortized O((1/B) log_{M/B}(N/B)) I/Os per operation.
- File
external_memory_priority_queue.py, 259 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/external-memory-priority-queue/. The authors are listed in prov/Z1W9D1.jsonl.
from bisect import bisect_right, insort
from math import ceil
from typing import Any, Generic, Optional, Protocol, TypeVar, Union
class Ordered(Protocol):
def __lt__(self, other: Any, /) -> bool: ...
K = TypeVar("K", bound=Ordered)
Item = tuple[Any, int] # (key, insertion number): makes equal keys distinct
class Leaf:
"""A leaf block: at most B items in increasing order."""
def __init__(self, items: list[Item]) -> None:
self.items = items
class Node:
"""An internal node of the buffer tree, with routing items, children and
a buffer of pending inserts. A bottom node has leaves as children."""
def __init__(self, bottom: bool, parent: Optional["Node"] = None) -> None:
self.bottom = bottom
self.parent = parent
self.routing: list[Item] = []
self.children: list[Union["Node", Leaf]] = []
self.buf: list[Item] = []
class ExternalMemoryPriorityQueue(Generic[K]):
"""Arge's priority queue on a buffer tree.
The smallest items are kept in memory in `mem`; every item in `mem` is
smaller than every item in the tree. The tree is a buffer tree with
fan-out at most m, leaves of B items and buffers of M = m * B inserts.
When `mem` runs empty, the buffers on the leftmost path are emptied and
the leaves of the leftmost bottom node, up to M items, move to memory.
N operations cost O((N/B) log_m (N/B)) I/Os; `ios` counts transfers.
"""
def __init__(self, block_size: int = 4, fanout: int = 4) -> None:
if block_size < 1 or fanout < 4:
raise ValueError("need block_size >= 1 and fanout >= 4")
self.B = block_size
self.m = fanout
self.M = fanout * block_size
self.root = self._new_root()
self.mem: list[Item] = []
self.count = 0
self.size = 0
self.ios = 0
@staticmethod
def _new_root() -> Node:
r = Node(bottom=True)
r.children = [Leaf([])]
return r
def __len__(self) -> int:
return self.size
# operations ---------------------------------------------------------
def insert(self, k: K) -> None:
"""Insert key k; equal keys come out in insertion order."""
item: Item = (k, self.count)
self.count += 1
self.size += 1
if self.mem and item < self.mem[-1]: # @line L1
item, self.mem[-1] = self.mem[-1], item # @line L2
self.mem.sort()
self._tree_insert(item) # @line L3
def delete_min(self) -> K:
"""Remove and return a smallest key."""
if not self.mem: # @line L4
self._refill()
if not self.mem: # @line L5
raise IndexError("delete_min from an empty priority queue")
self.size -= 1
return self.mem.pop(0)[0] # @line L6
def find_min(self) -> K:
"""Return a smallest key without removing it."""
if not self.mem:
self._refill()
if not self.mem:
raise IndexError("find_min of an empty priority queue")
return self.mem[0][0]
def _refill(self) -> None:
while True: # @line L7
x = self.root
while not x.buf and not x.bottom:
c = x.children[0]
assert isinstance(c, Node)
x = c
if not x.buf:
break
self._empty(x)
x = self.root # @line L8
while not x.bottom:
c = x.children[0]
assert isinstance(c, Node)
x = c
for leaf in x.children: # @line L9
assert isinstance(leaf, Leaf)
self.mem.extend(leaf.items)
self.ios += len(x.children)
self._remove(x) # @line L10
while not self.root.bottom and len(self.root.children) == 1: # @line L11
c = self.root.children[0]
assert isinstance(c, Node)
c.parent = None
self.root = c
def _remove(self, x: Node) -> None:
p = x.parent
if p is None:
self.root = self._new_root()
return
j = p.children.index(x)
del p.children[j]
if p.routing:
del p.routing[max(j - 1, 0)]
if not p.children:
self._remove(p)
# the buffer tree, inserts only ---------------------------------------
def _tree_insert(self, item: Item) -> None:
self.root.buf.append(item)
if len(self.root.buf) % self.B == 0:
self.ios += 1
if len(self.root.buf) > self.M:
self._empty(self.root)
def _empty(self, x: Node) -> None:
items, x.buf = x.buf, []
self.ios += ceil(len(items) / self.B)
if x.bottom:
self._empty_bottom(x, items)
return
counts = [0] * len(x.children)
for it in items:
i = bisect_right(x.routing, it)
child = x.children[i]
assert isinstance(child, Node)
child.buf.append(it)
counts[i] += 1
self.ios += sum(ceil(c / self.B) for c in counts)
full = [c for c in x.children if isinstance(c, Node) and len(c.buf) > self.M]
for c in full:
self._empty(c)
def _empty_bottom(self, x: Node, items: list[Item]) -> None:
old: list[Item] = []
for leaf in x.children:
assert isinstance(leaf, Leaf)
old.extend(leaf.items)
self.ios += len(x.children)
allitems = sorted(old + items)
leaves: list[Union[Node, Leaf]] = [
Leaf(allitems[i : i + self.B]) for i in range(0, len(allitems), self.B)
] or [Leaf([])]
self.ios += len(leaves)
x.children = leaves
x.routing = [lf.items[0] for lf in leaves[1:] if isinstance(lf, Leaf)]
if len(x.children) > self.m:
self._split(x)
def _split(self, x: Node) -> None:
assert not x.buf
c = len(x.children)
g = ceil(c / self.m)
q, r = divmod(c, g)
sizes = [q + (1 if t < r else 0) for t in range(g)]
kids, routes = x.children, x.routing
nodes = [x] + [Node(x.bottom, x.parent) for _ in range(g - 1)]
seps: list[Item] = []
pos = 0
for t, y in enumerate(nodes):
y.children = kids[pos : pos + sizes[t]]
y.routing = routes[pos : pos + sizes[t] - 1]
if t > 0:
seps.append(routes[pos - 1])
for ch in y.children:
if isinstance(ch, Node):
ch.parent = y
pos += sizes[t]
self.ios += g
p = x.parent
if p is None:
p = Node(bottom=False)
p.children = [x]
self.root = p
for y in nodes:
y.parent = p
j = p.children.index(x)
p.children[j + 1 : j + 1] = nodes[1:]
p.routing[j:j] = seps
if len(p.children) > self.m:
self._split(p)
# checking -----------------------------------------------------------
def tree_items(self) -> list[Item]:
"""All items in the tree, in leaves or buffers, in no fixed order."""
out: list[Item] = []
def walk(x: Union[Node, Leaf]) -> None:
if isinstance(x, Leaf):
out.extend(x.items)
else:
out.extend(x.buf)
for c in x.children:
walk(c)
walk(self.root)
return out
def check_invariant(self) -> None:
"""Raise AssertionError if a property of the queue fails."""
assert self.mem == sorted(self.mem)
rest = self.tree_items()
assert len(rest) + len(self.mem) == self.size
if self.mem and rest:
assert self.mem[-1] < min(rest)
depths: set[int] = set()
def walk(x: Node, lo: Any, hi: Any, depth: int) -> None:
assert len(x.children) == len(x.routing) + 1 <= self.m
assert all(a < b for a, b in zip(x.routing, x.routing[1:]))
assert len(x.buf) <= self.M
for it in x.buf:
assert (lo is None or not (it < lo)) and (hi is None or it < hi)
bounds = [lo, *x.routing, hi]
for j, c in enumerate(x.children):
if x.bottom:
assert isinstance(c, Leaf) and len(c.items) <= self.B
assert c.items == sorted(c.items)
for it in c.items:
assert bounds[j] is None or not (it < bounds[j])
assert bounds[j + 1] is None or it < bounds[j + 1]
depths.add(depth)
else:
assert isinstance(c, Node) and c.parent is x
walk(c, bounds[j], bounds[j + 1], depth + 1)
assert self.root.parent is None
walk(self.root, None, None, 0)
assert len(depths) == 1
test_external_memory_priority_queue.py 101 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/external-memory-priority-queue/. The authors are listed in prov/Z1W9D1.jsonl.
import heapq
import math
import random
import pytest
from external_memory_priority_queue import ExternalMemoryPriorityQueue, Leaf, Node
def shape(x: "Node | Leaf") -> str:
if isinstance(x, Leaf):
return "[" + " ".join(str(i[0]) for i in x.items) + "]"
inner = " ".join(shape(c) for c in x.children)
return "<" + " ".join(str(r[0]) for r in x.routing) + "|" + str(len(x.buf)) + ">(" + inner + ")"
def mem(q: ExternalMemoryPriorityQueue[int]) -> list[int]:
return [i[0] for i in q.mem]
def test_example() -> None:
q: ExternalMemoryPriorityQueue[int] = ExternalMemoryPriorityQueue(block_size=2, fanout=4)
for k in [50, 20, 80, 10, 60, 30, 70, 40, 90]:
q.insert(k)
assert shape(q.root) == "<70|0>(<30 50|0>([10 20] [30 40] [50 60]) <90|0>([70 80] [90]))"
for k in [15, 25, 35, 45, 55, 65, 75, 85, 5]:
q.insert(k)
assert shape(q.root) == "<70|0>(<30 50|7>([10 20] [30 40] [50 60]) <90|2>([70 80] [90]))"
q.check_invariant()
assert q.delete_min() == 5
assert mem(q) == [10, 15, 20, 25, 30, 35, 40]
assert shape(q.root) == "<70|0>(<55 65|0>([45 50] [55 60] [65]) <90|2>([70 80] [90]))"
assert q.delete_min() == 10
assert q.delete_min() == 15
q.insert(12)
assert mem(q) == [12, 20, 25, 30, 35]
q.insert(1)
assert mem(q) == [1, 12, 20, 25, 30]
assert len(q.root.buf) == 2
q.check_invariant()
out = [q.delete_min() for _ in range(len(q))]
assert out == [1, 12, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]
assert q.ios == 75
q.check_invariant()
def test_edges() -> None:
with pytest.raises(ValueError):
ExternalMemoryPriorityQueue(0, 4)
q: ExternalMemoryPriorityQueue[int] = ExternalMemoryPriorityQueue(1, 4)
with pytest.raises(IndexError):
q.delete_min()
with pytest.raises(IndexError):
q.find_min()
for _ in range(10):
q.insert(7)
q.insert(3)
assert q.find_min() == 3
assert [q.delete_min() for _ in range(11)] == [3] + [7] * 10
assert len(q) == 0
q.check_invariant()
def test_against_heapq() -> None:
rng = random.Random(5)
for _ in range(500):
B = rng.randrange(1, 4)
m = rng.randrange(4, 7)
q: ExternalMemoryPriorityQueue[int] = ExternalMemoryPriorityQueue(B, m)
h: list[int] = []
for i in range(rng.randrange(1, 300)):
if h and rng.random() < 0.4:
assert q.delete_min() == heapq.heappop(h)
else:
k = rng.randrange(60)
q.insert(k)
heapq.heappush(h, k)
assert len(q) == len(h)
if i % 29 == 0:
q.check_invariant()
q.check_invariant()
assert [q.delete_min() for _ in range(len(h))] == sorted(h)
def test_io_bound() -> None:
B, m = 16, 8
q: ExternalMemoryPriorityQueue[int] = ExternalMemoryPriorityQueue(B, m)
rng = random.Random(11)
N = 30000
keys = [rng.randrange(10**9) for _ in range(N)]
for k in keys:
q.insert(k)
assert [q.delete_min() for _ in range(N)] == sorted(keys)
n = N / B
bound = n * math.log(n, m)
# 2N operations; a binary heap on disk would pay about one I/O per level
assert q.ios <= 8 * bound
assert q.ios < N
What the next level needs
- the tests did not run in this build