Algorithm Search⌘K

Skip list in Python

A randomized ordered dictionary made of layered linked lists, where higher layers skip over elements, giving expected logarithmic search, insertion and deletion.

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

"""Skip list (Pugh 1990): an ordered map made of linked lists in levels."""

import random
from typing import Any, Iterator


class _Node:
    __slots__ = ("key", "value", "forward")

    def __init__(self, key: Any, value: Any, level: int) -> None:
        self.key = key
        self.value = value
        self.forward: list["_Node | None"] = [None] * level


class SkipList:
    """Ordered map with expected O(log n) search, insert and delete.

    Keys need only "<". Each node gets level l with probability p^(l-1)(1-p),
    capped at max_level. The header node has max_level forward pointers.
    """

    def __init__(self, p: float = 0.5, max_level: int = 32, seed: int | None = None) -> None:
        if not 0.0 < p < 1.0:
            raise ValueError("p must be in (0, 1)")
        self.p = p
        self.max_level = max_level
        self.level = 1  # number of levels in use
        self.header = _Node(None, None, max_level)
        self.n = 0
        self._rng = random.Random(seed)

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

    def _random_level(self) -> int:
        lvl = 1
        while lvl < self.max_level and self._rng.random() < self.p:
            lvl += 1
        return lvl

    def _find_update(self, key: Any) -> list[_Node]:
        """For each level i, the last node with key < key (the header if none)."""
        update: list[_Node] = [self.header] * self.max_level
        x = self.header
        for i in range(self.level - 1, -1, -1):
            nxt = x.forward[i]
            while nxt is not None and nxt.key < key:
                x = nxt
                nxt = x.forward[i]
            update[i] = x
        return update

    def search(self, key: Any) -> Any:
        """Return the value of key, or None if key is absent."""
        x = self.header  # @line L1
        for i in range(self.level - 1, -1, -1):  # @line L2
            nxt = x.forward[i]
            while nxt is not None and nxt.key < key:  # @line L3
                x = nxt
                nxt = x.forward[i]
        x = x.forward[0]  # @line L4
        if x is not None and not (key < x.key):  # @line L5
            return x.value
        return None

    def __contains__(self, key: Any) -> bool:
        x = self._find_update(key)[0].forward[0]
        return x is not None and not (key < x.key)

    def insert(self, key: Any, value: Any = None) -> bool:
        """Map key to value. Return True if key was new."""
        update = self._find_update(key)  # @line L6
        x = update[0].forward[0]
        if x is not None and not (key < x.key):  # @line L7
            x.value = value
            return False
        lvl = self._random_level()  # @line L8
        if lvl > self.level:  # @line L9
            for i in range(self.level, lvl):
                update[i] = self.header
            self.level = lvl
        z = _Node(key, value, lvl)  # @line L10
        for i in range(lvl):  # @line L11
            z.forward[i] = update[i].forward[i]  # @line L12
            update[i].forward[i] = z  # @line L13
        self.n += 1
        return True

    def delete(self, key: Any) -> bool:
        """Remove key. Return True if it was present."""
        update = self._find_update(key)  # @line L14
        x = update[0].forward[0]
        if x is None or key < x.key:  # @line L15
            return False
        for i in range(len(x.forward)):  # @line L16
            if update[i].forward[i] is x:
                update[i].forward[i] = x.forward[i]  # @line L17
        while self.level > 1 and self.header.forward[self.level - 1] is None:  # @line L18
            self.level -= 1
        self.n -= 1
        return True

    def floor(self, key: Any) -> Any:
        """The largest key <= key, or None."""
        update = self._find_update(key)
        x = update[0].forward[0]
        if x is not None and not (key < x.key):
            return x.key
        y = update[0]
        return None if y is self.header else y.key

    def ceiling(self, key: Any) -> Any:
        """The smallest key >= key, or None."""
        x = self._find_update(key)[0].forward[0]
        return None if x is None else x.key

    def keys(self) -> Iterator[Any]:
        x = self.header.forward[0]
        while x is not None:
            yield x.key
            x = x.forward[0]

    def items(self) -> Iterator[tuple[Any, Any]]:
        x = self.header.forward[0]
        while x is not None:
            yield x.key, x.value
            x = x.forward[0]

    def level_sizes(self) -> list[int]:
        """The number of nodes in each level, from level 1 up."""
        sizes = []
        for i in range(self.level):
            c = 0
            x = self.header.forward[i]
            while x is not None:
                c += 1
                x = x.forward[i]
            sizes.append(c)
        return sizes

    def check_invariant(self) -> None:
        """Raise AssertionError if the structure is broken."""
        assert 1 <= self.level <= self.max_level
        for i in range(self.level, self.max_level):
            assert self.header.forward[i] is None
        bottom = []
        x = self.header.forward[0]
        while x is not None:
            assert 1 <= len(x.forward) <= self.max_level
            bottom.append(x)
            x = x.forward[0]
        assert len(bottom) == self.n
        for a, b in zip(bottom, bottom[1:]):
            assert a.key < b.key
        if self.level > 1:
            assert self.header.forward[self.level - 1] is not None
        for i in range(self.level):
            expected = [v for v in bottom if len(v.forward) > i]
            got = []
            x = self.header.forward[i]
            while x is not None:
                got.append(x)
                x = x.forward[i]
            assert got == expected, "level %d is not the sublist of tall nodes" % (i + 1)
test_skip_list.py 66 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/skip-list/. The authors are listed in prov/XYVYKG.jsonl.

import bisect
import random

from skip_list import SkipList


def test_basic() -> None:
    s = SkipList(seed=1)
    for k in [30, 10, 50, 20, 40]:
        assert s.insert(k, str(k))
        s.check_invariant()
    assert list(s.keys()) == [10, 20, 30, 40, 50]
    assert s.search(40) == "40" and s.search(35) is None
    assert not s.insert(40, "x") and s.search(40) == "x" and len(s) == 5
    assert s.floor(35) == 30 and s.ceiling(35) == 40
    assert s.floor(5) is None and s.ceiling(55) is None
    assert s.delete(30) and not s.delete(30)
    s.check_invariant()
    assert list(s.keys()) == [10, 20, 40, 50]


def test_empty() -> None:
    s = SkipList(seed=2)
    s.check_invariant()
    assert s.search(1) is None and not s.delete(1) and s.floor(1) is None
    assert list(s.keys()) == []


def test_level_sizes() -> None:
    s = SkipList(seed=3)
    for k in range(10000):
        s.insert(k)
    sizes = s.level_sizes()
    assert sizes[0] == 10000
    assert all(a >= b for a, b in zip(sizes, sizes[1:]))
    assert 4500 <= sizes[1] <= 5500 and 2000 <= sizes[2] <= 3000
    s.check_invariant()


def test_random_against_sorted_list() -> None:
    rng = random.Random(4242)
    for case in range(500):
        s = SkipList(p=rng.choice([0.25, 0.5]), max_level=rng.choice([4, 16, 32]), seed=case)
        ref: dict[int, int] = {}
        for _ in range(rng.randint(1, 60)):
            k = rng.randint(0, 40)
            op = rng.random()
            if op < 0.5:
                assert s.insert(k, k * 7) == (k not in ref)
                ref[k] = k * 7
            elif op < 0.8:
                assert s.delete(k) == (k in ref)
                ref.pop(k, None)
            else:
                assert s.search(k) == ref.get(k)
            keys = sorted(ref)
            i = bisect.bisect_right(keys, k)
            assert s.floor(k) == (keys[i - 1] if i else None)
            j = bisect.bisect_left(keys, k)
            assert s.ceiling(k) == (keys[j] if j < len(keys) else None)
        s.check_invariant()
        assert list(s.items()) == sorted(ref.items())
        assert len(s) == len(ref)

What the next level needs

  • the tests did not run in this build

Read the full page about Skip list