Algorithm Search⌘K

SA-IS algorithm in Python

Linear-time suffix array construction by induced sorting of LMS suffixes.

File
sa_is.py, 103 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
sa_is.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sa-is/. The authors are listed in prov/TF963B.jsonl.

from collections.abc import Sequence


def _buckets(t: Sequence[int], k: int, ends: bool) -> list[int]:
    count = [0] * k
    for c in t:
        count[c] += 1
    out, total = [0] * k, 0
    for c in range(k):
        total += count[c]
        out[c] = total if ends else total - count[c]
    return out


def _induce(t: Sequence[int], k: int, sa: list[int], stype: list[bool]) -> None:
    """Induce the L-type suffixes left to right, then the S-type ones right to left."""
    n = len(t)
    head = _buckets(t, k, False)
    for i in range(n):  # @line L4
        j = sa[i] - 1
        if sa[i] > 0 and not stype[j]:
            sa[head[t[j]]] = j
            head[t[j]] += 1
    tail = _buckets(t, k, True)
    for i in range(n - 1, -1, -1):  # @line L5
        j = sa[i] - 1
        if sa[i] > 0 and stype[j]:
            tail[t[j]] -= 1
            sa[tail[t[j]]] = j


def _sais(t: list[int], k: int) -> list[int]:
    """Suffix array of t, where t[-1] = 0 is a unique smallest sentinel and 0 <= t[i] < k."""
    n = len(t)
    stype = [False] * n  # @line L1: S-type (True) or L-type (False)
    stype[n - 1] = True
    for i in range(n - 2, -1, -1):
        stype[i] = t[i] < t[i + 1] or (t[i] == t[i + 1] and stype[i + 1])

    def is_lms(i: int) -> bool:
        return i > 0 and stype[i] and not stype[i - 1]

    lms = [i for i in range(1, n) if is_lms(i)]  # @line L2
    sa = [-1] * n
    tail = _buckets(t, k, True)
    for i in reversed(lms):  # @line L3: LMS positions at the ends of their buckets
        tail[t[i]] -= 1
        sa[tail[t[i]]] = i
    _induce(t, k, sa, stype)
    # name the LMS substrings in their sorted order
    sorted_lms = [p for p in sa if is_lms(p)]  # @line L6
    name = [-1] * n
    cur, prev = 0, -1
    for p in sorted_lms:
        if prev >= 0:
            d = 0
            while True:  # compare the LMS substrings that start at prev and p
                if t[p + d] != t[prev + d] or stype[p + d] != stype[prev + d]:
                    cur += 1
                    break
                if d > 0 and (is_lms(p + d) or is_lms(prev + d)):
                    break
                d += 1
        name[p] = cur
        prev = p
    reduced = [name[i] for i in lms]
    if cur + 1 < len(lms):  # @line L7: some names repeat, recurse
        order = _sais(reduced, cur + 1)
    else:  # @line L8: all names differ, sort by bucket
        order = [0] * len(lms)
        for i, c in enumerate(reduced):
            order[c] = i
    sa = [-1] * n  # @line L9: sorted LMS suffixes at the ends of their buckets
    tail = _buckets(t, k, True)
    for j in reversed(order):
        i = lms[j]
        tail[t[i]] -= 1
        sa[tail[t[i]]] = i
    _induce(t, k, sa, stype)  # @line L10
    return sa  # @line L11


def sa_is(s: Sequence[int]) -> list[int]:
    """The suffix array of s, a sequence of integers 0 <= s[i] < n^O(1).

    Integer alphabet; the symbols are first mapped to their ranks among the
    distinct values (a sort of the distinct values). Positions are 0-based.
    A sentinel smaller than all symbols is appended inside and removed from
    the result. The recursion depth is at most log2(n).
    """
    if len(s) == 0:
        return []
    alphabet = {c: r + 1 for r, c in enumerate(sorted(set(s)))}
    t = [alphabet[c] for c in s] + [0]
    return _sais(t, len(alphabet) + 1)[1:]


def sa_is_str(s: str) -> list[int]:
    """The suffix array of a string, by code points."""
    return sa_is([ord(c) for c in s])
test_sa_is.py 44 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sa-is/. The authors are listed in prov/TF963B.jsonl.

import random

from sa_is import sa_is, sa_is_str


def naive_sa(s) -> list[int]:
    return sorted(range(len(s)), key=lambda i: list(s[i:]))


def test_example() -> None:
    assert sa_is_str("mmiissiissiippii") == naive_sa("mmiissiissiippii")
    assert sa_is_str("mississippi") == [10, 7, 4, 1, 0, 9, 8, 6, 3, 5, 2]
    assert sa_is_str("banana") == [5, 3, 1, 0, 4, 2]
    assert sa_is([2, 1, 2, 1, 3]) == [1, 3, 0, 2, 4]


def test_edges() -> None:
    assert sa_is([]) == []
    assert sa_is([7]) == [0]
    for n in (2, 3, 10, 1000):
        assert sa_is_str("a" * n) == list(range(n - 1, -1, -1))
        s = "ab" * (n // 2)
        assert sa_is_str(s) == naive_sa(s)
        s = "ba" * (n // 2)
        assert sa_is_str(s) == naive_sa(s)
    s = "abaab" * 200
    assert sa_is_str(s) == naive_sa(s)


def test_random_against_naive() -> None:
    rng = random.Random(2009)
    for trial in range(1500):
        sigma = rng.choice([1, 2, 4])
        n = rng.randint(0, 60)
        s = [rng.randrange(sigma) for _ in range(n)]
        if trial % 50 == 0:
            s = rng.choice([[0] * n, [0, 1] * (n // 2), []])
        assert sa_is(s) == naive_sa(s)
    for _ in range(50):
        s = [rng.randrange(10 ** 9) for _ in range(rng.randint(1, 300))]
        assert sa_is(s) == naive_sa(s)

What the next level needs

  • the tests did not run in this build

Read the full page about SA-IS algorithm