Suffix array in Python
The lexicographically sorted list of all starting positions of a string's suffixes.
- File
suffix_array.py, 76 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/suffix-array/. The authors are listed in prov/R5YHHT.jsonl.
from collections.abc import Sequence
from typing import Any
class SuffixArray:
"""The suffix array of a sequence s over a general ordered alphabet.
Symbols are compared only with < and ==. No sentinel is appended and
positions are 0-based: sa[r] is the start of the suffix of rank r among
the n nonempty suffixes s[0:], ..., s[n-1:]. Building uses prefix doubling
with a comparison sort, O(n log^2 n) time in the worst case; a query for a
pattern of length m costs O(m log n) symbol comparisons.
"""
def __init__(self, s: Sequence[Any]) -> None:
self.s = s
n = len(s)
self.sa: list[int] = list(range(n)) # @line L1
if n == 0:
self.rank: list[int] = []
return
alphabet = sorted(set(s))
code = {c: k for k, c in enumerate(alphabet)}
rank = [code[c] for c in s] # @line L2: rank by the first symbol
k = 1
while True: # @line L3
def key(i: int, k: int = k, rank: list[int] = rank) -> tuple[int, int]:
return (rank[i], rank[i + k] if i + k < n else -1) # @line L4
self.sa.sort(key=key) # @line L5
new = [0] * n # @line L6
for r in range(1, n):
new[self.sa[r]] = new[self.sa[r - 1]] + (key(self.sa[r]) != key(self.sa[r - 1]))
rank = new
if rank[self.sa[-1]] == n - 1: # @line L7: all ranks distinct
break
k *= 2 # @line L8
self.rank = rank
def _bound(self, p: Sequence[Any], strict: bool) -> int:
"""The first r such that the m-prefix of suffix sa[r] is >= p (or > p if strict)."""
s, sa, m = self.s, self.sa, len(p)
lo, hi = 0, len(sa) # @line L9
pt = list(p)
while lo < hi: # @line L10
mid = lo + (hi - lo) // 2 # @line L11
head = list(s[sa[mid]:sa[mid] + m]) # @line L12
if head < pt or (strict and head == pt): # @line L13
lo = mid + 1 # @line L14
else:
hi = mid # @line L15
return lo # @line L16
def range(self, p: Sequence[Any]) -> tuple[int, int]:
"""The half-open rank interval of the suffixes that start with p."""
return self._bound(p, False), self._bound(p, True)
def count(self, p: Sequence[Any]) -> int:
lo, hi = self.range(p)
return hi - lo
def locate(self, p: Sequence[Any]) -> list[int]:
"""The sorted start positions of the occurrences of p."""
lo, hi = self.range(p)
return sorted(self.sa[lo:hi])
def check_invariant(self) -> None:
s, sa, n = self.s, self.sa, len(self.s)
assert sorted(sa) == list(range(n))
for r in range(1, n):
assert list(s[sa[r - 1]:]) < list(s[sa[r]:])
for r in range(n):
assert self.rank[sa[r]] == r
test_suffix_array.py 64 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/suffix-array/. The authors are listed in prov/R5YHHT.jsonl.
import random
from suffix_array import SuffixArray
def naive_sa(s: str) -> list[int]:
return sorted(range(len(s)), key=lambda i: s[i:])
def naive_occ(s: str, p: str) -> list[int]:
return [i for i in range(len(s) - len(p) + 1) if s[i:i + len(p)] == p]
def test_example() -> None:
a = SuffixArray("banana")
a.check_invariant()
assert a.sa == [5, 3, 1, 0, 4, 2]
assert a.rank == [3, 2, 5, 1, 4, 0]
assert a.range("ana") == (1, 3)
assert a.locate("ana") == [1, 3]
assert a.count("na") == 2
assert a.range("nab") == (5, 5)
assert a.count("c") == 0
assert a.range("") == (0, 6)
def test_edges() -> None:
a = SuffixArray("")
a.check_invariant()
assert a.sa == [] and a.locate("a") == []
assert SuffixArray("x").sa == [0]
assert SuffixArray("aaaa").sa == [3, 2, 1, 0]
assert SuffixArray("abab").sa == [2, 0, 3, 1]
assert SuffixArray("mississippi").sa == [10, 7, 4, 1, 0, 9, 8, 6, 3, 5, 2]
assert SuffixArray([3, 1, 2, 1]).sa == [3, 1, 2, 0]
def test_long_periodic() -> None:
for s in ("a" * 300, "ab" * 150, "aab" * 100):
a = SuffixArray(s)
assert a.sa == naive_sa(s)
def test_random_against_naive() -> None:
rng = random.Random(9071)
for trial in range(800):
sigma = "abcd"[: rng.choice([1, 2, 4])]
n = rng.randint(0, 20)
s = "".join(rng.choice(sigma) for _ in range(n))
if trial % 40 == 0:
s = rng.choice(["a" * n, "ab" * (n // 2), ""])
a = SuffixArray(s)
assert a.sa == naive_sa(s)
a.check_invariant()
for _ in range(6):
p = "".join(rng.choice(sigma) for _ in range(rng.randint(1, 4)))
if s and rng.random() < 0.5:
i = rng.randrange(len(s))
p = s[i:rng.randint(i + 1, len(s))]
assert a.locate(p) == naive_occ(s, p)
assert a.count(p) == len(naive_occ(s, p))
What the next level needs
- the tests did not run in this build