Algorithm Search⌘K

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
circular_suffix_array.pyRawGitHub
# 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

Read the full page about Circular suffix array