Circular suffix array in Python
Sorted array of the cyclic rotations of a string, used by the Burrows-Wheeler transform.
- File
circular_suffix_array.py, 105 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/circular-suffix-array/. The authors are listed in prov/DE34MK.jsonl.
from collections.abc import Sequence
def _counting_sort(items: list[int], key: list[int], buckets: int) -> list[int]:
"""Stable sort of items by key[item], with 0 <= key[item] < buckets."""
count = [0] * (buckets + 1)
for x in items:
count[key[x] + 1] += 1
for b in range(buckets):
count[b + 1] += count[b]
out = [0] * len(items)
for x in items:
out[count[key[x]]] = x
count[key[x]] += 1
return out
def cyclic_sort(text: Sequence) -> tuple[list[int], list[int]]:
"""Return (ca, cls): the circular suffix array of text and the final classes.
ca lists the start positions 0 .. n-1 of the n rotations of text in
increasing lexicographic order; equal rotations are ordered by position.
cls[i] is the rank of rotation i among the distinct rotations. No sentinel
is added and positions are 0-based. Symbols need a total order; they are
mapped to 0 .. sigma - 1 first. Time O(n log n) after that mapping.
"""
n = len(text)
if n == 0: # @line L1
return [], []
code = {c: r for r, c in enumerate(sorted(set(text)))}
cls = [code[c] for c in text]
p = _counting_sort(list(range(n)), cls, len(code)) # @line L2
length = 1 # @line L3
while length < n: # @line L4
pn = [(x - length) % n for x in p] # @line L5
p = _counting_sort(pn, cls, n) # @line L6
new = [0] * n # @line L7
for r in range(1, n):
a, b = p[r - 1], p[r]
same = cls[a] == cls[b] and cls[(a + length) % n] == cls[(b + length) % n]
new[b] = new[a] + (0 if same else 1)
cls = new
length *= 2 # @line L8
ca = _counting_sort(list(range(n)), cls, n) # @line L9
return ca, cls
class CircularSuffixArray:
"""The sorted array of the rotations of a text, with its BWT and rotation search."""
def __init__(self, text: Sequence) -> None:
self.text = text
self.n = len(text)
self.ca, self.cls = cyclic_sort(text)
def rotation(self, r: int) -> list:
"""Return the rotation of rank r as a list of symbols."""
i = self.ca[r]
return list(self.text[i:]) + list(self.text[:i])
def bwt(self) -> tuple[list, int]:
"""Return the last column L and the rank of rotation 0 (the primary index)."""
last = [self.text[(i - 1) % self.n] for i in self.ca]
primary = self.ca.index(0) if self.n else -1
return last, primary
def least_rotation(self) -> int:
"""Return the smallest position whose rotation is lexicographically least."""
return self.ca[0] if self.n else -1
def _compare(self, i: int, pattern: Sequence) -> int:
"""Compare the first len(pattern) symbols of rotation i with pattern: -1, 0 or 1."""
for k, c in enumerate(pattern):
d = self.text[(i + k) % self.n]
if d != c:
return -1 if d < c else 1
return 0
def find(self, pattern: Sequence) -> list[int]:
"""Return the sorted positions i whose rotation starts with pattern (len(pattern) <= n)."""
if self.n == 0 or len(pattern) > self.n:
return []
lo, hi = 0, self.n
while lo < hi:
mid = (lo + hi) // 2
if self._compare(self.ca[mid], pattern) < 0:
lo = mid + 1
else:
hi = mid
start, hi = lo, self.n
while lo < hi:
mid = (lo + hi) // 2
if self._compare(self.ca[mid], pattern) <= 0:
lo = mid + 1
else:
hi = mid
return sorted(self.ca[start:lo])
def check_invariant(self) -> None:
for r in range(1, self.n):
a, b = self.rotation(r - 1), self.rotation(r)
assert a < b or (a == b and self.ca[r - 1] < self.ca[r])
test_circular_suffix_array.py 66 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/circular-suffix-array/. The authors are listed in prov/DE34MK.jsonl.
import random
from circular_suffix_array import CircularSuffixArray, cyclic_sort
def naive_ca(t):
return sorted(range(len(t)), key=lambda i: (t[i:] + t[:i], i))
def texts(seed: int, count: int = 700, maxn: int = 14) -> list[str]:
rng = random.Random(seed)
out = ["", "a", "aa", "ab", "ba"]
for n in range(1, 13):
out += ["a" * n, "ab" * n, "aab" * n]
for _ in range(count):
sigma = rng.choice([1, 2, 4])
out.append("".join(rng.choice("abcd"[:sigma]) for _ in range(rng.randrange(maxn + 1))))
return out
def test_examples() -> None:
x = CircularSuffixArray("banana")
assert x.ca == [5, 3, 1, 0, 4, 2]
assert x.bwt() == (list("nnbaaa"), 3)
x = CircularSuffixArray("abab")
assert x.ca == [0, 2, 1, 3]
assert x.cls == [0, 1, 0, 1]
x = CircularSuffixArray("abracadabra")
assert "".join(x.bwt()[0]) == "rdarcaaaabb"
assert x.find("aa") == [10]
assert x.find("abra") == [0, 7]
def test_edges() -> None:
x = CircularSuffixArray("")
assert x.ca == [] and x.bwt() == ([], -1) and x.find("a") == []
x = CircularSuffixArray("z")
assert x.ca == [0] and x.bwt() == (["z"], 0)
assert CircularSuffixArray("aaaa").ca == [0, 1, 2, 3]
assert CircularSuffixArray([2, 1, 2, 1]).ca == [1, 3, 0, 2]
def test_against_naive() -> None:
for t in texts(5):
x = CircularSuffixArray(t)
x.check_invariant()
assert x.ca == naive_ca(t)
n = len(t)
if n:
assert x.least_rotation() == min(range(n), key=lambda i: (t[i:] + t[:i], i))
doubled = t + t
for m in range(1, n + 1):
for p in {doubled[i : i + m] for i in range(n)} | {"ca"[: min(m, 2)]}:
want = [i for i in range(n) if doubled[i : i + len(p)] == p]
assert x.find(p) == want
def test_sentinel_gives_suffix_array() -> None:
rng = random.Random(9)
for _ in range(300):
t = "".join(rng.choice("ab") for _ in range(rng.randrange(12))) + "$"
sa = sorted(range(len(t)), key=lambda i: t[i:])
assert cyclic_sort(t)[0] == sa
What the next level needs
- the tests did not run in this build