String Algorithms contents
Suffix Array
Sort all suffixes of a string, build the array in O(n log n) by prefix doubling, compute LCP with Kasai's algorithm, and use both for substring search and counting distinct substrings.
Read first: Z-function, Strings
The suffix array of a string of length is the list of the starting positions of all its suffixes, sorted in lexicographic order. For banana:
| position | suffix |
|---|---|
| 5 | a |
| 3 | ana |
| 1 | anana |
| 0 | banana |
| 4 | na |
| 2 | nana |
so the suffix array is [5, 3, 1, 0, 4, 2]. It is a compact, cache-friendly replacement for the suffix tree: every question about substrings becomes a question about a range of sorted suffixes.
Naive construction
Sort the suffixes directly. Comparing two suffixes costs , so this is , but for short strings it is a perfectly good reference implementation.
def suffix_array_naive(s):
return sorted(range(len(s)), key=lambda i: s[i:])
assert suffix_array_naive("banana") == [5, 3, 1, 0, 4, 2]
assert suffix_array_naive("") == []
assert suffix_array_naive("aaaa") == [3, 2, 1, 0]Prefix doubling:
Sort the suffixes by their first characters, for Each step uses the previous one: the first characters of suffix are the pair (rank of the first characters of suffix , rank of the first characters of suffix ). After rounds all ranks are distinct and the order is the suffix array.
def suffix_array(s):
n = len(s)
if n == 0:
return []
rank = [ord(c) for c in s]
sa = list(range(n))
k = 1
while True:
def key(i):
return (rank[i], rank[i + k] if i + k < n else -1)
sa.sort(key=key)
new_rank = [0] * n
for idx in range(1, n):
new_rank[sa[idx]] = new_rank[sa[idx - 1]] + (key(sa[idx]) != key(sa[idx - 1]))
rank = new_rank
if rank[sa[-1]] == n - 1: # all ranks distinct
return sa
k *= 2
import random
random.seed(1)
for _ in range(500):
s = "".join(random.choice("abc") for _ in range(random.randint(0, 40)))
assert suffix_array(s) == suffix_array_naive(s)Each round sorts with Python's sort in , so the total is . That handles within a few seconds. Replacing the comparison sort by two counting sorts (radix sort on the pair) gives the classical ; here is that version, working on cyclic shifts of + a sentinel smaller than every character:
def suffix_array_radix(s):
"""O(n log n) via counting sort on cyclic shifts of s + sentinel."""
s = s + "\0"
n = len(s)
alphabet = 256
count = [0] * max(alphabet, n)
p = [0] * n
c = [0] * n
for ch in s:
count[ord(ch)] += 1
for i in range(1, alphabet):
count[i] += count[i - 1]
for i in range(n - 1, -1, -1):
count[ord(s[i])] -= 1
p[count[ord(s[i])]] = i
c[p[0]] = 0
classes = 1
for i in range(1, n):
if s[p[i]] != s[p[i - 1]]:
classes += 1
c[p[i]] = classes - 1
h = 0
while (1 << h) < n:
pn = [(p[i] - (1 << h)) % n for i in range(n)] # sort by the second half first (it is p shifted)
count = [0] * classes
for i in range(n):
count[c[pn[i]]] += 1
for i in range(1, classes):
count[i] += count[i - 1]
for i in range(n - 1, -1, -1):
count[c[pn[i]]] -= 1
p[count[c[pn[i]]]] = pn[i]
cn = [0] * n
classes = 1
for i in range(1, n):
cur = (c[p[i]], c[(p[i] + (1 << h)) % n])
prev = (c[p[i - 1]], c[(p[i - 1] + (1 << h)) % n])
if cur != prev:
classes += 1
cn[p[i]] = classes - 1
c = cn
h += 1
return p[1:] # drop the sentinel suffix
for _ in range(300):
s = "".join(random.choice("abc") for _ in range(random.randint(0, 40)))
assert suffix_array_radix(s) == suffix_array_naive(s)LCP array (Kasai's algorithm)
The longest common prefix array stores, for consecutive suffixes in sorted order, the length of their common prefix: lcp[i] is . Kasai computes it in using one observation: if suffix shares characters with its predecessor in sorted order, then suffix shares at least with its predecessor. So process the suffixes in text order and let decrease by at most one per step:
def lcp_array(s, sa):
n = len(s)
rank = [0] * n
for i, p in enumerate(sa):
rank[p] = i
lcp = [0] * max(0, n - 1)
h = 0
for i in range(n):
if rank[i] == n - 1:
h = 0
continue
j = sa[rank[i] + 1] # the next suffix in sorted order
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
return lcp
s = "banana"
sa = suffix_array(s)
assert lcp_array(s, sa) == [1, 3, 0, 0, 2] # a|ana=1, ana|anana=3, anana|banana=0, banana|na=0, na|nana=2
def lcp_naive(s, sa):
def common(a, b):
k = 0
while k < min(len(a), len(b)) and a[k] == b[k]:
k += 1
return k
return [common(s[sa[i]:], s[sa[i + 1]:]) for i in range(len(sa) - 1)]
for _ in range(300):
t = "".join(random.choice("ab") for _ in range(random.randint(1, 40)))
sa_t = suffix_array(t)
assert lcp_array(t, sa_t) == lcp_naive(t, sa_t)The LCP of two arbitrary suffixes is the minimum of lcp over the range between their ranks, answered in with a sparse table.
Applications
Substring search
All suffixes that start with a pattern form a contiguous block in the suffix array. Two binary searches find its boundaries in ; the block size is the number of occurrences.
def find_occurrences(s, sa, pattern):
lo, hi = 0, len(sa)
while lo < hi: # first suffix >= pattern
mid = (lo + hi) // 2
if s[sa[mid]:sa[mid] + len(pattern)] < pattern:
lo = mid + 1
else:
hi = mid
start = lo
hi = len(sa)
while lo < hi: # first suffix whose prefix is > pattern
mid = (lo + hi) // 2
if s[sa[mid]:sa[mid] + len(pattern)] <= pattern:
lo = mid + 1
else:
hi = mid
return sorted(sa[start:lo])
text = "abracadabra"
sa_text = suffix_array(text)
assert find_occurrences(text, sa_text, "abra") == [0, 7]
assert find_occurrences(text, sa_text, "a") == [0, 3, 5, 7, 10]
assert find_occurrences(text, sa_text, "xyz") == []Number of distinct substrings
Every substring is a prefix of some suffix. Going through the suffixes in sorted order, suffix sa[i] contributes prefixes, of which the first lcp[i-1] were already counted with the previous suffix:
def distinct_substrings(s):
sa = suffix_array(s)
n = len(s)
return n * (n + 1) // 2 - sum(lcp_array(s, sa))
assert distinct_substrings("banana") == 15
assert distinct_substrings("aaaa") == 4
for _ in range(300):
t = "".join(random.choice("ab") for _ in range(random.randint(0, 30)))
assert distinct_substrings(t) == len({t[i:j] for i in range(len(t)) for j in range(i + 1, len(t) + 1)})Longest repeated substring
It is the maximum value in the LCP array:
def longest_repeated_substring(s):
sa = suffix_array(s)
lcp = lcp_array(s, sa)
if not lcp or max(lcp) == 0:
return ""
i = max(range(len(lcp)), key=lcp.__getitem__)
return s[sa[i]:sa[i] + lcp[i]]
assert longest_repeated_substring("banana") == "ana"
assert longest_repeated_substring("abcd") == ""Longest common substring of two strings
Concatenate with a separator that occurs in neither, build the suffix array and LCP, and look for the largest lcp[i] between two suffixes that start in different strings:
def longest_common_substring(a, b):
s = a + "\x01" + b
sa = suffix_array(s)
lcp = lcp_array(s, sa)
boundary = len(a)
best, where = 0, 0
for i in range(len(sa) - 1):
x, y = sa[i], sa[i + 1]
if (x < boundary) != (y < boundary) and lcp[i] > best:
best, where = lcp[i], x
return s[where:where + best]
assert longest_common_substring("xabcdz", "yabcdw") == "abcd"
assert longest_common_substring("abc", "xyz") == ""The LCP between suffixes from different strings can never cross the separator, so it is bounded by the true common substring length.
Summary
| Task | Cost with a suffix array |
|---|---|
| build | (or with sort) |
| LCP array | |
| find a pattern | $O( |
| number of distinct substrings | after the LCP |
| longest repeated / common substring | after the LCP |
| -th smallest substring | with the LCP array |
Practice problems
- Uva 760 - DNA Sequencing
- Uva 1223 - Editor
- Codechef - Tandem
- Codechef - Substrings and Repetitions
- Codechef - Entangled Strings
- Codeforces - Martian Strings
- Codeforces - Little Elephant and Strings
- SPOJ - Ada and Terramorphing
- SPOJ - Ada and Substring
- UVA - 1227 - The longest constant gene
- SPOJ - Longest Common Substring
- UVA 11512 - GATTACA
- LA 7502 - Suffixes and Palindromes
- GYM - Por Costel and the Censorship Committee
- UVA 1254 - Top 10
- UVA 12191 - File Recover
- UVA 12206 - Stammering Aliens
- Codechef - Jarvis and LCP
- LA 3943 - Liking's Letter
- UVA 11107 - Life Forms
- UVA 12974 - Exquisite Strings
- UVA 10526 - Intellectual Property
- UVA 12338 - Anti-Rhyme Pairs
- UVA 12191 - File Recover
- SPOJ - Suffix Array
- LA 4513 - Stammering Aliens
- SPOJ - LCS2
- Codeforces - Fake News (hard)
- SPOJ - Longest Commong Substring
- SPOJ - Lexicographical Substring Search
- Codeforces - Forbidden Indices
- Codeforces - Tricky and Clever Password
- LA 6856 - Circle of digits