Skip to content

Commit 3ae8332

Browse files
Add suffix array algorithm
1 parent 438c4dc commit 3ae8332

1 file changed

Lines changed: 159 additions & 0 deletions

File tree

strings/suffix_array.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
Suffix Array construction and Kasai's LCP (Longest Common Prefix) algorithm.
3+
4+
A suffix array of a string is a sorted array of the starting indices of all
5+
its suffixes. It allows many string problems (substring search, longest
6+
repeated substring, etc.) to be solved efficiently.
7+
8+
The construction below uses the prefix-doubling technique: suffixes are
9+
sorted by their first character, then by their first 2, 4, 8, ... characters
10+
until the order is fully determined. Each round doubles the known prefix
11+
length, so O(log n) sorting rounds are needed.
12+
13+
Time Complexity: O(n log^2 n) where n is the length of the string
14+
(O(log n) rounds, each sorting n items)
15+
Space Complexity: O(n)
16+
17+
References:
18+
- https://en.wikipedia.org/wiki/Suffix_array
19+
- https://cp-algorithms.com/string/suffix-array.html
20+
- https://en.wikipedia.org/wiki/LCP_array
21+
"""
22+
23+
24+
def build_suffix_array(text: str) -> list[int]:
25+
"""
26+
Build the suffix array of `text` using the prefix-doubling algorithm.
27+
28+
Returns a list `suffix_array` of length len(text) such that
29+
`text[suffix_array[i]:]` is the i-th lexicographically smallest suffix
30+
of `text`.
31+
32+
>>> build_suffix_array("banana")
33+
[5, 3, 1, 0, 4, 2]
34+
>>> build_suffix_array("abracadabra")
35+
[10, 7, 0, 3, 5, 8, 1, 4, 6, 9, 2]
36+
>>> build_suffix_array("aaaa")
37+
[3, 2, 1, 0]
38+
>>> build_suffix_array("a")
39+
[0]
40+
>>> build_suffix_array("")
41+
Traceback (most recent call last):
42+
...
43+
ValueError: Input string must not be empty.
44+
>>> build_suffix_array(123)
45+
Traceback (most recent call last):
46+
...
47+
TypeError: Input must be a string.
48+
"""
49+
if not isinstance(text, str):
50+
raise TypeError("Input must be a string.")
51+
if not text:
52+
raise ValueError("Input string must not be empty.")
53+
54+
length = len(text)
55+
suffix_array = list(range(length))
56+
# rank[i] is the equivalence class of the prefix starting at index i.
57+
rank = [ord(character) for character in text]
58+
59+
step = 1
60+
while step < length:
61+
# Sort suffixes by the pair (rank[i], rank[i + step]), i.e. by their
62+
# first `2 * step` characters. A missing second half ranks lowest.
63+
suffix_array.sort(
64+
key=lambda index: (
65+
rank[index],
66+
rank[index + step] if index + step < length else -1,
67+
)
68+
)
69+
70+
# Re-rank: suffixes that compared equal share the same class.
71+
new_rank = [0] * length
72+
for position in range(1, length):
73+
previous = suffix_array[position - 1]
74+
current = suffix_array[position]
75+
previous_key = (
76+
rank[previous],
77+
rank[previous + step] if previous + step < length else -1,
78+
)
79+
current_key = (
80+
rank[current],
81+
rank[current + step] if current + step < length else -1,
82+
)
83+
new_rank[current] = new_rank[previous] + (current_key > previous_key)
84+
85+
rank = new_rank
86+
# Every suffix has a unique rank, so the order is fully determined.
87+
if rank[suffix_array[-1]] == length - 1:
88+
break
89+
step *= 2
90+
91+
return suffix_array
92+
93+
94+
def longest_common_prefix_array(text: str, suffix_array: list[int]) -> list[int]:
95+
"""
96+
Build the LCP array of `text` for a given `suffix_array` using Kasai's
97+
algorithm, which runs in O(n) time.
98+
99+
Returns a list `lcp` of length len(text) where `lcp[i]` is the length of
100+
the longest common prefix between the suffixes starting at
101+
`suffix_array[i - 1]` and `suffix_array[i]`. `lcp[0]` is always 0.
102+
103+
>>> longest_common_prefix_array("banana", [5, 3, 1, 0, 4, 2])
104+
[0, 1, 3, 0, 0, 2]
105+
>>> longest_common_prefix_array("banana", build_suffix_array("banana"))
106+
[0, 1, 3, 0, 0, 2]
107+
>>> longest_common_prefix_array("aaaa", [3, 2, 1, 0])
108+
[0, 1, 2, 3]
109+
>>> longest_common_prefix_array("abcde", [0, 1, 2, 3, 4])
110+
[0, 0, 0, 0, 0]
111+
>>> longest_common_prefix_array("", [])
112+
Traceback (most recent call last):
113+
...
114+
ValueError: Input string must not be empty.
115+
>>> longest_common_prefix_array("abc", [0, 1])
116+
Traceback (most recent call last):
117+
...
118+
ValueError: suffix_array must be a permutation of range(len(text)).
119+
"""
120+
if not isinstance(text, str):
121+
raise TypeError("Input must be a string.")
122+
length = len(text)
123+
if length == 0:
124+
raise ValueError("Input string must not be empty.")
125+
if sorted(suffix_array) != list(range(length)):
126+
raise ValueError("suffix_array must be a permutation of range(len(text)).")
127+
128+
# rank[start] is the position in suffix_array of the suffix at `start`.
129+
rank = [0] * length
130+
for position, start in enumerate(suffix_array):
131+
rank[start] = position
132+
133+
lcp = [0] * length
134+
common = 0
135+
for start in range(length):
136+
position = rank[start]
137+
if position == 0:
138+
# Lexicographically smallest suffix has no predecessor.
139+
common = 0
140+
continue
141+
neighbor = suffix_array[position - 1]
142+
while (
143+
start + common < length
144+
and neighbor + common < length
145+
and text[start + common] == text[neighbor + common]
146+
):
147+
common += 1
148+
lcp[position] = common
149+
# The next suffix shares at least `common - 1` characters with its
150+
# own predecessor, so the comparison can resume from there.
151+
common = max(common - 1, 0)
152+
153+
return lcp
154+
155+
156+
if __name__ == "__main__":
157+
import doctest
158+
159+
doctest.testmod()

0 commit comments

Comments
 (0)