Algorithm Search⌘K

Succinct graph representation in Python

Graph encoding close to the information-theoretic minimum while supporting fast queries.

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

from typing import Iterable

W = 64


class BitVector:
    """A static bit vector with rank and select.

    Bits are packed in 64-bit words. ones[k] is the number of 1s in the words
    before word k. select uses a binary search on ones, so it costs
    O(log(len / 64) + 64) instead of the O(1) of the theoretical structures.
    """

    def __init__(self, length: int, positions: Iterable[int]) -> None:
        self.length = length
        self.words = [0] * ((length + W - 1) // W)
        for p in positions:
            self.words[p // W] |= 1 << (p % W)
        self.ones = [0]
        for x in self.words:
            self.ones.append(self.ones[-1] + bin(x).count("1"))

    def get(self, p: int) -> int:
        return (self.words[p // W] >> (p % W)) & 1

    def _select(self, i: int, bit: int) -> int:
        """Position of the i-th (0-based) bit equal to `bit`."""
        def count(k: int) -> int:
            return self.ones[k] if bit else k * W - self.ones[k]
        lo, hi = 0, len(self.words)
        while hi - lo > 1:  # the last word k with count(k) <= i
            mid = (lo + hi) // 2
            if count(mid) <= i:
                lo = mid
            else:
                hi = mid
        left = i - count(lo)
        x = self.words[lo] if bit else ~self.words[lo]
        for b in range(W):
            if (x >> b) & 1:
                if left == 0:
                    return lo * W + b
                left -= 1
        raise IndexError("select out of range")

    def select1(self, i: int) -> int:
        return self._select(i, 1)

    def select0(self, i: int) -> int:
        return self._select(i, 0)


class SuccinctGraph:
    """A static simple directed graph on 0..n-1, stored as the Elias-Fano
    encoding of the sorted positions p = u*n + v of the 1s of its adjacency
    matrix. The space is about m (2 + log2(n^2 / m)) bits, within O(m) bits
    of the minimum log2 C(n^2, m). An undirected graph stores both arcs.

    adjacent, degree and neighbor cost O(1 + s) plus the cost of a select,
    where s is the number of arcs in the bucket of the query (O(1) expected
    for a random graph). Build costs O(m + n^2 / 2^l) after sorting.
    """

    def __init__(self, n: int, arcs: Iterable[tuple[int, int]]) -> None:
        self.n = n
        pos = sorted({u * n + v for u, v in arcs})
        for p in pos:
            if not 0 <= p < n * n:
                raise ValueError("vertex out of range")
        self.m = m = len(pos)
        universe = n * n
        l = 0
        while m and (universe >> (l + 1)) >= m:  # l = floor(log2(universe / m))
            l += 1
        self.l = l
        self.low = [p & ((1 << l) - 1) for p in pos]  # l bits each
        self.high = BitVector(m + (universe >> l) + 1,
                              ((p >> l) + i for i, p in enumerate(pos)))

    def access(self, i: int) -> int:
        """The i-th smallest position, 0 <= i < m."""
        return ((self.high.select1(i) - i) << self.l) | self.low[i]  # @line L1

    def rank(self, x: int) -> int:
        """The number of stored positions smaller than x."""
        h = x >> self.l  # @line L2
        start = self.high.select0(h - 1) + 1 if h > 0 else 0  # @line L3
        k = start - h  # @line L4
        q = start
        while (q < self.high.length and self.high.get(q) == 1  # @line L5
               and self.low[k] < (x & ((1 << self.l) - 1))):
            k += 1  # @line L6
            q += 1
        return k  # @line L7

    def adjacent(self, u: int, v: int) -> bool:
        p = u * self.n + v  # @line L8
        k = self.rank(p)  # @line L9
        return k < self.m and self.access(k) == p  # @line L10

    def degree(self, u: int) -> int:
        return self.rank((u + 1) * self.n) - self.rank(u * self.n)  # @line L11

    def neighbor(self, u: int, i: int) -> int:
        """The i-th smallest out-neighbour of u, 0 <= i < degree(u)."""
        return self.access(self.rank(u * self.n) + i) - u * self.n  # @line L12

    def neighbors(self, u: int) -> list[int]:
        return [self.neighbor(u, i) for i in range(self.degree(u))]

    def bits(self) -> int:
        """The size of the encoding in bits, without the rank/select index."""
        return self.m * self.l + self.high.length

    def check_invariant(self) -> None:
        pos = [self.access(i) for i in range(self.m)]
        assert all(a < b for a, b in zip(pos, pos[1:]))
        assert self.high.ones[-1] == self.m
test_succinct_graph_representation.py 69 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/succinct-graph-representation/. The authors are listed in prov/M16F3Q.jsonl.

import math
import random

from succinct_graph_representation import SuccinctGraph

EXAMPLE = [(0, 1), (0, 2), (1, 2), (2, 0), (2, 3), (3, 3)]


def test_example() -> None:
    g = SuccinctGraph(4, EXAMPLE)
    g.check_invariant()
    assert g.l == 1
    assert [g.access(i) for i in range(6)] == [1, 2, 6, 8, 11, 15]
    assert g.high.length == 6 + 8 + 1
    assert g.bits() == 21
    assert g.adjacent(2, 3) and not g.adjacent(3, 2) and g.adjacent(3, 3)
    assert [g.degree(u) for u in range(4)] == [2, 1, 2, 1]
    assert g.neighbors(2) == [0, 3]
    assert g.neighbor(0, 1) == 2


def test_empty_and_single() -> None:
    g = SuccinctGraph(0, [])
    assert g.m == 0 and g.bits() == 1
    g = SuccinctGraph(1, [])
    assert not g.adjacent(0, 0) and g.degree(0) == 0
    g = SuccinctGraph(1, [(0, 0)])
    assert g.adjacent(0, 0) and g.neighbors(0) == [0]


def test_complete_graph_and_duplicates() -> None:
    n = 9
    arcs = [(u, v) for u in range(n) for v in range(n)]
    g = SuccinctGraph(n, arcs + arcs[:5])
    g.check_invariant()
    assert g.m == n * n and g.l == 0
    assert all(g.degree(u) == n for u in range(n))


def test_space_is_near_the_bound() -> None:
    rng = random.Random(7)
    n, m = 200, 1000
    arcs = set()
    while len(arcs) < m:
        arcs.add((rng.randrange(n), rng.randrange(n)))
    g = SuccinctGraph(n, arcs)
    bound = math.log2(math.comb(n * n, m))
    assert bound <= g.bits() <= bound + 2 * m


def test_random_against_sets() -> None:
    rng = random.Random(2024)
    for _ in range(600):
        n = rng.randrange(0, 12)
        arcs = [(rng.randrange(n), rng.randrange(n))
                for _ in range(rng.randrange(0, n * n + 1))] if n else []
        g = SuccinctGraph(n, arcs)
        g.check_invariant()
        s = set(arcs)
        assert g.m == len(s)
        for u in range(n):
            nb = sorted(v for (a, v) in s if a == u)
            assert g.degree(u) == len(nb)
            assert g.neighbors(u) == nb
            for v in range(n):
                assert g.adjacent(u, v) == ((u, v) in s)

What the next level needs

  • the tests did not run in this build

Read the full page about Succinct graph representation