Lab 11 — Suffix Array & Kasai LCP
Goal
Build a suffix array in O(n log n) via prefix doubling, compute the LCP array in O(n) with Kasai’s algorithm, and apply the pair to the standard problems: longest repeated substring, distinct-substring counting, and pattern search.
Background
The suffix array sa is the array of starting indices of all suffixes of s, sorted lexicographically. It is the flat, cache-friendly cousin of the suffix tree.
Prefix doubling: sort suffixes by their first 2^k characters using rank pairs. If rank[i] orders suffixes by their first k characters, then the pair (rank[i], rank[i+k]) orders them by their first 2k. After ≤ log n rounds every rank is distinct and the sort is total.
Kasai’s insight: let lcp[r] = longest common prefix of suffixes sa[r-1] and sa[r]. If suffix starting at i shares h characters with its lexicographic predecessor, then suffix i+1 (drop one char) shares at least h−1 with its predecessor. So process suffixes in text order, never resetting h below h−1: total work O(n).
What the pair buys you:
- Longest repeated substring = max(lcp)
- Distinct substrings = n(n+1)/2 − Σlcp (each suffix contributes its length minus the overlap already counted)
- Pattern matching: binary search the SA in O(m log n)
- Longest common substring of two strings: concatenate with a separator, take max lcp across suffixes from different halves
SA + LCP is equivalent in power to a suffix tree — an in-order traversal of the suffix tree visits leaves in SA order, and internal-node string depths are exactly the LCP values. The suffix automaton (lab-05) is the third member of the family: same problems, different traversal idioms.
Interview Context
- Codeforces / ICPC: core string tool, expected at Div 1 level
- Bioinformatics roles: fundamental (genome alignment, FM-index)
- LC 1044 (Longest Duplicate Substring, Hard): SA+LCP is the clean deterministic answer; most solutions use binary search + rolling hash and pray about collisions
- Systems/search-infra roles: occasionally, as a design discussion (log-structured text indexes)
- Standard FAANG: near zero — rolling hash (phase-03 lab-06) covers the rare string-Hard
When to Skip This Topic
Skip if any of these are true:
- You haven’t mastered KMP (phase-03 lab-05) and rolling hash (phase-03 lab-06) — those cover 95% of interview string questions
- You’re not targeting competitive programming or bioinformatics
- You can’t state what a suffix even is without hesitating
One LC Hard justifies this lab. If LC 1044 isn’t on your target list, skim the Background and move on.
Problem Statement
Longest Duplicate Substring (LC 1044).
Given a string s, return any duplicated substring of maximum length — a contiguous substring that occurs two or more times (occurrences may overlap). Return "" if none exists.
Along the way, implement the general toolkit: suffix_array(s), kasai(s, sa), distinct_substrings(s).
Constraints
- 2 ≤ |s| ≤ 3×10^4 (LC); toolkit should handle 10^5+
- Lowercase English letters
- Wall-clock: < 2 sec in Python
Clarifying Questions
- Can occurrences overlap? (Yes — “aaaa” → “aaa”.)
- Any answer of maximum length, or all of them? (Any one.)
- Alphabet size — does it matter? (Only for radix-sort variants; comparison sort doesn’t care.)
- Empty answer possible? (Yes, when all characters are distinct.)
Examples
s = "banana"
sa = [5, 3, 1, 0, 4, 2] (a, ana, anana, banana, na, nana)
lcp = [0, 1, 3, 0, 0, 2]
longest duplicate = "ana" (lcp = 3)
distinct substrings = 6·7/2 − 6 = 15
s = "abcd" → "" (all lcp = 0)
s = "aaaaa" → "aaaa"
Brute Force
For each length L from n−1 down: put all substrings of length L in a set; if one repeats, return it. O(n²) substrings × O(n) hashing = O(n³) worst case. For n = 3×10^4: hopeless.
Brute Force Complexity
- Time: O(n³) (O(n²) with rolling hash per length, still too slow without the binary search)
- Space: O(n²) for the substring set
Optimization Path
- Binary search on length + rolling hash: O(n log n) expected, but collision-correct only if you verify matches — most posted solutions don’t. (Cross-ref phase-03 lab-06.)
- Suffix array + LCP: two suffixes share a prefix of length L iff some adjacent SA pair has lcp ≥ L (sorted order puts the most similar suffixes next to each other). So the answer is simply max(lcp) — deterministic, no hashing.
- Build SA by doubling (log n rounds of sorting rank pairs), LCP by Kasai in O(n).
Final Expected Approach
def suffix_array(s):
n = len(s)
sa = list(range(n))
rank = [ord(c) for c in s]
k = 1
while True:
key = lambda i: (rank[i], rank[i + k] if i + k < n else -1)
sa.sort(key=key)
tmp = [0] * n
for i in range(1, n):
tmp[sa[i]] = tmp[sa[i - 1]] + (key(sa[i]) > key(sa[i - 1]))
rank = tmp
if rank[sa[-1]] == n - 1: # all ranks distinct: done
return sa
k <<= 1
def kasai(s, sa):
n = len(s)
rank = [0] * n
for pos, suf in enumerate(sa):
rank[suf] = pos
lcp = [0] * n # lcp[r] = LCP(sa[r-1], sa[r]); lcp[0] = 0
h = 0
for i in range(n): # suffixes in TEXT order
if rank[i] > 0:
j = sa[rank[i] - 1]
while i + h < n and j + h < n and s[i + h] == s[j + h]:
h += 1
lcp[rank[i]] = h
if h:
h -= 1 # next suffix loses at most one char of overlap
else:
h = 0
return lcp
def distinct_substrings(s):
if not s:
return 0
return len(s) * (len(s) + 1) // 2 - sum(kasai(s, suffix_array(s)))
def longest_dup_substring(s):
if len(s) < 2:
return ""
sa = suffix_array(s)
lcp = kasai(s, sa)
r = max(range(len(s)), key=lambda r: lcp[r])
return s[sa[r]: sa[r] + lcp[r]]
Honesty note: Python’s Timsort makes each doubling round O(n log n), so this is O(n log² n) total. Replacing the sort with two-pass radix sort on the rank pairs gives true O(n log n). In Python the sort version is usually faster anyway (C-level sort vs interpreted radix loops).
Data Structures
sa: int array, suffix start indices in sorted orderrank: inverse-ish array, rank of each suffix among first-2^k-char prefixeslcp: int array aligned to SA positions
Correctness Argument
- Doubling invariant: after round k,
rankorders suffixes by their first 2^k characters; the pair sort extends this to 2^(k+1). Missing tail = rank −1 sorts shorter suffixes first, matching lexicographic order. - Termination: ranks strictly refine each round; all distinct within log n rounds.
- Kasai bound: if lcp(suffix i, predecessor) = h, then suffix i+1 and some suffix (the predecessor minus its first char) share h−1 chars; its true predecessor in SA order shares ≥ h−1.
hincreases ≤ n total and decreases ≤ n total → O(n). - Max-lcp = longest repeat: any two suffixes sharing prefix length L sandwich adjacent pairs each with lcp ≥ L (LCP of a range = min of adjacent lcps in it).
Complexity
- SA: O(n log² n) with comparison sort, O(n log n) with radix
- Kasai: O(n)
- Space: O(n)
Implementation Requirements
- Handle
i + k ≥ n: shorter suffix ranks first (use −1 sentinel) - Early exit once all ranks distinct (big win on random strings)
lcp[0]is 0 by definition — don’t read it as a real pair- Kasai must iterate suffixes in text order, not SA order
- Return indices, not substring copies, until the final answer (avoid O(n²) slicing)
Tests
suffix_array("banana") == [5, 3, 1, 0, 4, 2];kasai == [0, 1, 3, 0, 0, 2]- LC 1044: “banana” → “ana”; “abcd” → “”; “aaaaa” → “aaaa”
distinct_substrings("banana") == 15; “aaa” → 3- Stress: random strings over {a,b} and {a,b,c}, n ≤ 40, vs
sorted(range(n), key=lambda i: s[i:])and a set-based distinct count — 500 trials - Performance: n = 5×10^4 random binary string, SA+LCP < 1 sec (measured ~0.15 s)
Follow-up Questions
- Can you build the SA in O(n)? → Yes: SA-IS (induced sorting) and DC3/skew. Name them, don’t implement in an interview — doubling is the expected answer, linear constructions are a library job.
- SA vs suffix automaton vs suffix tree — which do you reach for? → SA+LCP for offline, index-shaped questions (k-th substring, counting, binary search). Suffix automaton (lab-05) for online/DP-over-states questions (distinct substrings incrementally, longest common substring streaming). Suffix tree almost never by hand — SA+LCP simulates it.
- LCP of two arbitrary suffixes, many queries? → LCP(sa[i..j]) = min of adjacent lcp values in the range → sparse-table RMQ, O(1) per query after O(n log n) build (phase-03 lab-04).
- Count occurrences of pattern p? → Two binary searches over SA for the range of suffixes prefixed by p: O(m log n), then range length. Beats KMP when the text is fixed and patterns stream in.
- Burrows-Wheeler transform connection? → BWT[i] = s[sa[i] − 1]: the character before each sorted suffix — the SA’s “last column”. bzip2 compresses it; the FM-index inverts it for O(m) pattern counting in compressed space. This is why read aligners (BWA, Bowtie) are SA machinery underneath.
- Longest common substring of two strings? → Build SA of
s1 + '#' + s2, answer = max lcp over adjacent pairs whose suffixes start in different halves.
Product Extension
- Genome aligners: BWA/Bowtie build FM-indexes from suffix arrays of reference genomes
- Plagiarism / clone detection: longest common substring across document pairs
- Log deduplication: longest repeated substring flags templated spam
- Full-text search indexes over static corpora (SA is simpler than an inverted index for substring — not word — queries)
Language/Runtime Follow-ups
- Python: the
key=lambdasort is fine to n ≈ 10^5; beyond that, precompute key tuples or use the two-array radix formulation. - C++: classic contest snippet ~30 lines;
std::sortversion handles 10^6 easily. - Java: beware
substringpre-Java-7u6 O(1) myths — always compare via indices. - Rust:
suffixcrate implements SA-IS; hand-rolled doubling withsort_unstable_by_keyis idiomatic.
Common Bugs
- Sorting by single rank instead of the pair: converges to first-character order and loops forever or returns garbage.
- Rank assignment off-by-one:
tmp[sa[i]]must compare keys, not old ranks alone — two suffixes with equal pairs must get equal new ranks. - Kasai iterated in SA order: the h ≥ h−1 amortization only holds in text order; wrong order gives O(n²).
- Forgot
h -= 1: silently correct output but O(n²) worst case (“aaaa…”). - Missing tail sentinel: treating
rank[i+k]as 0 instead of −1 misorders “aa” vs “aab” suffixes. - Separator collision in two-string concat: the separator must sort below every real character and appear nowhere in either string.
Debugging Strategy
- Print all suffixes next to
safor a 6-char string; eyeball the sort - Assert
sorted(sa) == range(n)(it’s a permutation) after every doubling round - Check
lcpby direct prefix comparison on small inputs - Binary alphabet stress first — repeats are dense, off-by-ones surface immediately
- If distinct-substring count is wrong, the bug is almost always in
lcp, notsa
Mastery Criteria
- Implement doubling SA from memory in ≤ 25 min
- Implement Kasai in ≤ 15 min and explain the h−1 amortization without notes
- Derive the distinct-substring formula on a whiteboard
- Solve LC 1044 end-to-end in ≤ 40 min
- State when you’d use SA vs suffix automaton vs rolling hash, with one problem each