Algorithm Search⌘K

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

Read the full page about Suffix array