Algorithm Search⌘K

Packed memory array in Python

Sorted array with evenly spaced gaps that supports updates in O(log^2 N) amortized moves while staying scan-friendly.

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

from typing import Any, Generic, Iterator, Optional, Protocol, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: Any, /) -> bool: ...


K = TypeVar("K", bound=Ordered)

# density thresholds: upper from TAU_LEAF at the segments to TAU_ROOT at the
# whole array, lower from RHO_LEAF to RHO_ROOT
TAU_LEAF, TAU_ROOT = 1.0, 0.5
RHO_LEAF, RHO_ROOT = 1 / 16, 1 / 8
MIN_CAPACITY = 8


class PackedMemoryArray(Generic[K]):
    """A sorted array of distinct keys with gaps (None) spread among them.

    The array of capacity 2**r is cut into segments of S = Theta(log N)
    slots. A window is an aligned group of 2**i segments, a node at height i
    of an implicit binary tree over the segments. Every window at height i
    keeps its density between rho_i and tau_i, which move linearly from the
    loose leaf values to the tight root values. An update rebalances the
    smallest window around it that stays within its thresholds, which costs
    O(log^2 N) amortized moves. `moves` counts the elements written.
    """

    def __init__(self) -> None:
        self.n = 0
        self.moves = 0
        self._layout(MIN_CAPACITY)
        self.slots: list[Optional[K]] = [None] * self.cap

    def _layout(self, cap: int) -> None:
        self.cap = cap
        lg = cap.bit_length() - 1
        s = 1
        while 2 * s <= lg:
            s *= 2
        self.S = max(2, s)  # a power of two at most lg(cap)
        self.H = (cap // self.S).bit_length() - 1  # height of the segment tree

    def tau(self, i: int) -> float:
        if self.H == 0:
            return TAU_ROOT
        return TAU_LEAF - (TAU_LEAF - TAU_ROOT) * i / self.H

    def rho(self, i: int) -> float:
        if self.H == 0:
            return RHO_ROOT
        return RHO_LEAF + (RHO_ROOT - RHO_LEAF) * i / self.H

    # searching -----------------------------------------------------------

    def _pred_pos(self, k: K) -> int:
        """The position of the largest key <= k, or -1."""
        lo, hi = 0, self.cap  # keys before lo are <= k, keys from hi on are > k
        while lo < hi:
            mid = (lo + hi) // 2
            j = mid
            while j < hi and self.slots[j] is None:
                j += 1
            if j == hi:
                hi = mid
                continue
            x = self.slots[j]
            assert x is not None
            if not (k < x):
                lo = j + 1
            else:
                hi = mid
        p = lo - 1
        while p >= 0 and self.slots[p] is None:
            p -= 1
        return p

    def predecessor(self, k: K) -> Optional[K]:
        """The largest key <= k, or None."""
        p = self._pred_pos(k)
        return self.slots[p] if p >= 0 else None

    def __contains__(self, k: K) -> bool:
        x = self.predecessor(k)
        return x is not None and not (x < k)

    def range(self, lo: K, hi: K) -> list[K]:
        """The keys with lo <= key <= hi, by one scan of the array."""
        out: list[K] = []
        p = self._pred_pos(lo)
        start = p if p >= 0 and not (self.slots[p] < lo) else p + 1  # type: ignore[operator]
        for x in self.slots[max(start, 0) :]:
            if x is None:
                continue
            if hi < x:
                break
            out.append(x)
        return out

    # updates -------------------------------------------------------------

    def _window(self, pos: int, i: int) -> tuple[int, int]:
        size = self.S << i
        start = (pos // size) * size
        return start, start + size

    def _count(self, a: int, b: int) -> int:
        return sum(1 for x in self.slots[a:b] if x is not None)

    def insert(self, k: K) -> None:
        """Insert k; inserting a present key changes nothing."""
        p = self._pred_pos(k)  # @line L1
        if p >= 0 and not (self.slots[p] < k):  # type: ignore[operator]
            return
        here = max(p, 0)
        for i in range(self.H + 1):  # @line L2
            a, b = self._window(here, i)  # @line L3
            if (self._count(a, b) + 1) / (b - a) <= self.tau(i):  # @line L4
                self._rebalance(a, b, k)  # @line L5
                self.n += 1
                return
        self.n += 1
        self._resize(k)  # @line L6

    def delete(self, k: K) -> None:
        """Delete k; deleting an absent key changes nothing."""
        p = self._pred_pos(k)  # @line L7
        if p < 0 or self.slots[p] < k:  # type: ignore[operator]
            return
        self.slots[p] = None
        self.n -= 1
        for i in range(self.H + 1):  # @line L8
            a, b = self._window(p, i)  # @line L9
            if self._count(a, b) / (b - a) >= self.rho(i) or self.cap == MIN_CAPACITY:  # @line L10
                if i > 0:
                    self._rebalance(a, b, None)
                return
        self._resize(None)  # @line L11

    def _rebalance(self, a: int, b: int, extra: Optional[K]) -> None:
        items = [x for x in self.slots[a:b] if x is not None]  # @line L12
        if extra is not None:
            j = 0
            while j < len(items) and items[j] < extra:  # type: ignore[operator]
                j += 1
            items.insert(j, extra)
        self._spread(a, b, items)

    def _spread(self, a: int, b: int, items: list[K]) -> None:
        w, m = b - a, len(items)
        new: list[Optional[K]] = [None] * w
        for t, x in enumerate(items):
            new[t * w // m] = x
        old = self.slots[a:b] if len(self.slots) >= b else [None] * w
        for t in range(w):
            if new[t] is not None and new[t] is not old[t]:
                self.moves += 1
        self.slots[a:b] = new

    def _resize(self, extra: Optional[K]) -> None:
        items = [x for x in self.slots if x is not None]  # @line L13
        if extra is not None:
            j = 0
            while j < len(items) and items[j] < extra:  # type: ignore[operator]
                j += 1
            items.insert(j, extra)
        cap = MIN_CAPACITY
        while cap < 2 * 2**0.5 * len(items):  # density n/cap in (0.177, 0.354]
            cap *= 2
        self._layout(cap)
        self.slots = [None] * cap
        if items:
            self._spread(0, cap, items)

    # inspection ----------------------------------------------------------

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

    def __iter__(self) -> Iterator[K]:
        return iter([x for x in self.slots if x is not None])

    def check_invariant(self) -> None:
        """Raise AssertionError if a property of the array fails."""
        keys = list(self)
        assert len(keys) == self.n
        assert all(a < b for a, b in zip(keys, keys[1:]))
        assert self.cap & (self.cap - 1) == 0 and self.cap % self.S == 0
        assert self.cap == MIN_CAPACITY or self.n <= self.cap
        for i in range(self.H + 1):
            size = self.S << i
            for a in range(0, self.cap, size):
                c = self._count(a, a + size)
                assert c <= size  # a window never holds more than it has slots
test_packed_memory_array.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/packed-memory-array/. The authors are listed in prov/A555C0.jsonl.

import math
import random

from packed_memory_array import PackedMemoryArray


def show(t: PackedMemoryArray[int]) -> str:
    return " ".join("_" if x is None else str(x) for x in t.slots)


def test_example() -> None:
    t: PackedMemoryArray[int] = PackedMemoryArray()
    assert (t.cap, t.S, t.H) == (8, 2, 2)
    for k in [50, 20, 80]:
        t.insert(k)
    assert show(t) == "20 50 80 _ _ _ _ _"
    t.insert(10)  # segment and its pair are too dense, so the whole array is spread
    assert show(t) == "10 _ 20 _ 50 _ 80 _"
    t.insert(60)
    t.insert(30)
    assert show(t) == "10 _ 20 30 50 60 80 _"
    t.insert(70)  # every window is too dense: the array grows to 32 slots
    assert (t.cap, t.S, t.H) == (32, 4, 3)
    assert show(t) == (
        "10 _ _ _ 20 _ _ _ _ 30 _ _ _ 50 _ _ _ _ 60 _ _ _ 70 _ _ _ _ 80 _ _ _ _"
    )
    t.insert(40)  # fits in its segment
    assert show(t)[: len("10 _ _ _ 20 _ _ _ 30 _ 40 _")] == "10 _ _ _ 20 _ _ _ 30 _ 40 _"
    assert t.moves == 19
    assert t.range(15, 60) == [20, 30, 40, 50, 60]
    assert t.predecessor(45) == 40 and t.predecessor(5) is None
    t.delete(30)
    assert 30 not in t and 40 in t and len(t) == 7
    t.check_invariant()


def test_edges() -> None:
    t: PackedMemoryArray[int] = PackedMemoryArray()
    assert t.predecessor(1) is None and 1 not in t and t.range(0, 9) == []
    t.delete(1)
    t.insert(1)
    t.insert(1)
    assert list(t) == [1]
    t.delete(1)
    assert list(t) == [] and len(t) == 0
    t.check_invariant()


def test_against_set() -> None:
    rng = random.Random(23)
    for _ in range(500):
        t: PackedMemoryArray[int] = PackedMemoryArray()
        ref: set[int] = set()
        for i in range(rng.randrange(1, 250)):
            k = rng.randrange(150)
            r = rng.random()
            if r < 0.35:
                t.delete(k)
                ref.discard(k)
            elif r < 0.85:
                t.insert(k)
                ref.add(k)
            else:
                lo, hi = sorted((k, rng.randrange(150)))
                assert t.range(lo, hi) == sorted(x for x in ref if lo <= x <= hi)
            assert (k in t) == (k in ref)
            if i % 19 == 0:
                t.check_invariant()
        t.check_invariant()
        assert list(t) == sorted(ref)


def test_moves_bound() -> None:
    N = 4096
    for keys in (list(range(N)), list(range(N, 0, -1)), random.Random(3).sample(range(10 * N), N)):
        t: PackedMemoryArray[int] = PackedMemoryArray()
        for k in keys:
            t.insert(k)
        # O(log^2 N) amortized moves per insert, with a small constant
        assert t.moves <= N * math.log2(N) ** 2 / 2
        assert t.cap <= 8 * N
        t.check_invariant()

What the next level needs

  • the tests did not run in this build

Read the full page about Packed memory array