1. Introduction & Motivation
Succinct is a precise technical claim, not a synonym for "compressed." A succinct data structure stores its data using space close to the information-theoretic minimum number of bits required to represent it, while still supporting the same queries a plain, uncompressed representation would support — and it answers those queries directly on the compact representation, in time close to what the uncompressed structure would need.
Contrast that with ordinary compression. A gzip-compressed array is small, but to ask "what is the value at index 1000?" you typically must decompress some or all of it first. A succinct structure never does that: the compact encoding is the queryable structure. This lesson builds the two ideas that make this possible for sequence data: a bitvector that supports rank and select in essentially constant time using only a small amount of extra space, and the wavelet tree, which uses rank/select on bitvectors to answer rich order-statistic and frequency queries over an entire sequence drawn from a small alphabet.
2. Prerequisites
This lesson builds directly on Bit Manipulation: Core Tricks and Interview Patterns, especially popcount (counting set bits in a word) and basic bit tests/masks. Rank on a bitvector is, at its core, "popcount up to a position," so if Kernighan's popcount trick and bitmasking are unfamiliar, read that lesson first — it is not re-derived here.
3. Core Insight: Rank and Select on Bitvectors
rank(i) is the number of 1-bits in positions $[0, i]$ of a bitvector. select(k) is the position of the $k$-th 1-bit (using 1-indexed $k$). Every succinct query in this lesson is built from just these two primitives.
The naive way to answer rank(i) instantly is to precompute a running popcount for every position: an array P where P[i] holds the number of 1-bits in positions $[0, i]$. That gives $O(1)$ rank, but each entry must hold a count up to $n$, needing $\lceil \log_2(n+1) \rceil$ bits, so the whole array costs $O(n \log n)$ bits — often more space than the original bitvector itself. That defeats the purpose of a succinct structure.
The classic fix is a two-level index. Split the bitvector into large superblocks and, inside each superblock, smaller blocks:
- A superblock array stores the absolute rank at the start of each superblock. Since these values run up to $n$, each entry costs $O(\log n)$ bits, but there are only $n / (\log n)^2$ superblocks (choosing superblock size $\Theta((\log n)^2)$), so this array costs $O(n / \log n)$ bits — already $o(n)$.
- A block array stores, for each block inside a superblock, the rank relative to the start of its superblock. Because a block's relative rank is bounded by the superblock size, $\Theta((\log n)^2)$, each entry needs only $O(\log \log n)$ bits, and with block size $\Theta(\log n)$ there are $n / \log n$ blocks — another $o(n)$ bits.
- The remaining offset inside a single $\Theta(\log n)$-bit block is answered with a popcount of a masked machine word — the same trick from the bit-manipulation prerequisite — taking $O(1)$ time on a word-RAM machine.
Add the three pieces together and rank(i) is answered in $O(1)$ time using only $o(n)$ extra bits on top of the $n$ bits of the bitvector itself — asymptotically free, and dramatically less than the naive $O(n \log n)$-bit prefix array. The implementation below uses a simplified single-level block array (fixed-size blocks, no separate superblock tier) for clarity: it still gives $O(1)$ rank in $O(1)$ words per block, but its extra space is $O(n)$ bits rather than the full scheme's $o(n)$ bits. The two-level scheme above is what a production succinct library actually implements.
select(k) has two common answers. The approachable one, implemented below, does a binary search over the same rank/block index to find which block contains the $k$-th one-bit, then scans linearly within that one block, giving $O(\log(n / \text{block size}))$ time using no extra structure beyond the rank index. The alternative for true $O(1)$ select is a separate precomputed structure (for example, sampling every $c$-th one-bit's absolute position directly), which trades additional space for dropping the binary search.
4. A Concrete Example
Rank and select on a 16-bit bitvector
Take the 16-bit vector 1011 0010 1101 0001 (positions 0–15, left to right) and split it into 4 blocks of 4 bits each. For each block, precompute blockRank[k]: the number of 1-bits before block k begins.
Block k | Positions | Bits | Ones in block | blockRank[k] |
|---|---|---|---|---|
| 0 | 0–3 | 1011 | 3 | 0 |
| 1 | 4–7 | 0010 | 1 | 3 |
| 2 | 8–11 | 1101 | 3 | 4 |
| 3 | 12–15 | 0001 | 1 | 7 |
A trailing sentinel entry blockRank[4] = 8 records the total number of ones, for a five-entry array [0, 3, 4, 7, 8].
Query rank(5): position 5 is in block 1 (positions 4–7), offset 1 within the block. blockRank[1] = 3, plus the popcount of bits 4–5 (00, which is 0), gives rank(5) = 3. Counting positions 0–5 directly (101100) confirms three 1-bits.
Query select(5): binary search [0, 3, 4, 7, 8] for the last block whose blockRank is still below 5 — that is block 2 (blockRank[2] = 4 < 5, while blockRank[3] = 7 ≥ 5). Within block 2 (1101, positions 8–11) we need the 5 - 4 = 1st one-bit, which is at offset 0, position 8. Scanning the whole vector confirms the 1st, 2nd, 3rd, 4th, 5th one-bits sit at positions 0, 2, 3, 6, 8 — so select(5) = 8.
5. Rank/Select Implementation
The implementation below precomputes the single-level block-rank array from the worked example (block size 4, generalizable to any block size) and answers rank1 in $O(1)$ by combining the block's precomputed rank with a popcount of the remaining bits, and select1 by binary-searching the block-rank array followed by a linear scan inside one block. Both panels reproduce the exact values traced by hand above.
6. Wavelet Trees & the Wavelet Matrix
From Bitvectors to Succinct Sequences
Rank/select on a single bitvector answers questions about a sequence of two symbols, 0 and 1. Real sequences usually range over a larger alphabet — DNA bases, byte values, dictionary ranks of words. The simplest generalization is one bitvector per character: for an alphabet of size $\sigma$, keep $\sigma$ bitvectors, one per symbol, each marking which positions hold that symbol. rank and select for symbol $c$ then reduce to rank/select on bitvector $c$ alone — for example, over the alphabet {A, C, G, T}, a query "how many Gs appear before position 100?" is exactly rank1(99) on the G-bitvector. This works, but storing $\sigma$ full-length bitvectors costs $O(n\sigma)$ bits, which is wasteful for anything beyond a tiny alphabet. The wavelet tree fixes this by sharing bits across symbols through a recursive split of the alphabet rather than one bitvector per character.
Core Insight: Recursive Alphabet Partitioning
Build proceeds top-down: at a node covering alphabet $[lo, hi]$ with $mid = \lfloor (lo+hi)/2 \rfloor$, build a bitvector where bit $i$ is 0 if the $i$-th element (among elements reaching this node, in original relative order) is $\le mid$, else 1. The elements with bit 0 form the left child's sequence over alphabet $[lo, mid]$; the elements with bit 1 form the right child's sequence over $[mid+1, hi]$. Recursion stops at a leaf where $lo = hi$: every element reaching that leaf equals that single value, so no bitvector is needed there.
To answer range k-th smallest (the value that would be $k$-th in sorted order among positions $[l, r]$) at a node with bitvector bv: count how many of positions $[l, r]$ went left, zeros = bv.rank0(r) - bv.rank0(l-1). If $k \le \text{zeros}$, the answer lives in the left child; remap the range into the left child's own indexing via bv.rank0(l-1) and bv.rank0(r)-1, and recurse with the same $k$. Otherwise the answer is in the right child; remap via bv.rank1 instead, and recurse with $k - \text{zeros}$. At a leaf, the answer is simply that leaf's alphabet value.
Rank of a Value and Range Frequency
The other two flagship wavelet-tree queries follow the exact same recursive remapping, just steered differently. For rank of value $v$ in range $[l, r]$ ("how many times does $v$ occur in positions $[l,r]$?"), descend by comparing $v$ against each node's $mid$ instead of comparing counts: go left if $v \le mid$, right otherwise, remapping $[l,r]$ with rank0/rank1 exactly as before. At the leaf $lo = hi = v$, the answer is simply the size of the remapped range, $r - l + 1$:
def count_value_in_range(node, l, r, v):
if l > r:
return 0
if node.lo == node.hi:
return r - l + 1
if v <= (node.lo + node.hi) // 2:
new_l, new_r = node.bv.rank0(l - 1), node.bv.rank0(r) - 1
return count_value_in_range(node.left, new_l, new_r, v)
new_l, new_r = node.bv.rank1(l - 1), node.bv.rank1(r) - 1
return count_value_in_range(node.right, new_l, new_r, v)
Range frequency for a value range $[v_{lo}, v_{hi}]$ (rather than a single value) generalizes this further: descend both children whenever $[v_{lo}, v_{hi}]$ straddles a node's $mid$, adding the fully-covered child's remapped range size directly and recursing only into the partially-covered side, again using nothing but rank0/rank1. Every one of these queries costs $O(\log \sigma)$ node visits, each doing $O(1)$ rank work — the wavelet tree turns rank/select on bits into rank/select on an arbitrary alphabet.
Wavelet Matrix: A Flattened Alternative
The wavelet tree stores one smaller bitvector per node, with the sequence physically split and re-partitioned into ever-smaller arrays at each level — a pointer-heavy structure that mirrors a balanced BST. The wavelet matrix stores the exact same information differently: one bitvector per alphabet-bit level (so $\lceil \log_2 \sigma \rceil$ bitvectors total), each the full length of the original sequence, rather than a tree of progressively smaller node-local bitvectors. Instead of physically partitioning the sequence into child arrays, the matrix keeps every level's bits in the same flat array and uses a stable partition (0s before 1s) to derive each subsequent level's ordering implicitly, tracked via small per-level offset counters rather than explicit tree pointers.
The two structures answer identical queries with identical asymptotic complexity — they are conceptually equivalent, level-by-level unrollings of the same recursive alphabet split. The matrix wins in practice because it is one flat array of bitvectors instead of a heap-allocated tree of small ones: better cache locality (no pointer chasing between nodes), less memory overhead (no per-node object headers), and a simpler, iterative (not recursive) implementation. This is why production succinct-data-structure libraries (such as SDSL and the succinct/sux family) implement wavelet matrices, not wavelet trees, despite teaching the tree first because its recursive structure is easier to reason about.
7. FM-Index Foundations
The FM-index is the classic application that made rank/select on bitvectors famous: it supports substring search — "does pattern $P$ occur in text $T$, and where?" — using space close to the compressed size of $T$, not the original size. It combines two ideas: the Burrows–Wheeler Transform (BWT), which permutes $T$'s characters into an order that clusters repeated contexts together (making the result highly compressible), and rank/select on the transformed string, computed per-character exactly as described in this lesson's "sequence of bitvectors" view (or, at larger alphabets, via a wavelet tree/matrix over the transformed string's alphabet).
Search proceeds backward, one pattern character at a time, using a technique called LF-mapping: at each step, a constant number of rank queries on the BWT narrows a range of matching positions, without ever touching the original text. After processing all of $P$, the size of the final range is the number of occurrences, and each occurrence's original position can be recovered via periodic sampling plus more rank/select steps.
Building the actual BWT requires constructing a suffix array (or an equivalent structure) for $T$, which is a substantial topic of its own and is out of scope here. Treat this section as a pointer to where rank/select structures lead next: a later, more advanced string-algorithms curriculum entry is the right place for the full BWT-construction-to-FM-index treatment.
8. Complexity Analysis
Bitvector rank/select (length $n$ bits): $O(1)$ rank using $O(n)$ extra bits with the single-level block array implemented above, or $o(n)$ extra bits with the full two-level superblock/block scheme — both far below the naive $O(n \log n)$-bit prefix-popcount array. Select costs $O(\log(n/\text{block size}))$ via binary search over the rank index, or $O(1)$ with a dedicated select structure.
Wavelet tree / wavelet matrix (sequence length $n$, alphabet size $\sigma$): range k-th-smallest, rank-of-value, and range-frequency queries all cost $O(\log \sigma)$ time, using $O(n \log \sigma)$ total bits — close to the $n\lceil \log_2 \sigma \rceil$ bits that are the information-theoretic minimum needed just to store the sequence, with no query support at all.
9. Applications & Alternatives
Where succinct bitvectors and wavelet trees show up
- Succinct text indexes: FM-index-based full-text search engines and compressed suffix arrays answer substring and pattern-count queries in space close to the compressed text.
- Genomics & bioinformatics: read-alignment tools (in the style of BWA and Bowtie) store reference genomes as BWT-transformed, rank/select-indexed sequences to align millions of short reads within memory budgets that a plain string could never fit.
- Database column-stores: rank/select-backed bitmap indexes answer predicate and range-count queries over compressed columns without decompressing them first, and wavelet-tree-style structures answer order-statistic queries (medians, percentiles) over compressed columns directly.
10. Exercises & Mastery
Easy
- Given that
selectis already implemented, implementrank(i)on the 16-bit bitvector1011 0010 1101 0001by hand or code, without a precomputed block array — just by scanning and counting — then check your answer against the block-basedrank1from Section 5.
Medium
- Using the wavelet tree built over
sequence = [2, 1, 3, 1, 2, 4, 3, 2](alphabet $[1,4]$) from Section 6, implement a range-frequency query: how many times does value 2 appear in positions $[0, 7]$? Verify your traversal against a direct linear scan of the array.
Hard
- Build a wavelet tree over the new array
[6, 2, 5, 2, 6, 3, 1, 5, 3, 1](alphabet $[1,6]$), and trace the full recursion path — the alphabet split at each node, the resulting bitvector, and the remapped range at each level — to answer "what is the 4th smallest value in positions $[2, 8]$?" by hand before checking it against code.
Review rhythm: tomorrow, reconstruct the block-rank array and the rank/select formulas from memory; next week, re-derive the wavelet tree's range remapping (rank0/rank1 on $[l-1]$ and $[r]$) without looking it up; several weeks later, recognize the same "recurse on a balanced split of the value domain, not the position domain" idea in a new problem, such as a merge-sort tree or a persistent segment tree over values. Finally, teach someone the difference between "succinct" and "compressed" using the FM-index as the motivating example.
11. Summary Cheat Sheet
- Succinct means space close to the information-theoretic minimum while still supporting fast direct queries — not merely small.
rank(i)counts 1-bits in $[0,i]$;select(k)finds the position of the $k$-th 1-bit.- A two-level superblock/block index gives $O(1)$ rank using only $o(n)$ extra bits; a single-level block array (implemented here) gives $O(1)$ rank using $O(n)$ extra bits — both far cheaper than a naive $O(n \log n)$-bit prefix array.
selectcan binary-search the rank index in $O(\log(n/\text{block size}))$, or use a dedicated sampled structure for $O(1)$.- A wavelet tree recursively splits the alphabet in half; each node's bitvector routes elements to the correct child using only rank/select.
- Range k-th-smallest, rank-of-value, and range-frequency all cost $O(\log \sigma)$ using $O(n \log \sigma)$ bits total.
- A wavelet matrix stores the same information as one flat bitvector per alphabet-bit level instead of a tree of node-local bitvectors — same queries, better cache locality, and what real libraries actually ship.
- The FM-index layers BWT plus rank/select on the transformed string to search compressed text directly; full construction needs a suffix array and is covered elsewhere.