Algorithm Search⌘K

SD-array in Python

A compressed bit vector built on Elias-Fano coding that is efficient for sparse bit vectors.

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

from collections.abc import Sequence

W = 64


class _Bits:
    """A plain bit vector with select1 and select0 by a scan from a sample of
    every 64th bit of each kind."""

    Q = 64

    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: tuple[list[int], list[int]] = ([], [])
        self.count = [0, 0]
        for p in range(length):
            bit = (self.words[p // W] >> (p % W)) & 1
            if self.count[bit] % self.Q == 0:
                self.samples[bit].append(p)
            self.count[bit] += 1

    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."""
        p = self.samples[bit][(j - 1) // self.Q]
        j -= (j - 1) // self.Q * self.Q
        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 SDArray:
    """A static bit vector stored as the Elias-Fano code of the positions of its 1s.

    select1 is one select on the high bits; rank finds the bucket of i with two
    select0 calls and binary-searches the low parts inside it, O(1 + log(n/m));
    select0 is a binary search over rank.
    """

    def __init__(self, bits: Sequence[int]) -> None:
        self.n = len(bits)
        pos = [i for i, x in enumerate(bits) if x]
        self.m = m = len(pos)
        self.ell = (self.n // m).bit_length() - 1 if m else 0
        mask = (1 << self.ell) - 1
        self.lo = [p & mask for p in pos]
        hlen = m + ((self.n - 1) >> self.ell) + 1 if self.n else 1
        self.H = _Bits([(p >> self.ell) + j for j, p in enumerate(pos)], hlen)

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

    def select1(self, j: int) -> int:
        """Return the position of the j-th 1, for 1 <= j <= m."""
        if not 1 <= j <= self.m:
            raise IndexError(j)
        return ((self.H.select(j, 1) - (j - 1)) << self.ell) + self.lo[j - 1]  # @line L1

    def rank1(self, i: int) -> int:
        """Return the number of 1s in B[0..i-1], for 0 <= i <= n."""
        if not 0 <= i <= self.n:
            raise IndexError(i)
        if i == self.n:  # @line L2
            return self.m
        h, l = i >> self.ell, i & ((1 << self.ell) - 1)  # @line L3
        p = 0 if h == 0 else self.H.select(h, 0) + 1  # @line L4
        s = self.H.select(h + 1, 0) - p  # @line L5
        r = p - h  # @line L6
        lo, hi = r, r + s  # @line L7
        while lo < hi:  # first t in [r, r + s) with Lo[t] >= l
            mid = (lo + hi) // 2
            if self.lo[mid] < l:
                lo = mid + 1
            else:
                hi = mid
        return lo

    def rank0(self, i: int) -> int:
        """Return the number of 0s in B[0..i-1], for 0 <= i <= n."""
        return i - self.rank1(i)

    def access(self, i: int) -> int:
        """Return B[i], for 0 <= i < n."""
        if not 0 <= i < self.n:
            raise IndexError(i)
        r = self.rank1(i)  # @line L8
        return 1 if r < self.m and self.select1(r + 1) == i else 0  # @line L9

    def select0(self, j: int) -> int:
        """Return the position of the j-th 0, for 1 <= j <= n - m."""
        if not 1 <= j <= self.n - self.m:
            raise IndexError(j)
        lo, hi = 0, self.n - 1  # @line L10
        while lo < hi:
            mid = (lo + hi) // 2
            if mid + 1 - self.rank1(mid + 1) >= j:
                hi = mid
            else:
                lo = mid + 1
        return lo

    def size_in_bits(self) -> int:
        """Return m * ell + |H|, the space without the select samples."""
        return self.m * self.ell + self.H.length
test_sd_array.py 66 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sd-array/. The authors are listed in prov/PZD3RH.jsonl.

import random

import pytest

from sd_array import SDArray


def check_all(bits: list[int]) -> None:
    v = SDArray(bits)
    n = len(bits)
    r = 0
    for i in range(n + 1):
        assert v.rank1(i) == r
        assert v.rank0(i) == i - r
        if i < n:
            assert v.access(i) == bits[i]
            r += bits[i]
    ones = [i for i, x in enumerate(bits) if x]
    zeros = [i for i, x in enumerate(bits) if not x]
    for j, p in enumerate(ones, 1):
        assert v.select1(j) == p
    for j, p in enumerate(zeros, 1):
        assert v.select0(j) == p


def example() -> list[int]:
    bits = [0] * 64
    for p in (3, 4, 7, 13, 14, 15, 21, 43):
        bits[p] = 1
    return bits


def test_page_example() -> None:
    v = SDArray(example())
    assert v.ell == 3
    assert v.lo == [3, 4, 7, 5, 6, 7, 5, 3]
    assert v.size_in_bits() == 40
    assert v.select1(7) == 21
    assert v.rank1(14) == 4
    assert v.rank1(40) == 7
    assert v.access(21) == 1 and v.access(22) == 0
    assert v.select0(10) == 12
    check_all(example())


def test_edges() -> None:
    check_all([])
    check_all([0])
    check_all([1])
    check_all([0] * 100)
    check_all([1] * 100)
    check_all([0] * 99 + [1])
    check_all([1] + [0] * 999)
    with pytest.raises(IndexError):
        SDArray([0, 0]).select1(1)


def test_random_against_lists() -> None:
    rng = random.Random(2007)
    for _ in range(500):
        n = rng.randrange(0, 160)
        p = rng.choice([0.005, 0.02, 0.1, 0.5, 0.9])
        check_all([1 if rng.random() < p else 0 for _ in range(n)])

What the next level needs

  • the tests did not run in this build

Read the full page about SD-array