DSA DSA In Depth
0%
lesson bwtfm-indexcompression

Burrows-Wheeler Transform (BWT), Last-to-First (LF) Mapping & FM-Index Substring Search

7 min read Module 4.2: Suffix Structures, Sequence Alignment & Data Compression Updated Oct 23, 2026
📄 Cheat Sheet
Burrows-Wheeler Transform and FM-Index Backward Search
Ben Langmead Burrows-Wheeler Transform and FM-Index Backward Search Watch on YouTube ↗

Ben Langmead clearly illustrates the BWT matrix, why the k-th occurrence of c in L is the exact same text character as the k-th occurrence of c in F, and how FM-Index backward search works.

0:00 / --:--

1. Constructing the BWT from the Suffix Array & Why It Compresses

Given $S[0..N-1]$ with unique smallest sentinel $S[N-1] = \texttt{'\$'}$ and its Suffix Array $\text{SA}[0..N-1]$, we never materialize the $N \times N$ rotation matrix $\mathcal{M}$. Row $r$ of $\mathcal{M}$ starts at $\text{SA}[r]$ and ends at $(\text{SA}[r] - 1 + N) \bmod N$, so the BWT string $L[0..N-1]$ is computed in one linear pass:

$$L[r] = \begin{cases} S[\text{SA}[r] - 1] & \text{if } \text{SA}[r] > 0 \\ S[N - 1] = \texttt{'\$'} & \text{if } \text{SA}[r] = 0 \end{cases}$$

Why does $L$ have long runs of identical characters? In English text, suffixes starting with $\texttt{"he "}$ are all grouped adjacently in $\text{SA}$; in almost every one of those suffixes, the character immediately preceding $\texttt{"he "}$ is $\texttt{'t'}$, producing a long contiguous run $\texttt{"tttt...t"}$ inside $L$! Applying Move-to-Front (MTF) coding, Run-Length Encoding (RLE), and Huffman/Arithmetic coding to $L$ is the entire pipeline of bzip2.

2. The Last-to-First (LF) Mapping & $O(N)$ Inversion

Why is the BWT reversible? Consider all rows in $\mathcal{M}$ whose last column $L[r]$ equals a specific character $c$. In those rows, the rest of the row $F[r..] $ spells the suffix immediately following that $c$. Since the rows of $\mathcal{M}$ are sorted by those suffixes $F[r..]$, the relative top-to-bottom order of all $c$'s in $L$ is identical to the relative top-to-bottom order of all rows starting with $c$ in $F$ (where $c$ is followed by the exact same sorted suffixes)!

Let $C[c]$ be the total number of characters in $S$ strictly smaller than $c$ (so the bucket for character $c$ in $F$ starts at row $C[c]$), and let $\text{Occ}(c, r)$ be the number of times character $c$ appears in the prefix $L[0..r-1]$ ($0$-indexed exclusive rank). Then row $r$ in $L$ maps to row $\text{LF}(r)$ in $F$:

$$\text{LF}(r) = C[L[r]] + \text{Occ}(L[r],\; r)$$

Starting from the row $r_0$ where $L[r_0] = \texttt{'\$'}$ (or the row corresponding to $\texttt{'\$'}$ in $F$, which is row $0$), repeatedly stepping $r \leftarrow \text{LF}(r)$ reconstructs $S$ backwards from right to left in $O(N)$ time!

3. FM-Index Backward Search in $O(M)$ Time

Even better, $C[c]$ and $\text{Occ}(c, r)$ allow us to count how many times a pattern $P[0..M-1]$ occurs in $S$ in $O(M)$ steps (faster than binary searching a Suffix Array, which takes $O(M \log N)$)! We process $P$ backwards from $j = M - 1$ down to $0$, maintaining the half-open Suffix Array interval $[sp, ep)$ of suffixes prefixed by $P[j..M-1]$:

$$sp_{\text{new}} = C[c] + \text{Occ}(c, sp), \qquad ep_{\text{new}} = C[c] + \text{Occ}(c, ep) \quad \text{where } c = P[j]$$

After $M$ steps, $ep - sp$ is the exact number of occurrences of $P$ in $S$!

Index Structure Index Space Count Occurrences of $P[0..M-1]$ Locate Each Match Offset
Suffix Tree $20N\text{–}48N$ bytes $O(M \log |\Sigma|)$ $O(1)$ per match
Suffix Array $4N$ bytes $O(M \log N)$ (or $O(M + \log N)$ w/ LCP) $O(1)$ per match
FM-Index (Wavelet Tree + Sampled SA) $0.5N\text{–}1.5N$ bytes $O(M \log |\Sigma|)$ ($O(M)$ for DNA/ASCII bitvectors) $O(\text{sample\_rate})$ via LF walks
LF-Mapping Rank Preservation & Backward-Search Invariant

LF Rank Preservation: For every character $c \in \Sigma$ and $k \ge 1$, the $k$-th occurrence of $c$ in the last column $L$ and the $k$-th occurrence of $c$ in the first column $F$ refer to the exact same index in the original text $S$.
FM-Index Interval: If $[sp, ep)$ is the exact contiguous range of rows in $\text{SA}$ prefixed by suffix $Q$, then $[C[c] + \text{Occ}(c, sp),\; C[c] + \text{Occ}(c, ep))$ is the exact range of rows prefixed by $cQ$.

Popcount Rank Bitvectors in Production FM-Indexes

Storing a full 2D table $\text{Occ}[|\Sigma|][N]$ would take $|\Sigma| \cdot 4N$ bytes. Instead, production genomics aligners (Bowtie2, BWA) store 64-bit bitmasks for each nucleotide ($\{\texttt{A}, \texttt{C}, \texttt{G}, \texttt{T}\}$) inside 64-byte cache-line blocks alongside a single checkpoint counter per block. Computing $\text{Occ}(c, r)$ then reduces to 1 memory load + 1 hardware __builtin_popcountll instruction!

4. Visual Trace: BWT Matrix & LF Mapping on $S = \texttt{"banana\$"}$

Row r SA[r] F Middle Rotated Chars L (BWT) LF(r) = C[L[r]] + Occ(L[r], r) 0 6 $ b a n a n a_1 C['a'](1) + 0 = 1 1 5 a_1 $ b a n a n_1 C['n'](5) + 0 = 5 2 3 a_2 n a $ b a n_2 C['n'](5) + 1 = 6 3 1 a_3 n a n a $ b_1 C['b'](4) + 0 = 4 4 0 b_1 a n a n a $ C['$'](0) + 0 = 0 5 4 n_1 a $ b a n a_2 C['a'](1) + 1 = 2 6 2 n_2 a n a $ b a_3 C['a'](1) + 2 = 3 BWT = "annb$aa" (Notice how the two 'n's and two 'a's cluster together!)
flowchart LR A["Init [sp, ep) = [0, N) and j = M - 1"] --> B{"j >= 0 and sp < ep?"} B -- "Yes: let c = P[j]" --> C["sp = C[c] + Occ(c, sp)"] C --> D["ep = C[c] + Occ(c, ep); j--"] D --> B B -- "No" --> E["Return max(0, ep - sp) matches in SA[sp..ep)"]
Burrows-Wheeler Transform (BWT) Rotation Matrix, FM-Index LF-Mapping & Thompson NFA
O(|P|) FM-Index Backward Search · O(NM) Thompson NFA

Trace the sorted cyclic rotation matrix of "banana$", LF-mapping rank queries shrinking [sp, ep] backward for P = "ana", and Ken Thompson’s lockstep NFA state-set simulation O(NM) vs O(2^N) backtracking ReDoS.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

BWT(banana$) = L = "annb$aa" (runs of "nn" and "aa" compress via RLE/MTF!). Backward search starts at P[2]='a': C['a']=1, range [sp, ep] = [1, 3].
📊 Live Magnitude & Pointer Stage — Burrows-Wheeler Transform (BWT) Rotation Matrix, FM-Index LF-Mapping & Thompson NFA Active Pivot Match/Sorted
0: $ba… [0] 1: a$b… [1] 2: ana… [2] 3: ana… [3] 4: ban… [4] 5: na$… [5] 6: nan… [6]
[0]0: $banan [a]
[1]1: a$bana [n]
[2]2: ana$ba [n]
[3]3: anana$ [b]
[4]4: banana [$]
[5]5: na$ban [a]
[6]6: nana$b [a]
BWT Last Column L
"annb$aa"
Matched Suffix of P
"a" (P[2])
SA Interval [sp, ep]
[0, 6] (7 rows)
Query Time
O(|P|) Backward Steps
[Step 1] Step 1 (char 'a'): C['a'] = 1, Occ('a', 7) = 3 -> all suffixes starting with "a" sit in SA range [1..3].

5. Production Implementation: BWT Inversion & FM-Index Backward Search

#include <array>
#include <cstdint>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

class FMIndex {
public:
    // Builds BWT and FM-Index from string s (terminated by '' at s.back()) and its SA
    FMIndex(std::string_view s_with_null, const std::vector<int>& sa)
        : n_(static_cast<int>(s_with_null.size())), bwt_(n_, ''), occ_(n_ + 1) {
        c_.fill(0);
        occ_[0].fill(0);
        for (int r = 0; r < n_; ++r) {
            int prev_idx = (sa[r] == 0) ? (n_ - 1) : (sa[r] - 1);
            uint8_t ch = static_cast<uint8_t>(s_with_null[prev_idx]);
            bwt_[r] = static_cast<char>(ch);
            c_[ch]++;
            occ_[r + 1] = occ_[r];
            occ_[r + 1][ch]++;
        }
        // Convert c_ into exclusive prefix sums (bucket start offsets in F)
        int sum = 0;
        for (int ch = 0; ch < 256; ++ch) {
            int cnt = c_[ch];
            c_[ch] = sum;
            sum += cnt;
        }
    }

    // Reconstructs the original string (excluding trailing '') in O(N) via LF-mapping
    std::string invert_bwt() const {
        std::string out(n_ - 1, '');
        int r = 0; // Row 0 in F starts with '', so L[0] is the last char of S
        for (int i = n_ - 2; i >= 0; --i) {
            uint8_t ch = static_cast<uint8_t>(bwt_[r]);
            out[i] = static_cast<char>(ch);
            r = c_[ch] + occ_[r][ch];
        }
        return out;
    }

    // Counts exact occurrences of pat in O(|pat|) time via FM-Index Backward Search
    int count_matches(std::string_view pat) const {
        int sp = 0, ep = n_;
        for (int j = static_cast<int>(pat.size()) - 1; j >= 0 && sp < ep; --j) {
            uint8_t ch = static_cast<uint8_t>(pat[j]);
            sp = c_[ch] + occ_[sp][ch];
            ep = c_[ch] + occ_[ep][ch];
        }
        return std::max(0, ep - sp);
    }

private:
    int n_;
    std::string bwt_;
    std::array<int, 256> c_{};
    std::vector<std::array<int, 256>> occ_;
};
Inclusive vs. Exclusive Occ(c, r) Indexing in FM-Index

The single most common bug when implementing FM-Index backward search is mixing up inclusive vs. exclusive rank queries $\text{Occ}(c, r)$. When maintaining a half-open Suffix Array interval $[sp, ep)$, $\text{Occ}(c, r)$ MUST count occurrences of $c$ in the prefix of length $r$ ($L[0..r-1]$, exclusive of index $r$), so that BOTH boundary updates use the exact same formula: $sp' = C[c] + \text{Occ}(c, sp)$ and $ep' = C[c] + \text{Occ}(c, ep)$!

Check yourself Question 1 of 4
Concept Check Relationship Between BWT and the Suffix Array recall

Let $S[0..N-1]$ be a string terminated by a unique smallest sentinel $S[N-1] = \texttt{'\$'}$, and let $\text{SA}[0..N-1]$ be its Suffix Array.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Given a match row $r$ in $[sp, ep)$ whose $\text{SA}[r]$ was NOT sampled, how does the FM-Index recover the exact text index $\text{SA}[r]$?

Key Takeaways

The Burrows-Wheeler Transform L[r] = S[(SA[r] - 1 + N) mod N] groups characters that precede identical contexts into long homogeneous runs ideal for Move-to-Front and Run-Length compression.
The Last-to-First (LF) property guarantees that the k-th occurrence of character c in L is the exact same text character as the k-th occurrence of c in F, enabling O(N) inversion via LF(r) = C[L[r]] + Occ(L[r], r).
FM-Index backward search processes pattern P from right to left (j = M - 1 down to 0) updating [sp, ep) = [C[c] + Occ(c, sp), C[c] + Occ(c, ep)) to count occurrences in O(M) time.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue