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