Algorithm Search⌘K

Unrolled linked list in Python

Linked list whose nodes each hold a small array of elements for cache locality.

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

from typing import Generic, TypeVar

T = TypeVar("T")


class Block(Generic[T]):
    """A node: up to b items in order, and a pointer to the next node."""

    __slots__ = ("items", "next")

    def __init__(self, items: list[T], next: "Block[T] | None" = None) -> None:
        self.items = items
        self.next = next


class UnrolledLinkedList(Generic[T]):
    """A sequence stored as a singly linked list of blocks of at most b items.

    Invariant: the blocks, read in order, hold the sequence; each block holds
    1 to b items; when there are two or more blocks, each holds at least
    h = b // 2 items. With k blocks, get and set cost O(k) = O(1 + n / b)
    steps, insert and delete cost O(n / b + b). Space: O(n + b).
    """

    def __init__(self, b: int = 16) -> None:
        if b < 2:
            raise ValueError("need b >= 2")
        self.b = b
        self.h = b // 2
        self.head: Block[T] | None = None
        self.n = 0

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

    def _find(self, i: int) -> tuple["Block[T] | None", Block[T], int]:
        """Return (pred, v, j): position i is offset j of block v. Needs a block."""
        assert self.head is not None
        pred: Block[T] | None = None
        v = self.head  # @line L1
        while v.next is not None and i >= len(v.items):  # @line L2
            i -= len(v.items)  # @line L3
            pred, v = v, v.next
        return pred, v, i  # @line L4

    def get(self, i: int) -> T:
        """Return the item at position i. Precondition: 0 <= i < n. Cost O(1 + n / b)."""
        if not 0 <= i < self.n:
            raise IndexError("position out of range")
        _, v, j = self._find(i)
        return v.items[j]  # @line L5

    def set(self, i: int, x: T) -> None:
        """Replace the item at position i. Precondition: 0 <= i < n. Cost O(1 + n / b)."""
        if not 0 <= i < self.n:
            raise IndexError("position out of range")
        _, v, j = self._find(i)
        v.items[j] = x

    def insert(self, i: int, x: T) -> None:
        """Insert x so that it gets position i. Precondition: 0 <= i <= n. Cost O(n / b + b)."""
        if not 0 <= i <= self.n:
            raise IndexError("position out of range")
        self.n += 1
        if self.head is None:  # @line L6
            self.head = Block([x])
            return
        _, v, j = self._find(i)  # @line L7
        if len(v.items) == self.b:  # @line L8
            c = self.b - self.h  # v keeps c = ceil(b / 2) items
            w = Block(v.items[c:], v.next)  # @line L9
            del v.items[c:]
            v.next = w
            if j > c:  # @line L10
                v, j = w, j - c
        v.items.insert(j, x)  # @line L11

    def delete(self, i: int) -> T:
        """Remove and return the item at position i. Precondition: 0 <= i < n. Cost O(n / b + b)."""
        if not 0 <= i < self.n:
            raise IndexError("position out of range")
        self.n -= 1
        pred, v, j = self._find(i)  # @line L12
        x = v.items.pop(j)  # @line L13
        if len(v.items) >= self.h or (pred is None and v.next is None):  # @line L14
            if not v.items:
                self.head = None
            return x
        if v.next is not None:  # @line L15
            u, r = v, v.next
        else:
            assert pred is not None
            u, r = pred, v
        if len(u.items) + len(r.items) <= self.b:  # @line L16
            u.items.extend(r.items)
            u.next = r.next
        elif u is v:  # @line L17
            u.items.append(r.items.pop(0))
        else:
            r.items.insert(0, u.items.pop())
        return x  # @line L18

    def append(self, x: T) -> None:
        """Insert x at the end. Cost O(n / b + b)."""
        self.insert(self.n, x)

    def to_list(self) -> list[T]:
        """Return the items in order. Cost O(n + n / b)."""
        out: list[T] = []
        v = self.head
        while v is not None:
            out.extend(v.items)
            v = v.next
        return out

    def blocks(self) -> list[int]:
        """Return the sizes of the blocks in order."""
        out = []
        v = self.head
        while v is not None:
            out.append(len(v.items))
            v = v.next
        return out

    def check_invariant(self) -> None:
        """Check the block sizes and the count of items."""
        sizes = self.blocks()
        assert sum(sizes) == self.n
        assert all(1 <= s <= self.b for s in sizes)
        if len(sizes) >= 2:
            assert all(s >= self.h for s in sizes)
test_unrolled_linked_list.py 90 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/unrolled-linked-list/. The authors are listed in prov/YWD1E5.jsonl.

import random

import pytest

from unrolled_linked_list import UnrolledLinkedList


def test_example() -> None:
    xs: UnrolledLinkedList[int] = UnrolledLinkedList(4)
    for x in (10, 20, 30, 40):
        xs.append(x)
    assert xs.blocks() == [4]
    xs.insert(1, 15)
    assert xs.blocks() == [3, 2]  # split
    xs.insert(5, 50)
    xs.insert(6, 60)
    assert xs.to_list() == [10, 15, 20, 30, 40, 50, 60]
    assert xs.get(3) == 30 and xs.get(5) == 50
    assert xs.delete(0) == 10
    assert xs.blocks() == [2, 4]
    assert xs.delete(0) == 15
    assert xs.blocks() == [2, 3]  # borrow
    assert xs.to_list() == [20, 30, 40, 50, 60]
    assert xs.delete(3) == 50
    assert xs.delete(2) == 40
    assert xs.blocks() == [3]  # merge
    assert xs.to_list() == [20, 30, 60]
    xs.check_invariant()


def test_edges() -> None:
    xs: UnrolledLinkedList[int] = UnrolledLinkedList(2)
    assert xs.to_list() == [] and len(xs) == 0
    with pytest.raises(IndexError):
        xs.get(0)
    with pytest.raises(IndexError):
        xs.delete(0)
    with pytest.raises(IndexError):
        xs.insert(1, 5)
    with pytest.raises(ValueError):
        UnrolledLinkedList(1)
    xs.insert(0, 7)
    assert xs.delete(0) == 7
    assert xs.head is None and xs.blocks() == []
    for x in range(9):
        xs.insert(0, x)
        xs.check_invariant()
    assert xs.to_list() == list(range(8, -1, -1))
    xs.set(4, 99)
    assert xs.get(4) == 99


def test_block_count_bound() -> None:
    b = 8
    xs: UnrolledLinkedList[int] = UnrolledLinkedList(b)
    rng = random.Random(5)
    for _ in range(2000):
        xs.insert(rng.randint(0, len(xs)), 1)
    for _ in range(1500):
        xs.delete(rng.randrange(len(xs)))
    xs.check_invariant()
    k = len(xs.blocks())
    assert k <= max(1, len(xs) // (b // 2))


def test_random_against_list() -> None:
    rng = random.Random(8128)
    for _ in range(500):
        b = rng.randint(2, 7)
        xs: UnrolledLinkedList[int] = UnrolledLinkedList(b)
        model: list[int] = []
        for _ in range(rng.randint(0, 80)):
            op = rng.randrange(4)
            if op <= 1:
                i = rng.randint(0, len(model))
                v = rng.randint(-10**18, 10**18)
                xs.insert(i, v)
                model.insert(i, v)
            elif op == 2 and model:
                i = rng.randrange(len(model))
                assert xs.delete(i) == model.pop(i)
            elif model:
                i = rng.randrange(len(model))
                assert xs.get(i) == model[i]
            xs.check_invariant()
            assert len(xs) == len(model)
        assert xs.to_list() == model

What the next level needs

  • the tests did not run in this build

Read the full page about Unrolled linked list