Rank Query: Counting Set Bits in Bitvectors
The rank query is a fundamental operation in succinct
data structures that counts the number of set bits (ones) up to a
specified index in a bitvector. While a naive scan through the binary
sequence solves this linearly, modern computer science uses a
combination of hardware-level bitwise operations and multi-level
precomputed index structures to achieve constant time \(O(1)\) evaluation with minimal memory
overhead.
The Fundamental Concept of Rank
A bitvector (or bitmap) is a compact array of binary digits (\(0\) and \(1\)). Given an index \(i\), the query \(\text{rank}_1(i)\) returns the number of \(1\)s occurring in the prefix \(B[0 \dots i]\). Similarly, \(\text{rank}_0(i)\) counts the number of \(0\)s, which can be derived directly using the identity:
\[\text{rank}_0(i) = (i + 1) - \text{rank}_1(i)\]
Baseline: Word-Level Popcount
At the machine level, bitvectors are stored as arrays of fixed-width machine words (typically 64-bit integers).
To count the bits up to index \(i\)
without precomputed structures: 1. Identify the word index containing
bit \(i\): \(\lfloor i / 64 \rfloor\). 2. Sum the set
bits of all preceding words using CPU-level population count
instructions (such as POPCNT on x86/x64 architectures). 3.
Mask the target word to isolate bits up to position \((i \bmod 64)\) and apply
POPCNT to the masked segment.
While this approach is space-efficient (requiring zero auxiliary storage), its query time is \(O(i/64)\), which degrades linearly as the bitvector grows.
Constant-Time Rank via Two-Level Indexing
To achieve true \(O(1)\) time complexity, bitvectors use a two-level indexing scheme (popularized by Guy Jacobson) that divides the bit array into hierarchical blocks:
- Superblocks: The bitvector of length \(n\) is divided into large superblocks of size \(S = \lfloor \log^2 n \rfloor\) bits. An array stores the absolute number of \(1\)s from the start of the bitvector up to the beginning of each superblock.
- Blocks (Sub-blocks): Each superblock is further subdivided into smaller blocks of size \(b = \lfloor \frac{1}{2} \log n \rfloor\) bits. A secondary array stores the relative number of \(1\)s from the start of the containing superblock up to the beginning of each block.
Because relative counts within a superblock are bounded by \(S\), they require significantly fewer bits to store than global counts.
Executing the Rank Query
When computing \(\text{rank}_1(i)\), the query executes three operations:
- Superblock Lookup: Retrieve the absolute count of set bits before the target superblock index \(\lfloor i / S \rfloor\).
- Block Lookup: Retrieve the relative count of set bits from the start of the superblock up to the target sub-block index \(\lfloor i / b \rfloor\).
- In-Block Resolution: Count the remaining set bits
within the target sub-block up to \((i \bmod
b)\) using either a CPU
POPCNTinstruction or a precomputed lookup table.
The final rank is the sum of these three values:
\[\text{rank}_1(i) = \text{SuperblockCount} + \text{BlockCount} + \text{InBlockPopcount}\]
Complexity and Efficiency
- Time Complexity: \(O(1)\) — The calculation involves two direct array lookups, basic bit masking, and a single word-level popcount instruction.
- Space Overhead: \(o(n)\) (sublinear) — The auxiliary index structures consume only an additional \(O\left(\frac{n \log \log n}{\log n}\right)\) bits, making the structure succinct because the metadata size becomes negligible relative to the bitvector itself.