Algorithm Search⌘K

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
external_memory_priority_queue.pyRawGitHub
# 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

Read the full page about External memory priority queue