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
# 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