DSA DSA In Depth
0%
lesson suffix-arraymanber-myerssais

Suffix Arrays: Manber-Myers O(N log N) Prefix Doubling & SA-IS O(N) Overview

7 min read Module 4.2: Suffix Structures, Sequence Alignment & Data Compression Updated Oct 23, 2026
📄 Cheat Sheet
Suffix Array Construction via Prefix Doubling and Radix Sort
Pavel Mavrin Suffix Array Construction via Prefix Doubling and Radix Sort Watch on YouTube ↗

Explains Manber-Myers 2^k prefix doubling, equivalence classes, cyclic shift sorting, and how counting sort on the second component reduces O(N log^2 N) to O(N log N).

0:00 / --:--

1. Manber-Myers $O(N \log N)$ Prefix Doubling

Append a sentinel $\texttt{'\$'}$ (smaller than any alphabet character) to $S$ so its length is $N$, and sort all cyclic shifts of $S$ (which is identical to sorting all suffixes because $\texttt{'\$'}$ breaks ties before wrapping around). At iteration $k = 0, 1, \dots, \lceil \log_2 N \rceil$:

  • Maintain the array $\text{sa}[0..N-1]$ of starting indices sorted by their $2^k$-length prefixes, and the equivalence class array $c[i] \in [0, N-1]$ giving the lexicographical rank of $S[i .. i + 2^k - 1]$.
  • To transition from length $2^k$ to $2^{k+1}$, each position $i$ is represented by the 2-tuple of ranks $(c[i],\; c[(i + 2^k) \bmod N])$.
  • Single-Pass Counting Sort Trick: We do NOT need two counting sorts per step! Because $\text{sa}[0..N-1]$ is already sorted by the second component if we shift each index backward by $2^k$ ($\text{sa}'[j] = (\text{sa}[j] - 2^k + N) \bmod N$), we only need one stable counting sort on the first component $c[\text{sa}'[j]]$!
  • Early Exit Optimization: As soon as the number of distinct equivalence classes reaches $N$, all suffixes are strictly ordered and we terminate immediately without running remaining doubling steps.

2. SA-IS ($O(N)$ Suffix Array via Induced Sorting)

How does SA-IS (Nong, Zhang, Chan 2009) eliminate the $\log N$ factor? It classifies every suffix $i$ into one of two types in a single right-to-left scan:

  • S-type (Smaller): Suffix $S[i..]$ is lexicographically strictly smaller than the next suffix $S[i+1..]$, i.e., $S[i] < S[i+1]$ or ($S[i] == S[i+1]$ and $i+1$ is S-type). The sentinel $S[N-1] = \texttt{'\$'}$ is S-type by definition.
  • L-type (Larger): Suffix $S[i..]$ is strictly larger than $S[i+1..]$.
  • LMS (Leftmost S-type) Positions: An index $i > 0$ is an LMS position if $i$ is S-type and $i - 1$ is L-type. Notice that two adjacent indices can never both be LMS, so there are at most $N / 2$ LMS positions!

SA-IS sorts only the LMS substrings (recursively if LMS substrings have ties, on a string of length $\le N / 2$), places the sorted LMS suffixes at the ends of their character buckets, and then induces the sorted order of all L-type suffixes in one left-to-right pass (scanning $\text{SA}$ left-to-right: if $\text{SA}[j] - 1$ is L-type, place it at the front of bucket $S[\text{SA}[j] - 1]$) followed by all S-type suffixes in one right-to-left pass! By the recurrence $T(N) = T(N/2) + O(N) = O(N)$, SA-IS runs in linear time.

Construction Algorithm Time Complexity Working Space Practical Performance
Naive std::sort on String Slices $O(N^2 \log N)$ $O(N)$ Degrades catastrophically on long common prefixes
Doubling + std::sort on Pairs $O(N \log^2 N)$ $O(N)$ Short code (~20 lines), ~$80\text{ ms}$ for $N = 10^5$
Manber-Myers Doubling + Counting Sort $O(N \log N)$ $O(N)$ Fast (~$25\text{ ms}$ for $N = 5 \times 10^5$), early exits when ranks unique
SA-IS (Induced Sorting) / DivSufSort $O(N)$ $O(N)$ Fastest in practice (~$5\text{ ms}$ for $N = 5 \times 10^5$)
Bucket Partitioning & Induced Sorting Invariant

Within any first-character bucket $c \in \Sigma$, every L-type suffix starting with $c$ is strictly smaller than every S-type suffix starting with $c$! Why? An L-type suffix starting with $c$ is followed by $c \dots c d$ with $d < c$, whereas an S-type suffix starting with $c$ is followed by $c \dots c e$ with $e > c$. Thus every character bucket $[B_{\text{start}}(c), B_{\text{end}}(c))$ partitions cleanly into an L-sub-bucket at the head and an S-sub-bucket at the tail.

Cache Locality of Shift-By-2^k Counting Sort

In Manber-Myers doubling, computing pn[i] = sa[i] - (1 << k) and incrementing cnt[c[pn[i]]]++ performs sequential reads over sa. To maximize L1 cache hits during the bucket-distribution loop, keep the equivalence class array c[0..N-1] as a flat std::vector<int32_t> and terminate the outer loop the instant the number of equivalence classes classes == N.

3. Visual Trace: Suffix Array & L/S/LMS Classification on $S = \texttt{"banana\$"}$

SA-IS Type Classification on "banana$": Idx: Char: Type: LMS: 0bL 1aS* 2nL 3aS* 4nL 5aL 6$S* Note: i=5 ('a$') is L-type because 'a' > '$'! Sorted Suffix Array SA[0..6]: Rank SA[r] Suffix S[SA[r]..] 0 6 $ 1 5 a$ 2 3 ana$ 3 1 anana$ 4 0 banana$ 5 4 na$ 6 2 nana$
flowchart LR A["Init k=0: Counting Sort 1-char prefixes into SA[0..N-1] and classes c[0..N-1]"] --> B{"(1 << k) < N and classes < N?"} B -- "Yes" --> C["Shift second halves: pn[i] = (SA[i] - 2^k + N) mod N"] C --> D["Stable Counting Sort pn[] by primary key c[pn[i]] into SA[]"] D --> E["Recompute new equivalence classes cn[] from pairs (c[i], c[i + 2^k])"] E --> F["c = cn; k++"] F --> B B -- "No" --> G["Return SA[1..N-1] (drop sentinel '$')"]
Suffix Array, Kasai O(N) LCP Array & Suffix Automaton (SAM)
SA + Kasai LCP O(N) · ≤ 2N−1 SAM States

Inspect the sorted Suffix Array and Kasai’s LCP[i] invariant (h ≥ LCP[rank[i]−1] − 1) on S = "banana$", or step through Suffix Automaton (SAM) endpos equivalence classes and state-splitting on "abcbc".

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Kasai Invariant: LCP[rank[i]] ≥ LCP[rank[i−1]] − 1 (h decrements ≤ N times, increments ≤ N times -> O(N) total!)
🌳 Live 2D Hierarchical Tree Topology — Suffix Array, Kasai O(N) LCP Array & Suffix Automaton (SAM) Visited/Active Pivot/Root Resolved
LCP=0 SA=6 … [0] LCP=0 SA=5 … [1] LCP=1 SA=3 … [2] LCP=3 SA=1 … [3] LCP=0 SA=0 … [4] LCP=0 SA=4 … [5] LCP=2 SA=2 … [6]
[0]SA=6 "$"LCP=0
[1]SA=5 "a$"LCP=0
[2]SA=3 "ana$"LCP=1
[3]SA=1 "anana$"LCP=3
[4]SA=0 "banana$"LCP=0
[5]SA=4 "na$"LCP=0
[6]SA=2 "nana$"LCP=2
String S
"banana$" (N = 6)
∑ LCP[i]
0 + 1 + 3 + 0 + 0 + 2 = 6
Distinct Substrings
21 − 6 = 15
Step
1 / 4
[Step 1] Kasai at i=0 ("banana$"): rank=4, predecessor is "anana$" -> LCP = 0.

4. Production Implementation: $O(N \log N)$ Prefix Doubling + Pattern Binary Search

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

// Builds Suffix Array in O(N log N) time using Manber-Myers Prefix Doubling
std::vector<int> build_suffix_array(std::string_view str) {
    // Append '' sentinel (strictly smaller than any printable char)
    const int n = static_cast<int>(str.size()) + 1;
    const int alpha = 256;
    std::vector<int> p(n), c(n), cnt(std::max(alpha, n), 0);

    for (int i = 0; i < n - 1; ++i) cnt[static_cast<uint8_t>(str[i])]++;
    cnt[0]++; // Sentinel at n - 1
    for (int i = 1; i < alpha; ++i) cnt[i] += cnt[i - 1];
    for (int i = 0; i < n; ++i) {
        uint8_t ch = (i == n - 1) ? 0 : static_cast<uint8_t>(str[i]);
        p[--cnt[ch]] = i;
    }

    c[p[0]] = 0;
    int classes = 1;
    for (int i = 1; i < n; ++i) {
        uint8_t ch_cur = (p[i] == n - 1) ? 0 : static_cast<uint8_t>(str[p[i]]);
        uint8_t ch_prv = (p[i - 1] == n - 1) ? 0 : static_cast<uint8_t>(str[p[i - 1]]);
        if (ch_cur != ch_prv) ++classes;
        c[p[i]] = classes - 1;
    }

    std::vector<int> pn(n), cn(n);
    for (int h = 0; (1 << h) < n && classes < n; ++h) {
        int step = 1 << h;
        for (int i = 0; i < n; ++i) {
            pn[i] = p[i] - step;
            if (pn[i] < 0) pn[i] += n;
        }
        std::fill(cnt.begin(), cnt.begin() + classes, 0);
        for (int i = 0; i < n; ++i) cnt[c[pn[i]]]++;
        for (int i = 1; i < classes; ++i) cnt[i] += cnt[i - 1];
        for (int i = n - 1; i >= 0; --i) p[--cnt[c[pn[i]]]] = pn[i];

        cn[p[0]] = 0;
        classes = 1;
        for (int i = 1; i < n; ++i) {
            std::pair<int, int> cur = {c[p[i]], c[(p[i] + step) % n]};
            std::pair<int, int> prv = {c[p[i - 1]], c[(p[i - 1] + step) % n]};
            if (cur != prv) ++classes;
            cn[p[i]] = classes - 1;
        }
        c.swap(cn);
    }

    p.erase(p.begin()); // Remove the '' sentinel suffix at p[0] == n - 1
    return p;
}

// Returns half-open range [L, R) in SA where pattern P occurs as a prefix
std::pair<int, int> suffix_array_search(std::string_view text,
                                        const std::vector<int>& sa,
                                        std::string_view pat) {
    auto cmp_lower = [&](int idx, std::string_view q) {
        return text.substr(idx, q.size()) < q;
    };
    auto cmp_upper = [&](std::string_view q, int idx) {
        return q < text.substr(idx, q.size());
    };
    auto l = std::lower_bound(sa.begin(), sa.end(), pat, cmp_lower);
    auto r = std::upper_bound(l, sa.end(), pat, cmp_upper);
    return {static_cast<int>(l - sa.begin()), static_cast<int>(r - sa.begin())};
}
Cyclic Shift Sorting vs. Suffix Sorting Without a Sentinel

Manber-Myers prefix doubling sorts cyclic shifts modulo $N$ via (p[i] + step) % n. If you forget to append a sentinel character strictly smaller than every character in $S$, sorting cyclic shifts is NOT equivalent to sorting suffixes! For example, on $S = \texttt{"ab"}$, suffix $1$ is $\texttt{"b"}$ and suffix $0$ is $\texttt{"ab"}$ ($\texttt{"ab"} < \texttt{"b"}$), but on $S = \texttt{"ba"}$, cyclic shift $1$ wraps around to $\texttt{"ab"}$ whereas suffix $1$ is $\texttt{"a"}$. Appending $\texttt{'\0'}$ ensures every comparison hits the unique smallest sentinel at index $N - 1$ before wrapping around.

Check yourself Question 1 of 4
Concept Check Single-Counting-Sort Optimization in Manber-Myers Prefix Doubling recall

At step $h$ of Manber-Myers prefix doubling (transitioning from length $2^h$ to $2^{h+1}$), each cyclic shift $i$ is ranked by the pair $(c[i],\; c[(i + 2^h) \bmod N])$. Naive radix sort runs two counting sorts per step (one on the second element, then one on the first).

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

How did Manber and Myers reduce pattern search to $O(M + \log N)$ time without building a full Suffix Tree or FM-Index?

Key Takeaways

A Suffix Array SA[0..N-1] stores the starting indices of all suffixes of S in lexicographical order, using 4N bytes instead of a Suffix Tree's 20N-48N bytes.
Manber-Myers prefix doubling sorts suffixes by 2^k-length prefixes in O(N log N) time by shifting SA by -2^k and running one stable counting sort on the primary rank c[pn[i]] per doubling step.
SA-IS achieves O(N) suffix array construction by partitioning suffixes into L-type and S-type, recursively sorting at most N/2 Leftmost-S (LMS) positions, and inducing the full order in two linear sweeps.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue