A sorted list of one billion document IDs drawn from a range of 232 needs 4 GB as an array of 32-bit integers. The information in it, which subset of the range it is, takes far fewer bits. Succinct data structures store data in space close to that information-theoretic minimum and still answer queries directly on the compressed form, usually in constant or near-constant time. Nothing is decompressed first.

This page builds the toolkit from first principles. It covers the counting argument that defines the target, the rank and select primitives that everything else rests on, Elias-Fano coding for sorted integers, LOUDS for trees in about two bits per node, and the engineering that decides whether these structures are fast in practice. All code is tested Python. Production versions pack bits into machine words and use hardware popcount, but the index arithmetic is identical.

The bound: how few bits are enough

Suppose a class of objects has N members. Any encoding that can distinguish all of them needs at least Z = log2 N bits for some member. That is the information-theoretic lower bound. Structures are then classed by how close they come to it:

  • implicit: Z + O(1) bits. A sorted array used for binary search is implicit for a set.
  • succinct: Z + o(Z) bits. The redundancy grows more slowly than Z itself, so its share tends to zero.
  • compact: O(Z) bits, a constant factor above the bound.

Three counts recur. A bitvector of length n with m ones is one of C(n, m) possibilities, so Z = log2 C(n, m), which is far below n when m is small. A sorted set of n distinct values from [0, u) is also one of C(u, n) subsets, about n log2(u/n) + 1.44n bits when n is much smaller than u. An ordinal tree with n nodes, meaning a rooted tree whose children are ordered, is one of the Catalan number Cn-1 shapes, which is about 2n bits. A pointer-based tree spends 64 bits or more per child pointer, so a tree in about 2 bits per node is a 30x to 100x saving before any labels are stored. The hard part is navigating those bits in constant time, which is what rank and select provide.

Rank and select, the engine

Over a bitvector B, rank1(i) counts the ones in positions [0, i), and select1(j) returns the position of the j-th one. rank0 and select0 are the same for zeros. Jacobson showed in 1989 that rank can be answered in constant time with o(n) extra bits, and later work by Clark and by Munro did the same for select. The practical version stores cumulative counts at two granularities and finishes with a popcount.

class BitVector:
    """Plain bits plus a sampled rank directory: one cumulative count per 512-bit
    superblock and one relative count per 64-bit word."""
    def __init__(self, bits):
        self.n = len(bits)
        self.words = []
        for i in range(0, self.n, 64):
            w = 0
            for j, b in enumerate(bits[i:i + 64]):
                w |= b << j
            self.words.append(w)
        self.super, self.rel = [], []
        total = 0
        for k, w in enumerate(self.words):
            if k % 8 == 0:
                self.super.append(total)
            self.rel.append(total - self.super[-1])
            total += bin(w).count("1")
        self.ones = total

    def rank1(self, i):
        """Number of 1s in positions [0, i)."""
        k, r = divmod(i, 64)
        if k == len(self.words):
            return self.ones
        mask = (1 << r) - 1
        return self.super[k // 8] + self.rel[k] + bin(self.words[k] & mask).count("1")

    def rank0(self, i):
        return i - self.rank1(i)

    def select1(self, j):
        """Position of the j-th 1 (j counts from 1): binary search over rank."""
        lo, hi = 0, self.n
        while lo < hi:
            mid = (lo + hi) // 2
            if self.rank1(mid + 1) < j:
                lo = mid + 1
            else:
                hi = mid
        return lo

    def select0(self, j):
        lo, hi = 0, self.n
        while lo < hi:
            mid = (lo + hi) // 2
            if self.rank0(mid + 1) < j:
                lo = mid + 1
            else:
                hi = mid
        return lo

    def __getitem__(self, i):
        return (self.words[i // 64] >> (i % 64)) & 1

rank1 is three memory reads and one popcount. select here is a binary search over rank, O(log n), which is enough to make the later code correct. Production libraries store a sampled position for every k-th one, jump to the right block, and scan a few words, which gives near-constant time. Overhead is a design choice. Vigna's rank9 layout spends about 25% extra space and answers rank with essentially one cache miss. Poppy (Zhou, Andersen and Kaminsky, 2013) gets overhead down to roughly 3% by using larger blocks and interleaving the counts. The wavelet tree article uses these same primitives to answer range queries over sequences.

Elias-Fano: sorted integers near the bound

Elias-Fano stores a sorted sequence of n integers from [0, u). Choose l = floor(log2(u/n)). Split each value into its low l bits and its remaining high bits. Store the low parts verbatim in an array of n l-bit fields. Store the high parts in unary: for the i-th value (0-based) with high part h, set bit h + i of a bitvector H. Because the sequence is sorted, the high parts never decrease. Each bucket's ones therefore land after the previous bucket's, and the zeros between them count bucket boundaries. H has n ones and at most u / 2l + 1 zeros, so the total is about n(2 + log2(u/n)) bits, close to the C(u, n) bound above.

Elias-Fano for 3, 4, 7, 13, 14, 15, 21, 43 (n = 8, u = 44, l = 2 low bits)30000110114000100100700011111113001101301140011103101500111131121010101501431010111011highlow10011213040516171809010111012013014015016117018019high-part bitvector H: value i with high part h sets bit h + i (unary buckets, 20 bits)low array L: 11 00 11 01 10 11 01 118 x 2 = 16 bitsaccess(3) = ((select1(H, 4) - 3) << 2) | L[3](6 - 3) << 2 | 01 = 1336 bits in total, against 48 for eight fixed-width 6-bit integers and 256 for 32-bit integers.The saving grows with n: the cost is about 2 + log2(u/n) bits per value, independent of the largest value.
Figure: Elias-Fano on eight values. Each value splits into a high part, written in unary into H, and two low bits, written verbatim into L. access(i) is one select1 and one array read.
import math

class EliasFano:
    def __init__(self, values, universe):
        n = len(values)
        self.n = n
        self.l = max(0, int(math.floor(math.log2(universe / n)))) if n else 0
        low_mask = (1 << self.l) - 1
        self.low = [v & low_mask for v in values]       # packed l-bit fields in production
        high = [0] * (n + (universe >> self.l) + 1)
        for i, v in enumerate(values):
            high[(v >> self.l) + i] = 1                  # unary bucket, shifted by i
        self.high = BitVector(high)

    def access(self, i):
        """i-th value, 0-based: select finds the bucket, low bits fill in the rest."""
        hi = self.high.select1(i + 1) - i
        return (hi << self.l) | self.low[i]

    def next_geq(self, x):
        """Smallest stored value >= x, or None."""
        b = x >> self.l
        pos = self.high.select0(b) + 1 if b > 0 else 0  # first slot of bucket b
        i = pos - b                                      # number of values before bucket b
        while i < self.n:
            v = self.access(i)
            if v >= x:
                return v
            i += 1
        return None

Worked trace. For the eight values in the figure, u = 44, so u/n = 5.5 and l = 2. The low parts are 3, 0, 3, 1, 2, 3, 1, 3. The high parts 0, 1, 1, 3, 3, 3, 5, 10 set bits 0, 2, 3, 6, 7, 8, 11 and 17 of a 20-bit H. To read access(3): select1(4) = 6, the high part is 6 - 3 = 3, and the value is 3 x 4 + 1 = 13. For next_geq(16): bucket b = 4. The fourth zero of H is at position 9, so bucket 4 begins at position 10, after 10 - 4 = 6 values. access(6) = 21, which is the answer. Posting-list intersection in a search engine is a loop of next_geq calls, which is why Vigna's quasi-succinct indices build on Elias-Fano.

Trees in about two bits per node

LOUDS, the level-order unary degree sequence, encodes an ordinal tree by visiting nodes in breadth-first order. Each node writes a one for each child, followed by a zero. A '10' prefix for an imaginary super-root gives every real node a parent one. The tree then takes 2n + 1 bits, and node x in BFS numbering (1-based) is identified with the x-th one.

def louds_bits(children, root=0):
    bits, order, q = [1, 0], [], [root]
    while q:
        v = q.pop(0)
        order.append(v)
        kids = children.get(v, [])
        bits += [1] * len(kids) + [0]
        q += kids
    return bits, order

class Louds:
    def __init__(self, bits):
        self.b = BitVector(bits)
    def first_child(self, x):
        p = self.b.select0(x) + 1            # start of node x's child run
        return self.b.rank1(p) + 1 if self.b[p] == 1 else None
    def degree(self, x):
        return self.b.select0(x + 1) - self.b.select0(x) - 1
    def parent(self, x):
        return self.b.rank0(self.b.select1(x))   # owner of the run holding x's 1

For the tree 0 → {1, 2, 3}, 1 → {4, 5}, 3 → {6}, the encoding is 10 1110 110 0 10 0 0 0, which is 15 bits for 7 nodes. Node 2 in BFS order is original node 1. Its run starts after the second zero, at position 6, which holds a one, so first_child(2) = rank1(6) + 1 = 5, original node 4. parent(7) finds the seventh one at position 10. Four zeros precede it, the super-root's included, so the parent is BFS node 4, original node 3. Every navigation step is a constant number of rank and select calls.

LOUDS does not give subtree size or lowest common ancestor. The balanced parentheses (BP) encoding writes '(' entering and ')' leaving each node in a depth-first walk, also in 2n bits. With a range min-max tree over the excess, as in Sadakane and Navarro's work, it adds subtree size, depth and LCA. Use LOUDS for downward-walking tries, as marisa-trie does, and BP for DFS-order queries.

Engineering for real hardware

  • Popcount is the hot instruction. On x86-64 it is POPCNT, and on Arm NEON it is CNT plus a horizontal add. Python 3.10+ exposes it as int.bit_count(). Compilers emit it only when the target allows it, so build with -mpopcnt or an appropriate -march, or rank silently falls back to a slower software popcount.
  • Cache misses dominate. A rank query costs a superblock read, a block read and a data word read. If those are in three cache lines, that is three misses on a large vector. Interleaved layouts that put counts and bits in one 64-byte line are the main reason modern designs beat older ones.
  • Build once, query many. These structures are static. Inserting one value into Elias-Fano means rebuilding it. For updates, batch changes into a small mutable delta and merge periodically, as LSM trees do.
  • Memory-map the result. Flat word arrays map from disk with no deserialisation.
  • Partition for skew. Elias-Fano spends bits according to the average gap u/n. Dense runs inside a sparse list waste space, so partitioned Elias-Fano splits the list into chunks with their own parameters.

Failure modes

  • Off-by-one conventions. Libraries disagree on whether rank is inclusive and on whether select is 0- or 1-based. Mixing conventions shifts every navigation result by one node. Wrap the library in your own functions with documented semantics, and test them against a brute-force oracle, as this page's harness does.
  • Unsorted or duplicate input to Elias-Fano. Unsorted input corrupts H silently. The structure tolerates duplicates if it is built for non-decreasing values, but next_geq semantics must then be defined. Assert sortedness at build time.
  • Wrong universe. A value equal to or above u writes past the end of H. Set u = max + 1 from the data, not from a guess.
  • Tiny inputs. The fixed overheads of directories and headers dominate below a few thousand elements. A plain sorted array is smaller and faster there.
  • Measuring the wrong thing. Benchmarks on a vector that fits in L2 cache hide the cache-miss behaviour that decides production performance. Benchmark at the real size.

Trade-offs

StructureSpaceRandom accessBest for
Plain integer arrayn x 32 or 64 bits1 readsmall or hot data
Varint or delta + varintnear entropy for small gapssequential decode onlylogs, wire formats
Elias-Fanon(2 + log2(u/n)) bitsselect + readposting lists, monotone offsets
Roaring bitmapadapts per 2^16 chunkcontainer lookupsets with mixed density, set algebra
LOUDS or BP treeabout 2 bits per noderank/select per steplarge static tries, XML or JSON trees
Pointer tree64+ bits per edge1 pointer chasemutable trees

What to do next

  1. Copy the BitVector, EliasFano and Louds classes, and run them against a brute-force oracle on random inputs before changing anything.
  2. Find one large, static, sorted integer array in your system, such as document IDs, file offsets or timestamps. Measure its size as Elias-Fano and the latency of next_geq at full size.
  3. For production, use a maintained library such as SDSL-lite or Vigna's Sux in C++, or folly's Elias-Fano coding, rather than porting this Python.
  4. Wrap whichever library you use in functions with explicit rank and select conventions, and keep the oracle tests.
  5. Keep learning: wavelet trees over rank and select, Roaring bitmaps, suffix arrays, tries in depth and Cartesian trees and range minimum.
Key takeaway: Succinct structures store data close to the information-theoretic minimum and answer queries in place. Rank and select over a bitvector, with a small directory of counts and popcount, are the engine. Elias-Fano uses it to store sorted integers in about 2 + log2(u/n) bits each with fast next_geq. LOUDS and balanced parentheses use it to store trees in about two bits per node. Use them for large static data, benchmark at real size, and test against a brute-force oracle.