Algorithm Search⌘K

Eytzinger layout in Python

Store a sorted array in breadth-first order of its implicit binary search tree to make binary search cache-friendly.

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

from __future__ import annotations

from collections.abc import Iterator, Sequence
from typing import Generic, Protocol, TypeVar


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


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


class Eytzinger(Generic[T]):
    """A sorted array stored in breadth-first order of its implicit search tree.

    Slot k (1-based) has children 2k and 2k + 1; slot 0 is unused. The
    in-order traversal of the slots gives the sorted order. The array
    rank[k] holds the sorted index of the item in slot k.
    """

    def __init__(self, items: Sequence[T]) -> None:
        self.n = len(items)
        self.b: list[T | None] = [None] * (self.n + 1)
        self.rank = [0] * (self.n + 1)
        self._fill(items)  # @line L3

    def _fill(self, a: Sequence[T]) -> None:
        """Copy the sorted items into the slots in in-order. O(n)."""
        n = self.n
        i = 0  # @line L1
        stack: list[int] = []
        k = 1  # @line L2
        while stack or k <= n:
            if k <= n:  # @line L4
                stack.append(k)
                k = 2 * k  # @line L5
            else:
                k = stack.pop()
                self.b[k] = a[i]  # @line L6
                self.rank[k] = i
                i += 1
                k = 2 * k + 1  # @line L7

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

    def lower_bound_slot(self, x: T) -> int:
        """Return the slot of the first item >= x, or 0 when every item is < x."""
        b, n = self.b, self.n
        k = 1  # @line L8
        while k <= n:  # @line L9
            k = 2 * k + (1 if b[k] < x else 0)  # type: ignore[operator]  # @line L10
        k >>= (k ^ (k + 1)).bit_length()  # @line L11
        return k  # @line L12

    def lower_bound(self, x: T) -> int:
        """Return the number of items less than x (the sorted index of the lower bound)."""
        k = self.lower_bound_slot(x)
        return self.rank[k] if k != 0 else self.n

    def find(self, x: T) -> int:
        """Return the smallest sorted index i with item i == x, or -1."""
        k = self.lower_bound_slot(x)
        if k != 0 and self.b[k] == x:
            return self.rank[k]
        return -1

    def __contains__(self, x: T) -> bool:
        return self.find(x) != -1

    def __iter__(self) -> Iterator[T]:
        """Yield the items in sorted order by an in-order walk of the slots."""
        stack: list[int] = []
        k = 1
        while stack or k <= self.n:
            if k <= self.n:
                stack.append(k)
                k = 2 * k
            else:
                k = stack.pop()
                yield self.b[k]  # type: ignore[misc]
                k = 2 * k + 1

    def check_invariant(self) -> None:
        """Check the in-order sortedness and the rank array."""
        items = list(self)
        assert len(items) == self.n
        for i in range(1, self.n):
            assert not (items[i] < items[i - 1])
        for k in range(1, self.n + 1):
            assert items[self.rank[k]] == self.b[k]
            l, r = 2 * k, 2 * k + 1
            if l <= self.n:
                assert not (self.b[k] < self.b[l])  # type: ignore[operator]
            if r <= self.n:
                assert not (self.b[r] < self.b[k])  # type: ignore[operator]


def eytzinger_layout(items: Sequence[T]) -> list[T]:
    """Return the items in Eytzinger order as a 0-based list (slot k at index k - 1)."""
    return list(Eytzinger(items).b[1:])  # type: ignore[arg-type]
test_eytzinger_layout.py 89 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/eytzinger-layout/. The authors are listed in prov/Q8TG36.jsonl.

import bisect
import random

from eytzinger_layout import Eytzinger, eytzinger_layout

A = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]


def test_page_example() -> None:
    e = Eytzinger(A)
    e.check_invariant()
    assert eytzinger_layout(A) == [14, 8, 18, 4, 12, 16, 20, 2, 6, 10]
    assert e.rank[1:] == [6, 3, 8, 1, 5, 7, 9, 0, 2, 4]
    assert e.lower_bound_slot(13) == 1 and e.lower_bound(13) == 6
    assert e.lower_bound_slot(21) == 0 and e.lower_bound(21) == 10
    assert e.lower_bound_slot(1) == 8 and e.lower_bound(1) == 0
    assert e.lower_bound_slot(20) == 7 and e.lower_bound(20) == 9
    assert e.find(14) == 6 and e.find(13) == -1
    assert list(e) == A


def test_edges() -> None:
    e: Eytzinger[int] = Eytzinger([])
    e.check_invariant()
    assert len(e) == 0
    assert e.lower_bound_slot(5) == 0 and e.lower_bound(5) == 0
    assert e.find(5) == -1
    one = Eytzinger([7])
    one.check_invariant()
    assert one.lower_bound(6) == 0 and one.lower_bound(7) == 0 and one.lower_bound(8) == 1
    assert one.find(7) == 0 and 7 in one and 8 not in one
    same = Eytzinger([3] * 9)
    same.check_invariant()
    assert same.find(3) == 0 and same.lower_bound(4) == 9


def test_every_size_and_key() -> None:
    for n in range(0, 70):
        a = list(range(0, 2 * n, 2))
        e = Eytzinger(a)
        e.check_invariant()
        for v in range(-1, 2 * n + 1):
            assert e.lower_bound(v) == bisect.bisect_left(a, v)


def test_comparison_count() -> None:
    class Key:
        count = 0

        def __init__(self, v: int) -> None:
            self.v = v

        def __lt__(self, other: "Key") -> bool:
            Key.count += 1
            return self.v < other.v

    for n in range(1, 140):
        e = Eytzinger([Key(2 * i) for i in range(n)])
        lo, hi = (n + 1).bit_length() - 1, n.bit_length()
        for v in range(-1, 2 * n + 1):
            Key.count = 0
            e.lower_bound_slot(Key(v))
            assert lo <= Key.count <= hi


def test_random_ops_against_bisect() -> None:
    rng = random.Random(5150)
    for _ in range(600):
        a = sorted(rng.randint(-10, 10) for _ in range(rng.randint(0, 50)))
        e = Eytzinger(a)
        e.check_invariant()
        assert list(e) == a
        for _ in range(10):
            x = rng.randint(-12, 12)
            i = bisect.bisect_left(a, x)
            assert e.lower_bound(x) == i
            assert e.find(x) == (i if i < len(a) and a[i] == x else -1)
            assert (x in e) == (x in a)


def test_strings() -> None:
    words = ["ant", "bee", "cat", "dog", "eel", "fox", "gnu"]
    e = Eytzinger(words)
    e.check_invariant()
    assert eytzinger_layout(words) == ["dog", "bee", "fox", "ant", "cat", "eel", "gnu"]
    assert e.find("eel") == 4 and e.find("elk") == -1

What the next level needs

  • the tests did not run in this build

Read the full page about Eytzinger layout