Skip to content

Commit 7fd91a5

Browse files
Clear20-22cclauss
andauthored
Add suffix automaton (#15083)
* Add Suffix Automaton algorithm in strings * Use descriptive variable names for algorithms-keeper * Refactor State class to use dataclass and update count_occurrences time complexity in SuffixAutomaton * Apply suggestion from @cclauss --------- Co-authored-by: Christian Clauss <cclauss@me.com>
1 parent 659b468 commit 7fd91a5

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

strings/suffix_automaton.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""
2+
Suffix Automaton (SAM) for String Processing.
3+
4+
Reference: https://en.wikipedia.org/wiki/Suffix_automaton
5+
Reference: https://cp-algorithms.com/string/suffix-automaton.html
6+
7+
A Suffix Automaton is the minimal Deterministic Finite Automaton (DFA) that recognizes
8+
all suffixes (and substrings) of a given string in O(N) time and O(N) space.
9+
"""
10+
11+
from dataclasses import dataclass, field
12+
13+
14+
@dataclass
15+
class State:
16+
"""
17+
State (node) in a Suffix Automaton.
18+
"""
19+
20+
length: int = 0
21+
link: int = -1
22+
next: dict[str, int] = field(default_factory=dict)
23+
24+
25+
class SuffixAutomaton:
26+
"""
27+
Suffix Automaton data structure.
28+
29+
>>> sam = SuffixAutomaton("abacaba")
30+
>>> sam.contains("abac")
31+
True
32+
>>> sam.contains("caba")
33+
True
34+
>>> sam.contains("xyz")
35+
False
36+
>>> sam.count_distinct_substrings()
37+
21
38+
>>> sam.count_occurrences("aba")
39+
2
40+
>>> sam.count_occurrences("a")
41+
4
42+
>>> SuffixAutomaton("")
43+
Traceback (most recent call last):
44+
...
45+
ValueError: Input string must not be empty.
46+
"""
47+
48+
def __init__(self, string: str) -> None:
49+
if not string:
50+
raise ValueError("Input string must not be empty.")
51+
52+
self.states: list[State] = [State(length=0, link=-1)]
53+
self.last: int = 0
54+
self.string: str = string
55+
56+
for char in string:
57+
self.extend(char)
58+
59+
def extend(self, char: str) -> None:
60+
"""
61+
Extend the Suffix Automaton by appending character char.
62+
Time Complexity: O(1) amortized
63+
"""
64+
curr = len(self.states)
65+
self.states.append(State(length=self.states[self.last].length + 1))
66+
67+
prev_state = self.last
68+
while prev_state != -1 and char not in self.states[prev_state].next:
69+
self.states[prev_state].next[char] = curr
70+
prev_state = self.states[prev_state].link
71+
72+
if prev_state == -1:
73+
self.states[curr].link = 0
74+
else:
75+
next_state = self.states[prev_state].next[char]
76+
if self.states[prev_state].length + 1 == self.states[next_state].length:
77+
self.states[curr].link = next_state
78+
else:
79+
clone = len(self.states)
80+
self.states.append(
81+
State(
82+
length=self.states[prev_state].length + 1,
83+
link=self.states[next_state].link,
84+
)
85+
)
86+
self.states[clone].next = dict(self.states[next_state].next)
87+
88+
while (
89+
prev_state != -1
90+
and self.states[prev_state].next.get(char) == next_state
91+
):
92+
self.states[prev_state].next[char] = clone
93+
prev_state = self.states[prev_state].link
94+
95+
self.states[next_state].link = clone
96+
self.states[curr].link = clone
97+
98+
self.last = curr
99+
100+
def contains(self, pattern: str) -> bool:
101+
"""
102+
Check if pattern exists as a substring in O(|pattern|) time.
103+
104+
>>> sam = SuffixAutomaton("banana")
105+
>>> sam.contains("nan")
106+
True
107+
>>> sam.contains("apple")
108+
False
109+
"""
110+
curr = 0
111+
for char in pattern:
112+
if char not in self.states[curr].next:
113+
return False
114+
curr = self.states[curr].next[char]
115+
return True
116+
117+
def count_distinct_substrings(self) -> int:
118+
"""
119+
Compute total number of distinct substrings in O(N) time.
120+
121+
>>> sam = SuffixAutomaton("abc")
122+
>>> sam.count_distinct_substrings()
123+
6
124+
>>> SuffixAutomaton("aaaa").count_distinct_substrings()
125+
4
126+
"""
127+
total = 0
128+
for state in self.states[1:]:
129+
total += state.length - self.states[state.link].length
130+
return total
131+
132+
def count_occurrences(self, pattern: str) -> int:
133+
"""
134+
Count occurrences of pattern as a substring in the text in O(N + |pattern|) time
135+
136+
>>> sam = SuffixAutomaton("banana")
137+
>>> sam.count_occurrences("an")
138+
2
139+
>>> sam.count_occurrences("na")
140+
2
141+
>>> sam.count_occurrences("banana")
142+
1
143+
>>> sam.count_occurrences("xyz")
144+
0
145+
"""
146+
curr = 0
147+
for char in pattern:
148+
if char not in self.states[curr].next:
149+
return 0
150+
curr = self.states[curr].next[char]
151+
152+
# Standard endpos size calculation via suffix link tree
153+
occurrences = [0] * len(self.states)
154+
order = sorted(
155+
range(len(self.states)),
156+
key=lambda state_index: self.states[state_index].length,
157+
reverse=True,
158+
)
159+
160+
# Mark initial end positions of prefix states
161+
temp_last = 0
162+
for char in self.string:
163+
temp_last = self.states[temp_last].next[char]
164+
occurrences[temp_last] = 1
165+
166+
# Push endpos sizes up the suffix link tree
167+
for state_index in order:
168+
if self.states[state_index].link != -1:
169+
occurrences[self.states[state_index].link] += occurrences[state_index]
170+
171+
return occurrences[curr]
172+
173+
174+
if __name__ == "__main__":
175+
import doctest
176+
177+
doctest.testmod()

0 commit comments

Comments
 (0)