Banker's queue in Python
A persistent queue of a lazy front stream and a rear list, rebuilt lazily when the rear outgrows the front, with O(1) amortized operations that stay valid under persistent reuse.
- File
banker_queue.py, 168 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/banker-queue/. The authors are listed in prov/WBJ0ZE.jsonl.
from collections.abc import Callable, Iterator
from typing import Generic, Optional, TypeVar
T = TypeVar("T")
# A stream cell is None (the empty stream) or a pair (item, rest), where rest is a Susp.
Cell = Optional[tuple[T, "Susp[T]"]]
class Susp(Generic[T]):
"""A suspension: a computation of a stream cell that runs at most once.
The first force runs the computation and stores the cell; later forces
return the stored cell in O(1) time. This memoization is what makes the
amortized bounds of the queue hold when old versions are reused.
"""
__slots__ = ("_thunk", "_cell", "_done")
def __init__(self, thunk: Callable[[], Cell[T]]) -> None:
self._thunk: Callable[[], Cell[T]] | None = thunk
self._cell: Cell[T] = None
self._done = False
@staticmethod
def value(cell: Cell[T]) -> "Susp[T]":
"""A suspension that is already evaluated."""
s: Susp[T] = Susp(lambda: cell)
s.force()
return s
def force(self) -> Cell[T]:
if not self._done:
assert self._thunk is not None
self._cell = self._thunk()
self._done = True
self._thunk = None
return self._cell
@property
def evaluated(self) -> bool:
return self._done
EMPTY_STREAM: Susp = Susp.value(None)
def append(f: Susp[T], g: Susp[T]) -> Susp[T]:
"""The lazy concatenation f ++ g: each cell is computed only when forced."""
def step() -> Cell[T]:
cell = f.force()
if cell is None:
return g.force()
x, rest = cell
return (x, append(rest, g))
return Susp(step)
# The rear list is an immutable linked list: None, or a pair (item, rest), newest item first.
RList = Optional[tuple[T, "RList[T]"]]
def reverse(r: "RList[T]") -> Susp[T]:
"""A suspension of the reverse of the rear list r (newest item first).
The whole reversal runs at the first force, in O(len(r)) time: it is a
monolithic suspension. The cells it builds are already evaluated.
"""
def run() -> Cell[T]:
s: Susp[T] = EMPTY_STREAM
node = r
while node is not None: # newest first, so the oldest item ends up in front
x, node = node
s = Susp.value((x, s))
return s.force()
return Susp(run)
class BankerQueue(Generic[T]):
"""Okasaki's banker's queue: a persistent FIFO queue.
The fields are (lf, F, lr, R): a lazy stream F of length lf (the front, in
queue order) and a rear list R of length lr (newest item first), with the
invariant lr <= lf. snoc, head and tail take O(1) amortized time, also when
any old version is reused, because the rebuild F ++ reverse(R) is a shared
memoized suspension. One operation can take Theta(n) time. Every method
returns a new queue and leaves self unchanged.
"""
__slots__ = ("_lf", "_f", "_lr", "_r")
def __init__(self) -> None:
self._lf = 0 # @line L1
self._f: Susp[T] = EMPTY_STREAM
self._lr = 0
self._r: RList[T] = None
@classmethod
def _make(cls, lf: int, f: Susp[T], lr: int, r: RList[T]) -> "BankerQueue[T]":
q: BankerQueue[T] = cls.__new__(cls)
q._lf, q._f, q._lr, q._r = lf, f, lr, r
return q
@classmethod
def _check(cls, lf: int, f: Susp[T], lr: int, r: RList[T]) -> "BankerQueue[T]":
if lr <= lf: # @line L8
return cls._make(lf, f, lr, r)
return cls._make(lf + lr, append(f, reverse(r)), 0, None) # @line L9
def is_empty(self) -> bool:
return self._lf == 0
def __len__(self) -> int:
return self._lf + self._lr
def snoc(self, x: T) -> "BankerQueue[T]":
"""Return the queue with x added at the rear."""
lf, f, lr, r = self._lf, self._f, self._lr, self._r # @line L2
return self._check(lf, f, lr + 1, (x, r)) # @line L3
def head(self) -> T:
"""Return the front item. Raises IndexError on an empty queue."""
if self._lf == 0: # @line L4
raise IndexError("head of an empty queue")
cell = self._f.force() # @line L5
assert cell is not None
return cell[0]
def tail(self) -> "BankerQueue[T]":
"""Return the queue without its front item. Raises IndexError on an empty queue."""
if self._lf == 0: # @line L6
raise IndexError("tail of an empty queue")
cell = self._f.force()
assert cell is not None
return self._check(self._lf - 1, cell[1], self._lr, self._r) # @line L7
def __iter__(self) -> Iterator[T]:
q = self
while not q.is_empty():
yield q.head()
q = q.tail()
def to_list(self) -> list[T]:
return list(self)
def lengths(self) -> tuple[int, int]:
"""The pair (lf, lr), for tests and for the trace of the page."""
return self._lf, self._lr
def check_invariant(self) -> None:
"""Check lr <= lf, that F has lf cells and that R has lr items. Forces F."""
assert 0 <= self._lr <= self._lf
k, node = 0, self._r
while node is not None:
k, node = k + 1, node[1]
assert k == self._lr
n, s = 0, self._f
while (cell := s.force()) is not None:
n += 1
s = cell[1]
assert n == self._lf
test_banker_queue.py 76 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/banker-queue/. The authors are listed in prov/WBJ0ZE.jsonl.
import random
import pytest
from banker_queue import BankerQueue
def test_example() -> None:
q0: BankerQueue[int] = BankerQueue()
q = q0
lengths = []
for x in [1, 2, 3, 4, 5]:
q = q.snoc(x)
q.check_invariant()
lengths.append(q.lengths())
assert lengths == [(1, 0), (1, 1), (3, 0), (3, 1), (3, 2)]
q5 = q
assert q5.head() == 1
q6 = q5.tail()
assert q6.lengths() == (2, 2)
q7 = q6.tail()
assert q7.lengths() == (3, 0)
assert q7.to_list() == [3, 4, 5]
# the old versions are unchanged
assert q5.to_list() == [1, 2, 3, 4, 5]
assert q6.to_list() == [2, 3, 4, 5]
assert q0.to_list() == []
def test_edges() -> None:
q: BankerQueue[str] = BankerQueue()
assert q.is_empty() and len(q) == 0
with pytest.raises(IndexError):
q.head()
with pytest.raises(IndexError):
q.tail()
one = q.snoc("a")
assert one.head() == "a" and one.tail().is_empty()
same = q.snoc(7).snoc(7).snoc(7)
assert same.to_list() == [7, 7, 7]
def test_persistent_reuse() -> None:
q: BankerQueue[int] = BankerQueue()
for x in range(7):
q = q.snoc(x)
# q has lf = lr = ... ; branch it in many ways
a = q.snoc(100)
b = q.snoc(200)
c = q.tail()
assert a.to_list() == list(range(7)) + [100]
assert b.to_list() == list(range(7)) + [200]
assert c.to_list() == list(range(1, 7))
assert q.to_list() == list(range(7))
def test_random_against_list() -> None:
rng = random.Random(20261104)
for _ in range(500):
versions: list[tuple[BankerQueue[int], list[int]]] = [(BankerQueue(), [])]
for _ in range(rng.randrange(1, 40)):
q, model = versions[rng.randrange(len(versions))] # any old version
if model and rng.random() < 0.4:
assert q.head() == model[0]
q2, m2 = q.tail(), model[1:]
else:
x = rng.randrange(1000)
q2, m2 = q.snoc(x), model + [x]
assert len(q2) == len(m2)
versions.append((q2, m2))
for q, model in versions:
q.check_invariant()
assert q.to_list() == model
What the next level needs
- the tests did not run in this build