Suffix Trees and Suffix Arrays
Imagine searching a genome three billion characters long for every occurrence of a short DNA motif. Or imagine finding the longest repeated substring in a codebase to detect plagiarism. A naive scan re-reads the text for every query. This costs time proportional to text length times query count. Real systems — genome browsers, full-text search engines, data-compression tools like bzip2 — need to answer thousands of such substring questions against a fixed text. They cannot afford to pay linear-scan cost each time. The suffix tree and suffix array are index structures built once over a text. They are built in time linear or near-linear in its length. Afterward, substring search, longest-common-substring queries, and repeat-finding all run in time proportional to the query size. This is rather than the text size. They encode, implicitly or explicitly, every one of the text's suffixes. This encoding exposes shared prefixes as shared structure — a trie compressed into a tree, or an array of suffixes sorted lexicographically. This article develops the suffix array augmented with the longest-common-prefix (LCP) array as the primary, practical structure. It achieves the same asymptotic query power as the suffix tree. It does so with a fraction of the memory footprint — a decisive advantage for large texts. We build the ideas up from the naive sort. We move through the classical Manber–Myers doubling algorithm (). We continue to the linear-time Kasai algorithm for the LCP array. We connect the whole structure back to the conceptually simpler suffix tree. Building on Knuth-Morris-Pratt String Matching, which answers a single pattern-matching query in linear time against a fixed text scanned once, this article shows how to preprocess the text. This preprocessing lets many pattern queries be answered fast. Structural queries about repeats are also answered fast. It also contrasts index-based search with Introduction to Hashing techniques such as rolling hashes. These techniques trade exactness and worst-case guarantees for simplicity.
- A suffix array is the sorted list of starting positions of all suffixes of a text; it supports substring search via binary search in time.
- The LCP array records, between consecutive suffixes in sorted order, the length of their longest common prefix — it upgrades the suffix array to answer longest-common-substring and repeat queries.
- Manber–Myers prefix doubling builds the suffix array in time using radix-style rank sorting rounds that double the compared prefix length each pass.
- Kasai's algorithm computes the LCP array in time given the suffix array, by exploiting an amortized argument about how little the matched length can drop between suffixes.
- A suffix tree stores the same information as a compressed trie of all suffixes; it supports the same and additional queries in optimal time but at roughly – the memory of a suffix array.
- Suffix arrays with LCP information underlie tools such as bzip2's BWT stage, bioinformatics aligners, and full-text search engines that must index gigabyte-scale corpora.
Sorting all suffixes of a text turns every substring question into a binary-search or range question over one array, trading a one-time build for or better queries forever after.
The idea
A suffix of a string of length is the substring starting at position , for . There are exactly suffixes, one per starting index, including the full string itself () and the single last character (). The suffix array of is a permutation of listing suffix start positions in lexicographically increasing order of the suffixes they name. To make comparisons total and well-defined, we append a unique sentinel character `SPSPSAPPO(m \log n)m = |P|SA\mathrm{LCP}[i]SA[i-1]SA[i]\mathrm{LCP}[0]0SS\binom{n}{2}\sum_i (n - SA[i]) - \mathrm{LCP}[i]SALCPniSA[i]$. This equivalence is the conceptual bridge: a suffix array is a suffix tree with the tree structure flattened into two arrays, at large memory savings.
How it works
We build the structure in two stages: first sort the suffixes to get , then derive from .
Naive construction. Sorting all suffixes directly with a general comparison sort costs comparisons, but each comparison between two suffixes can itself take time in the worst case (e.g., text `aaaa...a`), giving overall — too slow for texts beyond a few thousand characters.
Manber–Myers prefix doubling. The efficient approach sorts suffixes not by their full content at once, but by progressively longer prefixes, doubling the compared length each round: first by 1-character prefixes, then by 2-character prefixes (reusing the 1-character ranks), then 4, then 8, and so on, until the compared prefix length exceeds . After rounds, all ties are broken and the ranks give the exact sorted order. The trick that makes each round fast: a suffix's rank by -character prefix is determined by the pair (rank-by--prefix of itself, rank-by--prefix of the suffix positions later). Sorting these pairs by radix sort (or a comparison sort) takes or per round; with rounds the total is with comparison sorting, or with radix sorting of the pairs — the latter is the standard bound quoted for this algorithm. ``` function BUILD-SUFFIX-ARRAY(S): n = length(S) append sentinel '$' to S # smaller than all characters n = n + 1
# round 0: rank by single character rank = array of size n for i in 0..n-1: rank[i] = ORD(S[i]) SA = [0, 1, ..., n-1] k = 1 while k < n:
# comparator: compare (rank[i], rank[i+k] or -1) pairs define key(i): first = rank[i] second = rank[i + k] if i + k < n else -1 return (first, second) sort SA by key(·) # O(n log n) comparison sort
# (or O(n) via radix sort on pairs)
# recompute ranks: equal keys get equal rank newRank = array of size n newRank[SA[0]] = 0 for idx in 1..n-1: prevSuffix = SA[idx - 1] currSuffix = SA[idx] if key(currSuffix) == key(prevSuffix): newRank[currSuffix] = newRank[prevSuffix] else: newRank[currSuffix] = newRank[prevSuffix] + 1 rank = newRank if rank[SA[n-1]] == n - 1: break # all suffixes fully distinguished k = k 2 return SA ``` Once is built, <strong>Kasai's algorithm</strong> computes in linear time by exploiting a monotonicity property: if suffix has LCP value with its predecessor in sorted order, then suffix (the suffix obtained by chopping one character off the front) has LCP at least with its predecessor in sorted order. This lets us walk through the text in original index order (not sorted order), maintaining a running match-length counter that only ever decreases by 1 per step, so its total* decrease across the whole pass is bounded by even though it may increase arbitrarily at each step. ``` function BUILD-LCP-ARRAY(S, SA): n = length(S) rankOfSuffix = array of size n # inverse permutation of SA for i in 0..n-1: rankOfSuffix[SA[i]] = i LCP = array of size n, all zero h = 0 for i in 0..n-1: if rankOfSuffix[i] > 0: j = SA[rankOfSuffix[i] - 1] # predecessor suffix in sorted order while i + h < n and j + h < n and S[i + h] == S[j + h]: h = h + 1 LCP[rankOfSuffix[i]] = h if h > 0: h = h - 1 else: h = 0 return LCP ``` Substring search against the finished structure uses two binary searches over , comparing the query pattern character by character against the suffix starting at each candidate position: ``` function SEARCH-RANGE(S, SA, P): lo = LOWER-BOUND(S, SA, P) # first index with suffix >= P lexicographically hi = UPPER-BOUND(S, SA, P) # first index with suffix > (P followed by max char) if lo == hi: return "not found" return SA[lo .. hi - 1] # all starting positions of occurrences of P ``` Each comparison in the binary search costs up to character comparisons, so the whole search costs . With additional preprocessing (the LCP array combined with the range-minimum-query technique), this bound improves to , matching suffix-tree query time.
Complexity & correctness
Suffix array construction. The Manber–Myers doubling scheme runs rounds (since the compared prefix length doubles from 1 to at least ). Each round performs a sort of rank-pairs. Using a comparison sort this costs per round, giving an overall ; using radix sort on the bounded-range pair keys, each round drops to , giving the commonly quoted total. (Truly linear-time suffix array construction algorithms — DC3/skew, SA-IS — exist and run in , but they are substantially more intricate; the doubling algorithm is the standard teaching and practical choice, and is what production libraries often ship for moderate-size texts.) Correctness of doubling follows by induction on the round index , where the invariant is: after round (comparing -character prefixes), suffixes are correctly grouped and ordered by their first characters, with equal-key suffixes tied at equal rank. The base case holds trivially by definition of the initial rank array (single characters). For the inductive step, two suffixes' first characters are compared exactly by the pair (first characters of self, first characters of the suffix shifted right by ) — this pair is the rank pair used as sort key, and by the inductive hypothesis the ranks used already correctly order -character prefixes. So sorting by these pairs correctly orders -character prefixes. The loop terminates once , because with distinct suffixes over a text of length (plus sentinel), a prefix length of characters uniquely distinguishes every suffix — the sentinel guarantees no suffix is a proper prefix of another, so no ties can survive that long.
LCP construction (Kasai). The algorithm runs in time. The correctness argument rests on one lemma: if (in rank terms), then when we move to suffix (one character shorter), its LCP with its rank-predecessor is at least . This holds because removing the first character from both and its predecessor (which shared characters, ) yields and , which still share the last of those characters; and since suffix order is compatible with this shift (a known lexicographic fact: if and both start with the same character, then and remain correctly ordered relative to some suffix no worse in rank distance), the actual predecessor of in the array shares at least those characters. So the match-length variable `h` in the pseudocode never needs to restart from 0; it only decreases by at most 1 per outer-loop iteration, while the inner `while` loop can only increase `h`. Summing increases and decreases: total decreases across the whole run are at most (h decreases by at most 1, times), so total increases (the work done in all inner-loop iterations combined) are also bounded by — this is a standard amortized analysis via a potential function equal to the current value of `h`.
Substring search. Binary search over sorted suffixes takes comparisons; each suffix-vs-pattern comparison costs up to character operations in the worst case, giving total — honest and simple, though not optimal. Precomputing, for each binary-search step, the LCP information between the query and known boundary suffixes (a technique using the LCP array plus a sparse table for range-minimum queries) removes redundant character comparisons and achieves .
Suffix tree comparison. A suffix tree, built via Ukkonen's algorithm, achieves construction time and pattern search time (no logarithmic factor) — asymptotically better for search — but at the cost of substantially higher constant-factor memory: each internal node needs child pointers (often via a hash map or fixed-size array over the alphabet), suffix links, and edge-label ranges, typically costing 10–20 bytes per character of text versus roughly 4–8 bytes per character for a suffix array plus LCP array packed as integer arrays. In memory-constrained settings — indexing a multi-gigabase genome, for instance — the suffix array's compactness often outweighs its extra query factor, which is why practical bioinformatics tools (e.g., BWA, based on the closely related FM-index/Burrows–Wheeler transform) favor array-based structures.
Honest tradeoffs. The suffix array is not asymptotically optimal for single-pattern search compared to a suffix tree, and building it in true linear time requires the more complex DC3/SA-IS family of algorithms rather than the doubling method shown here. The doubling method's bound (with radix sort) is a solid, teachable, and practically fast compromise — but if is enormous (billions of characters) and construction time dominates, production systems reach for SA-IS. Also, unlike a hash-based index (see Introduction to Hashing), the suffix array gives exact, worst-case guaranteed bounds with no probability of collision-driven failure, at the cost of the or overhead hashing usually avoids for simple exact-match lookups — but hashing cannot efficiently answer range queries like "find all suffixes with a given prefix" or "find the longest repeated substring," which is precisely where the sorted suffix structure wins.
Worked example
Take (the sentinel `$` is smaller than any letter). Its 7 suffixes, indexed by start position:
| start | suffix |
|---|---|
| 0 | banana$ |
| 1 | anana$ |
| 2 | nana$ |
| 3 | ana$ |
| 4 | na$ |
| 5 | a$ |
| 6 | $ |
Trace of prefix doubling (round by round). Round 0 (, sort by 1 character): initial ranks by character: `[2,1,3,1,3,1,0]SA = [6,1,3,5,0,2,4]k=2") has key (rank[1], rank[3]) = (1,1); suffix 3 ("ana") has key (1, rank[7]) = (1, -1) — distinguished from the others since it's shorter. After sorting by these pairs and reassigning ranks, "a" and "ananak=4" vs "anana" ends, "anana" from "nak=8 \geq n=7k=4$, so the break condition `rank[SA[n-1]] == n-1` fires early) — final:
corresponding to suffixes: ``, `ana`, `banana`, `nana$` — correctly sorted lexicographically (sentinel first, then the `a`-suffixes shortest-to-longest by shared-prefix rule, then `b`, then `n`-suffixes).
Kasai LCP pass, walking in original text order (not sorted order), using `rankOfSuffix` (the inverse of ):
| (text index) | rankOfSuffix[i] | predecessor | matched | LCP[rank] |
|---|---|---|---|---|
| 0 | 4 | compare "banana" → 0 | LCP[4]=0 | |
| 1 | 3 | compare "anana" → "ana" matches = 3 | LCP[3]=3 | |
| 2 | 6 | compare "nana" → "na" = 2 | LCP[6]=2 | |
| 3 | 2 | compare "ana" → "a" = 1 | LCP[2]=1 | |
| 4 | 5 | compare "na" → 0 | LCP[5]=0 | |
| 5 | 1 | compare "a" → 0 | LCP[1]=0 | |
| 6 | 0 | none (rank 0) | h reset to 0 | LCP[0]=0 |
Final (indexed by sorted rank, matching the figure above). The maximum LCP value is 3, at rank 3, confirming the longest repeated substring is "ana" — matching the figure's highlighted rows. A query for binary-searches and finds the contiguous block at ranks 2–3 (suffixes "ana"), giving occurrence positions , exactly as read off the sorted suffix column.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Forgetting the sentinel character | Suffixes can then be prefixes of one another, breaking total order and causing infinite/incorrect comparisons | Always append a unique symbol smaller than the whole alphabet before building |
| Off-by-one in LCP indexing | is defined between rank and rank ; easy to misalign with rank and | Fix a single convention (predecessor-based) and use it consistently in both build and query code |
| Re-scanning characters in binary search naively | Comparing full pattern against full suffix each step gives , sometimes mistaken for | State the bound honestly; use LCP + range-minimum-query trick only if the tighter bound is actually needed |
| Assuming doubling is regardless of the inner sort | If each round uses a naive comparison sort instead of radix/counting sort on bounded rank pairs, real cost is | Use counting/radix sort on the rank-pair keys to hit the true bound |
| Treating suffix array as always superior to suffix tree | Suffix array needs (or extra machinery) per query where suffix tree needs flat | Choose based on memory budget vs. query-time requirements, not by default habit |
| Ignoring integer overflow / large-alphabet rank arrays | Rank values grow up to ; naive fixed-width types can truncate for very large texts | Use appropriately sized integer types matched to |
Pioneers & history
The suffix tree was introduced by Peter Weiner in 1973 as the first linear-time construction of an index encoding all substrings of a text, though his original algorithm was memory-hungry and complex to implement. Edward McCreight simplified the construction in 1976, and Esko Ukkonen gave the now-standard online linear-time construction in 1995, building the tree incrementally character by character — a landmark in string algorithmics for its elegance and its amortized-analysis proof technique. The suffix array, designed explicitly as a space-efficient alternative, was introduced by Udi Manber and Gene Myers in 1990, alongside their doubling construction; Kasai, Lee, Arimura, Arikawa, and Park published the linear-time LCP-array construction in 2001, completing the suffix-array toolkit to match suffix-tree query power at a fraction of the memory.
Origins · 1 minUdi Manber and how this was discovered
Manber, working with Gene Myers at the University of Arizona, co-designed the suffix array specifically to address the practical memory blowup of suffix trees on large genomic and textual datasets, later applying string-indexing ideas at Yahoo! and Google's search infrastructure teams.
Where this matters
Full-text search engines index large static corpora (source-code repositories, legal documents, literary corpora) using suffix-array-style structures to answer substring and phrase queries without scanning the whole corpus per query. Bioinformatics tools for genome alignment (short-read mappers, genome browsers) rely on the closely related Burrows–Wheeler transform and FM-index, both of which are built directly from the suffix array of the reference genome, to align millions of reads per run against multi-gigabase references. Data-compression algorithms such as bzip2 use the Burrows–Wheeler transform — itself computed from a sorted rotation array intimately related to the suffix array — as their core reversible transform before entropy coding. Plagiarism detectors and code-clone finders use the LCP array directly: the longest repeated substring and the longest common substring between two concatenated documents fall out of LCP maxima with almost no extra machinery. Version-control and diff tools, and biological repeat-finding pipelines (finding tandem repeats, transposable elements), similarly lean on suffix structures because they turn an pairwise-comparison problem into an preprocessing step followed by linear-time repeat enumeration.
:::quiz[What does SA[i] store in a suffix array of length n?]
- The i-th character of the text
- The starting index of the i-th lexicographically smallest suffix ✓
- The length of the i-th suffix
- The hash value of the i-th suffix
:::quiz[What is LCP[i] conventionally defined as?]
- The length of the longest common prefix between suffix SA[i] and suffix SA[i+1]
- The length of the longest common prefix between suffix SA[i-1] and suffix SA[i] ✓
- The total number of distinct substrings up to position i
- The number of times character S[i] appears in the text
Ask the tutor: How would you extend a suffix array and LCP array to efficiently find the longest common substring shared across three or more input texts simultaneously?
Why append a sentinel character like `$` to the text?
Without a unique, alphabet-minimum sentinel, one suffix can be a literal prefix of another (e.g., "ana" is a prefix of "anana"). Comparison-based sorting then has no natural way to break the tie, since one string simply runs out of characters mid-comparison. The sentinel guarantees every suffix has a distinct final character and thus a well-defined total order, ensuring the sort terminates correctly and every suffix receives a unique rank. It also ensures no suffix can match past the end of the text during LCP or binary-search comparisons, since the sentinel differs from every real character and stops any spurious match from extending further than intended.
Why not just use a hash table of all substrings instead of a suffix array?
A hash table can answer "does this exact substring exist?" in expected O(1) time after O(n²) preprocessing (or less with rolling hashes per length), but it cannot efficiently answer range-style questions: "what is the longest repeated substring," "list all suffixes starting with this prefix," or "what is the longest common substring between two texts." Those require the ordering information a sorted suffix structure provides. A suffix array also gives worst-case guarantees rather than expected-case guarantees, which matters when adversarial or pathological inputs (many repeated substrings) are possible, since hash-based approaches can degrade under collisions or need per-length hashing to be complete.
How does the suffix array relate to the Burrows–Wheeler transform (BWT)?
The BWT of a text is obtained by sorting all cyclic rotations of the text and taking the last column of the sorted matrix. If the text ends with a sentinel that never repeats, sorting cyclic rotations is equivalent to sorting suffixes, because after the sentinel a rotation just "wraps" with characters that never get compared past it. So the BWT's sorted order is exactly the suffix array's order, and the BWT last-column value at rank i is simply S[SA[i] - 1] (with wraparound). This is why suffix-array construction algorithms are directly reusable to build the BWT, and why tools like bzip2 and the FM-index share so much machinery with suffix-array indexing.
Can the suffix array be built in true linear time, not O(n log n)?
Yes — algorithms such as DC3/skew (Kärkkäinen–Sanders) and SA-IS (Nong, Zhang, Chen) build the suffix array in O(n) time, matching suffix-tree construction asymptotically. They are considerably more intricate: DC3 recursively sorts suffixes at positions ≡1,2 (mod 3) and merges with position-0 suffixes using clever encoding tricks, while SA-IS classifies suffixes into S-type and L-type and induces the full sort from a recursively-sorted subset. The O(n log n) doubling algorithm remains popular in teaching and in many production settings because it is far simpler to implement correctly, and the log n factor is often negligible relative to constant-factor overheads at practical text sizes.
How do you find the longest common substring between two different texts using this structure?
Concatenate the two texts with a unique separator between them (and a sentinel at the end), build one suffix array and LCP array over the concatenation, then scan the LCP array for the maximum value achieved between two suffixes that originate from different original texts (checkable by comparing each suffix's starting index against the length of the first text). This works because any common substring of the two texts must appear as a shared prefix of some suffix from text one and some suffix from text two, and in sorted order such suffixes are necessarily near each other, making their shared-prefix length visible as a (possibly non-adjacent, but boundable) LCP value using range-minimum queries between their ranks.
What is the actual query-time cost of substring search, precisely?
Plain binary search over the suffix array costs O(m log n): each of the O(log n) binary-search steps compares the pattern against a candidate suffix, and each such comparison can take up to O(m) character comparisons in the worst case (when many characters match before a mismatch). Augmenting with the LCP array and a sparse table for range-minimum queries lets each binary-search step reuse previously-computed match lengths, avoiding redundant character comparisons and reducing total search cost to O(m + log n) — but this requires extra O(n log n)-space preprocessing for the RMQ structure, so it is a genuine space-for-speed tradeoff, not a free upgrade.
When should I prefer a suffix tree over a suffix array in practice?
Prefer a suffix tree when query-time optimality matters more than memory — for example, repeated online pattern matching against a moderately sized fixed text where O(m) search (no log factor) meaningfully matters, or when you need suffix links for algorithms like Ukkonen's construction itself or certain tree-based string algorithms (e.g., computing matching statistics, generalized suffix tree multi-string queries). Prefer a suffix array with LCP array when memory is the binding constraint — indexing genome-scale or web-scale text — since it typically uses several times less memory per character while still supporting nearly all the same query types with only a logarithmic-factor query-time penalty.