Succinct Data Structures: Rank and Select Explained
Succinct data structures represent data using space that approaches the information-theoretic lower bound while still supporting fast, efficient algorithmic queries in-place without full decompression. This article explains how succinct design minimizes bit usage in binary systems and explores the mechanics behind the fundamental \(\text{rank}\) and \(\text{select}\) operations that make these structures fast and practical.
Understanding Succinct Data Structures
In traditional computer science, data structures use extra pointers and word-aligned memory blocks to achieve optimal query speeds, often consuming significantly more memory than the raw data requires. An information-theoretic minimum, denoted as \(L\), represents the fewest number of bits mathematically required to distinguish all possible states of a dataset.
A data structure is defined as: * Implicit: Uses \(L + O(1)\) bits of space (e.g., a standard binary heap). * Succinct: Uses \(L + o(L)\) bits of space, adding only a sublinear overhead. * Compact: Uses \(O(L)\) bits of space.
Succinct design allows complex structures such as trees, graphs, and text indexes to be encoded as compact bit arrays, allowing massive datasets to fit directly into RAM or CPU cache.
Binary Bitvectors as the Foundation
At the core of succinct design is the bitvector (a static sequence of 0s and 1s). By encoding structural properties into binary—such as tree traversals represented by opening and closing parentheses or node degrees—any arbitrary discrete structure can be linearized into a bit array of length \(n\).
To manipulate and navigate this raw bit sequence without decoding it entirely, systems rely on two foundational primitive operations: \(\text{rank}\) and \(\text{select}\).
The Rank Operation
The \(\text{rank}\) operation counts
occurrences of a specific bit up to a given position: * \(\text{rank}_1(i)\): Returns the number of
1 bits in the range \([0,
i]\). * \(\text{rank}_0(i)\):
Returns the number of 0 bits in the range \([0, i]\) (calculated as \((i + 1) - \text{rank}_1(i)\)).
Achieving \(O(1)\) Rank Time with \(o(n)\) Bits
To answer \(\text{rank}\) in constant time \(O(1)\) without storing full precomputed prefix sums for every index, a two-level indexing hierarchy (Jacobson’s technique) is used:
- Superblocks: The bit array of size \(n\) is divided into large blocks of size
\(s = \lfloor \log^2 n \rfloor\) bits.
For each superblock, the cumulative count of
1s from the beginning of the array up to that point is stored. This requires \(\frac{n}{\log^2 n} \cdot \log n = O\left(\frac{n}{\log n}\right) = o(n)\) bits. - Blocks (Sub-blocks): Each superblock is divided
into smaller blocks of size \(b = \lfloor
\frac{1}{2} \log n \rfloor\) bits. Each block stores the relative
count of
1s from the start of its parent superblock. Storing relative counts requires \(\log s = \log(\log^2 n) = 2 \log \log n\) bits per block. The total space for all blocks is \(\frac{n}{b} \cdot 2 \log \log n = O\left(\frac{n \log \log n}{\log n}\right) = o(n)\) bits. - Lookup Tables: Within an individual sub-block, the remaining bits are resolved using a precomputed universal lookup table that maps every possible bit pattern of length \(b\) and offset to its internal rank count. Since \(b = \frac{1}{2} \log n\), the table contains \(2^b = \sqrt{n}\) entries, using negligible space (\(o(n)\) bits).
To compute \(\text{rank}_1(i)\), the system sums: \[\text{rank}_1(i) = \text{Superblock Count} + \text{Block Count} + \text{Lookup Table Value}\] This requires three memory lookups and basic arithmetic, executing in \(O(1)\) time using only \(o(n)\) auxiliary bits.
The Select Operation
The \(\text{select}\) operation is
the inverse of rank: * \(\text{select}_1(j)\): Finds the index of
the \(j\)-th occurrence of bit
1. * \(\text{select}_0(j)\): Finds the index of
the \(j\)-th occurrence of bit
0.
Achieving \(O(1)\) Select Time
While \(\text{select}\) can be answered in \(O(\log \log n)\) time using binary search over the \(\text{rank}\) structure, true \(O(1)\) time is achieved by partitioning occurrences rather than indices:
- Super-select Blocks: The array is partitioned based
on fixed counts of
1s (e.g., every \(\log n \log \log n\)-th1). - Density Handling: If the span of indices between two markers is wide (sparse bits), the exact positions are stored explicitly in an array. If the span is small (dense bits), a second level of sub-blocks and precomputed lookup tables is used, mirroring the sublinear space logic of the rank structure.
This ensures that finding the index of the \(j\)-th bit requires constant time \(O(1)\) while maintaining a total auxiliary footprint of \(o(n)\) bits.
Practical Applications
By combining bit arrays with \(O(1)\) \(\text{rank}\) and \(\text{select}\) primitives, developers can build: * Succinct Trees: Binary trees and ordinal trees represented in \(2n + o(n)\) bits (compared to \(O(n \log n)\) bits for pointer-based trees) using schemes like LOUDS (Level-Order Unary Degree Sequence) or Balanced Parentheses. * Compressed Text Indexes: The FM-index and Burrows-Wheeler Transform (BWT), which allow full-text pattern searching in compressed space. * Compact Graphs and Dictionaries: Minimal perfect hash tables and sparse sets optimized for in-memory graph traversals and large database keys.