Algorithm Search⌘K

Calendar queue in Python

Bucket-based queue for discrete-event simulation that expects near-uniform event times.

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

import bisect
import heapq
import math
from typing import Generic, Optional, TypeVar

E = TypeVar("E")


class CalendarQueue(Generic[E]):
    """Brown's calendar queue: a min-priority queue of events with real times.

    There are N buckets of width w. An event with time t has the virtual
    bucket vb(t) = floor(t / w) and lives in the bucket vb(t) mod N, which is
    kept sorted by (time, arrival). V is a virtual bucket with vb(t) >= V for
    every event in the queue. extract_min scans at most one year of N
    buckets from V, then falls back to a direct search. With resize=True,
    N doubles when the size exceeds 2N and halves when it falls below N/2,
    and w is estimated from the gaps between the first events.
    """

    def __init__(self, nbuckets: int = 2, width: float = 1.0, resize: bool = True) -> None:
        if nbuckets < 1 or not width > 0:
            raise ValueError("nbuckets must be at least 1 and width positive")
        self.n_buckets = nbuckets
        self.width = width
        self.resize_on = resize
        self.buckets: list[list[tuple[float, int, E]]] = [[] for _ in range(nbuckets)]
        self.size = 0
        self.v = 0
        self.seq = 0
        self.resizes = 0

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

    def _vb(self, t: float) -> int:
        return math.floor(t / self.width)

    def insert(self, e: E, t: float) -> None:
        vb = self._vb(t)
        i = vb % self.n_buckets  # @line L1
        bisect.insort(self.buckets[i], (t, self.seq, e))  # @line L2
        self.seq += 1
        if self.size == 0 or vb < self.v:  # @line L3
            self.v = vb
        self.size += 1
        if self.resize_on and self.size > 2 * self.n_buckets:  # @line L4
            self._resize(2 * self.n_buckets)

    def _take(self, i: int) -> tuple[E, float]:
        t, _, e = self.buckets[i].pop(0)
        self.size -= 1
        if self.resize_on and self.n_buckets > 2 and self.size < self.n_buckets // 2:
            self._resize(self.n_buckets // 2)
        return e, t

    def peek_min(self) -> tuple[E, float]:
        if self.size == 0:
            raise IndexError("peek_min from an empty queue")
        best = min(b[0] for b in self.buckets if b)
        return best[2], best[0]

    def extract_min(self) -> tuple[E, float]:
        if self.size == 0:  # @line L5
            raise IndexError("extract_min from an empty queue")
        n = self.n_buckets
        for _ in range(n):  # @line L6
            b = self.buckets[self.v % n]
            if b and self._vb(b[0][0]) == self.v:  # @line L7
                return self._take(self.v % n)  # @line L8 @line L9
            self.v += 1  # @line L10
        best: Optional[tuple[float, int, E]] = None
        best_i = -1
        for i, b in enumerate(self.buckets):  # @line L11
            if b and (best is None or b[0] < best):
                best, best_i = b[0], i
        assert best is not None
        self.v = self._vb(best[0])  # @line L12
        return self._take(best_i)

    def _new_width(self, events: list[tuple[float, int, E]]) -> float:
        front = heapq.nsmallest(25, events)  # @line L14
        gaps = [b[0] - a[0] for a, b in zip(front, front[1:])]
        if not gaps:
            return self.width
        avg = sum(gaps) / len(gaps)
        kept = [g for g in gaps if g <= 2 * avg]
        w = 3 * sum(kept) / len(kept) if kept else 0.0  # @line L15
        return w if w > 0 else self.width

    def _resize(self, new_n: int) -> None:
        events = [x for b in self.buckets for x in b]  # @line L13
        self.width = self._new_width(events)
        self.n_buckets = new_n
        self.buckets = [[] for _ in range(new_n)]
        for x in events:
            self.buckets[self._vb(x[0]) % new_n].append(x)
        for b in self.buckets:
            b.sort()
        self.v = min((self._vb(x[0]) for x in events), default=0)
        self.resizes += 1

    def check_invariant(self) -> None:
        count = 0
        for i, b in enumerate(self.buckets):
            assert b == sorted(b)
            for t, _, _ in b:
                vb = self._vb(t)
                assert vb % self.n_buckets == i
                assert vb >= self.v
                count += 1
        assert count == self.size
test_calendar_queue.py 96 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/calendar-queue/. The authors are listed in prov/CZJZK8.jsonl.

import heapq
import random

import pytest

from calendar_queue import CalendarQueue


def test_example() -> None:
    q: CalendarQueue[str] = CalendarQueue(4, 1.0, resize=False)
    for e, t in [("a", 0.3), ("b", 2.6), ("c", 1.2), ("d", 5.1), ("e", 3.9), ("f", 2.2)]:
        q.insert(e, t)
    assert [[x[0] for x in b] for b in q.buckets] == [[0.3], [1.2, 5.1], [2.2, 2.6], [3.9]]
    q.check_invariant()
    out = [q.extract_min() for _ in range(5)]
    assert out == [("a", 0.3), ("c", 1.2), ("f", 2.2), ("b", 2.6), ("e", 3.9)]
    assert q.v == 3
    q.insert("g", 40.0)
    assert q.extract_min() == ("d", 5.1)
    assert q.v == 5
    assert q.extract_min() == ("g", 40.0)
    assert q.v == 40
    q.check_invariant()


def test_edges() -> None:
    with pytest.raises(ValueError):
        CalendarQueue(0)
    with pytest.raises(ValueError):
        CalendarQueue(2, 0.0)
    q: CalendarQueue[int] = CalendarQueue()
    with pytest.raises(IndexError):
        q.extract_min()
    with pytest.raises(IndexError):
        q.peek_min()
    q.insert(1, -3.5)
    q.insert(2, -3.5)
    assert q.peek_min() == (1, -3.5)
    assert q.extract_min() == (1, -3.5)
    assert q.extract_min() == (2, -3.5)
    assert len(q) == 0


def test_insert_in_the_past() -> None:
    q: CalendarQueue[int] = CalendarQueue(4, 1.0, resize=False)
    q.insert(0, 10.0)
    assert q.extract_min() == (0, 10.0)
    q.insert(1, 12.0)
    q.insert(2, 3.0)
    q.check_invariant()
    assert q.extract_min() == (2, 3.0)
    assert q.extract_min() == (1, 12.0)


def test_hold_model_resizes() -> None:
    rng = random.Random(8)
    q: CalendarQueue[int] = CalendarQueue()
    ref: list[tuple[float, int]] = []
    for i in range(300):
        t = rng.expovariate(1.0)
        q.insert(i, t)
        heapq.heappush(ref, (t, i))
    assert q.resizes > 0
    for i in range(300, 3000):
        e, t = q.extract_min()
        assert (t, e) == heapq.heappop(ref)
        t2 = t + rng.expovariate(1.0)
        q.insert(i, t2)
        heapq.heappush(ref, (t2, i))
    q.check_invariant()
    while ref:
        e, t = q.extract_min()
        assert (t, e) == heapq.heappop(ref)
    assert len(q) == 0


def test_random_against_model() -> None:
    rng = random.Random(4)
    for _ in range(500):
        q: CalendarQueue[int] = CalendarQueue(rng.randrange(1, 5), rng.choice([0.5, 1.0, 3.0]), resize=rng.random() < 0.7)
        ref: list[tuple[float, int]] = []
        nxt = 0
        for _ in range(rng.randrange(1, 60)):
            if rng.random() < 0.55 or not ref:
                t = rng.choice([rng.uniform(-5, 50), float(rng.randrange(10))])
                q.insert(nxt, t)
                heapq.heappush(ref, (t, nxt))
                nxt += 1
            else:
                e, t = q.extract_min()
                assert (t, e) == heapq.heappop(ref)
            q.check_invariant()
            assert len(q) == len(ref)

What the next level needs

  • the tests did not run in this build

Read the full page about Calendar queue