Elias-Fano encoding in Python
A succinct representation of a monotone integer sequence that supports access and successor queries.
- File
elias_fano_encoding.py, 103 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/elias-fano-encoding/. The authors are listed in prov/XGWQ3Q.jsonl.
from collections.abc import Sequence
W = 64
Q = 64 # sampling rate of the select samples on H
class _HighBits:
"""The bit vector H with select1 and select0 by sampled positions.
Every Q-th 1 and every Q-th 0 has its position stored; a select jumps to
the sample and scans words, which is O(1) when the samples are dense
enough and the rank/select dictionary of the page gives worst-case O(1).
"""
def __init__(self, ones: list[int], length: int) -> None:
self.length = length
self.words = [0] * (length // W + 1)
for p in ones:
self.words[p // W] |= 1 << (p % W)
self.samples: dict[int, list[int]] = {1: [], 0: []}
count = {1: 0, 0: 0}
for p in range(length):
bit = (self.words[p // W] >> (p % W)) & 1
if count[bit] % Q == 0:
self.samples[bit].append(p)
count[bit] += 1
self.count = count
def _word(self, k: int, bit: int) -> int:
x = self.words[k]
if bit == 0:
x = ~x & ((1 << W) - 1)
if k == self.length // W:
x &= (1 << (self.length % W)) - 1
return x
def select(self, j: int, bit: int) -> int:
"""Return the position of the j-th bit equal to `bit`, for j >= 1."""
if not 1 <= j <= self.count[bit]:
raise IndexError(j)
p = self.samples[bit][(j - 1) // Q]
j -= (j - 1) // Q * Q # p is the position of the wanted bit number 1
k = p // W
x = self._word(k, bit) & ~((1 << (p % W)) - 1)
while x.bit_count() < j:
j -= x.bit_count()
k += 1
x = self._word(k, bit)
for _ in range(j - 1):
x &= x - 1
return k * W + (x & -x).bit_length() - 1
class EliasFano:
"""A static nondecreasing sequence of integers in [0, u), stored as low bits
in plain form and high bits as unary gaps in a bit vector H."""
def __init__(self, xs: Sequence[int], u: int) -> None:
n = len(xs)
if u < 1:
raise ValueError("u must be positive")
for i, x in enumerate(xs):
if not 0 <= x < u or (i and xs[i - 1] > x):
raise ValueError("the sequence must be nondecreasing in [0, u)")
self.n, self.u = n, u
ell = (u // n).bit_length() - 1 if 0 < n <= u else 0 # @line L1
self.ell = ell
self.low = 0 # the low parts, packed into one integer of n * ell bits
ones = []
for i, x in enumerate(xs): # @line L2
self.low |= (x & ((1 << ell) - 1)) << (i * ell) # @line L3
ones.append((x >> ell) + i) # @line L4
self.H = _HighBits(ones, n + ((u - 1) >> ell) + 1)
def __len__(self) -> int:
return self.n
def _low(self, i: int) -> int:
return (self.low >> (i * self.ell)) & ((1 << self.ell) - 1)
def access(self, i: int) -> int:
"""Return x_i, for 0 <= i < n."""
if not 0 <= i < self.n:
raise IndexError(i)
return ((self.H.select(i + 1, 1) - i) << self.ell) | self._low(i) # @line L5
def next_geq(self, x: int) -> int:
"""Return the smallest i with x_i >= x, or n if there is none, for 0 <= x < u."""
if not 0 <= x < self.u:
raise IndexError(x)
h = x >> self.ell # @line L6
p = 0 if h == 0 else self.H.select(h, 0) + 1 # @line L7
i = p - h # @line L8
while i < self.n and self.access(i) < x: # @line L9
i += 1
return i # @line L10
def size_in_bits(self) -> int:
"""Return n * ell + |H|, the space without the select samples."""
return self.n * self.ell + self.H.length
test_elias_fano_encoding.py 62 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/elias-fano-encoding/. The authors are listed in prov/XGWQ3Q.jsonl.
import bisect
import random
import pytest
from elias_fano_encoding import EliasFano
def check_all(xs: list[int], u: int) -> None:
ef = EliasFano(xs, u)
n = len(xs)
assert [ef.access(i) for i in range(n)] == xs
for x in range(u):
assert ef.next_geq(x) == bisect.bisect_left(xs, x)
if 0 < n <= u:
assert ef.size_in_bits() < n * ef.ell + 3 * n + 2
def test_page_example() -> None:
xs = [3, 4, 7, 13, 14, 15, 21, 43]
ef = EliasFano(xs, 64)
assert ef.ell == 3
h = "".join(str((ef.H.words[0] >> p) & 1) for p in range(16))
assert h == "1110111010001000"
assert ef.size_in_bits() == 40
assert ef.access(6) == 21 and ef.access(7) == 43
assert ef.H.select(1, 0) == 3 and ef.H.select(3, 0) == 9
assert ef.next_geq(14) == 4
assert ef.next_geq(30) == 7
assert ef.next_geq(44) == 8
check_all(xs, 64)
def test_edges() -> None:
check_all([], 1)
check_all([], 10)
check_all([0], 1)
check_all([5] * 20, 6) # u < n, ell = 0
check_all([0, 999], 1000)
check_all(list(range(300)), 300)
with pytest.raises(ValueError):
EliasFano([3, 2], 10)
with pytest.raises(ValueError):
EliasFano([10], 10)
with pytest.raises(IndexError):
EliasFano([1], 10).access(1)
def test_random_against_bisect() -> None:
rng = random.Random(1974)
for _ in range(500):
u = rng.randrange(1, 2000)
n = rng.randrange(0, 300)
xs = sorted(rng.randrange(u) for _ in range(n))
ef = EliasFano(xs, u)
assert [ef.access(i) for i in range(n)] == xs
for _ in range(40):
x = rng.randrange(u)
assert ef.next_geq(x) == bisect.bisect_left(xs, x)
What the next level needs
- the tests did not run in this build