Algorithm Search⌘K

Compressed suffix array in Python

A suffix array stored in space close to the compressed text size, with fast search.

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

from collections.abc import Hashable, Sequence


def _suffix_array(t: list[int]) -> list[int]:
    """Suffix array by prefix doubling with library sorts; t ends with a unique smallest code."""
    n = len(t)
    sa, rank, h = list(range(n)), t[:], 1
    while n > 1:
        key = [(rank[i], rank[i + h] if i + h < n else -1) for i in range(n)]
        sa.sort(key=key.__getitem__)
        new = [0] * n
        for r in range(1, n):
            new[sa[r]] = new[sa[r - 1]] + (key[sa[r]] != key[sa[r - 1]])
        rank = new
        if rank[sa[-1]] == n - 1:
            break
        h *= 2
    return sa


class _GammaStream:
    """Positive integers written with the Elias gamma code into one growing integer."""

    def __init__(self) -> None:
        self.bits, self.length = 0, 0

    def write(self, x: int) -> None:
        """Write k - 1 zeros, then the k binary digits of x from the highest one, which is 1."""
        k = x.bit_length()
        self.length += k - 1
        rev = int(format(x, "b")[::-1], 2)  # the stream is read from the low end
        self.bits |= rev << self.length
        self.length += k

    def read(self, pos: int) -> tuple[int, int]:
        """The value that starts at pos and the position after it."""
        zeros = 0
        while not self.bits >> (pos + zeros) & 1:
            zeros += 1
        pos += zeros
        chunk = (self.bits >> pos) & ((1 << (zeros + 1)) - 1)
        return int(format(chunk, "0%db" % (zeros + 1))[::-1], 2), pos + zeros + 1


class CompressedSuffixArray:
    """Sadakane-style compressed suffix array of a text with a sentinel appended.

    The symbols need a total order and are coded 1 .. sigma, and the sentinel is
    the code 0, unique and smallest; rows are 0 .. n and positions 0-based. The
    index keeps Psi(i) = ISA[SA[i] + 1] (cyclic) as the increasing sequence
    F(i) * (n + 1) + Psi(i), with a sample every `block` rows and gamma-coded gaps,
    plus SA at the positions that are multiples of `step` (and n), and ISA there.
    It does not keep the text.
    """

    def __init__(self, text: Sequence[Hashable], step: int = 4, block: int = 8) -> None:
        self.n = n = len(text)
        self.step, self.block = step, block
        self.symbols = sorted(set(text))
        code = {c: r + 1 for r, c in enumerate(self.symbols)}
        t = [code[c] for c in text] + [0]
        sa = _suffix_array(t)
        isa = [0] * (n + 1)
        for i, p in enumerate(sa):
            isa[p] = i
        self.samples: list[tuple[int, int]] = []  # (value, offset in the stream) at every block-th row
        self.stream = _GammaStream()
        prev = 0
        for i, p in enumerate(sa):
            value = t[p] * (n + 1) + isa[(p + 1) % (n + 1)]
            if i % block == 0:
                self.samples.append((value, self.stream.length))
            else:
                self.stream.write(value - prev)
            prev = value
        self.sa_sample = {i: p for i, p in enumerate(sa) if p % step == 0 or p == n}
        self.isa_sample = [isa[p] for p in range(0, n + 1, step)]

    def _value(self, i: int) -> int:
        value, pos = self.samples[i // self.block]
        for _ in range(i % self.block):
            gap, pos = self.stream.read(pos)
            value += gap
        return value

    def psi(self, i: int) -> int:
        """Row of the suffix that starts one position after the suffix of row i."""
        return self._value(i) % (self.n + 1)

    def first(self, i: int) -> int:
        """Code of the first symbol of the suffix of row i (0 for the sentinel)."""
        return self._value(i) // (self.n + 1)

    def lookup(self, i: int) -> int:
        """SA[i], by walking Psi to a sampled row."""
        t = 0  # @line L1
        while i not in self.sa_sample:  # @line L2
            i = self.psi(i)  # @line L3
            t += 1
        return self.sa_sample[i] - t  # @line L4

    def inverse(self, p: int) -> int:
        """ISA[p], from the sample at or before p."""
        i = self.isa_sample[p // self.step]
        for _ in range(p % self.step):
            i = self.psi(i)
        return i

    def extract(self, left: int, right: int) -> list[Hashable]:
        """The text symbols at positions left .. right - 1."""
        if not 0 <= left <= right <= self.n:
            raise ValueError("bad range")
        i, out = self.inverse(left), []
        for _ in range(right - left):
            out.append(self.symbols[self.first(i) - 1])
            i = self.psi(i)
        return out

    def _compare(self, i: int, pattern: list[int]) -> int:
        """Sign of (prefix of the suffix of row i) minus pattern, over len(pattern) symbols."""
        for x in pattern:  # @line L5
            y = self.first(i)
            if y != x:
                return -1 if y < x else 1  # @line L6
            i = self.psi(i)  # @line L7
        return 0

    def find(self, pattern: Sequence[Hashable]) -> tuple[int, int]:
        """Rows [lo, hi) of the suffixes that start with pattern, by binary search."""
        code = {c: r + 1 for r, c in enumerate(self.symbols)}
        if any(c not in code for c in pattern):
            return (0, 0)
        p = [code[c] for c in pattern]
        lo, hi = 0, self.n + 1
        while lo < hi:  # @line L8
            mid = (lo + hi) // 2
            if self._compare(mid, p) < 0:
                lo = mid + 1
            else:
                hi = mid
        start, hi = lo, self.n + 1
        while lo < hi:  # @line L9
            mid = (lo + hi) // 2
            if self._compare(mid, p) <= 0:
                lo = mid + 1
            else:
                hi = mid
        return (start, lo)  # @line L10

    def locate(self, pattern: Sequence[Hashable]) -> list[int]:
        """Sorted start positions of a nonempty pattern."""
        if len(pattern) == 0:
            raise ValueError("the pattern must be nonempty")
        lo, hi = self.find(pattern)
        return sorted(self.lookup(i) for i in range(lo, hi))

    def psi_stream_length(self) -> int:
        """Number of binary digits used by the gamma-coded gaps of Psi."""
        return self.stream.length
test_compressed_suffix_array.py 53 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/compressed-suffix-array/. The authors are listed in prov/M2B1RW.jsonl.

import random

from compressed_suffix_array import CompressedSuffixArray


def naive(text: str) -> tuple[list[int], list[int]]:
    t = text + "\0"
    sa = sorted(range(len(t)), key=lambda i: t[i:])
    isa = [0] * len(t)
    for i, p in enumerate(sa):
        isa[p] = i
    return sa, [isa[(p + 1) % len(t)] for p in sa]


def check(text: str, step: int, block: int) -> None:
    csa = CompressedSuffixArray(text, step=step, block=block)
    sa, psi = naive(text)
    n = len(text)
    assert [csa.psi(i) for i in range(n + 1)] == psi
    assert [csa.lookup(i) for i in range(n + 1)] == sa
    assert [csa.inverse(p) for p in range(n + 1)] == [sa.index(p) for p in range(n + 1)]
    assert "".join(csa.extract(0, n)) == text
    pats = {text[i:j] for i in range(n) for j in range(i + 1, min(n, i + 4) + 1)} | {"x", "ab", "ba", "aa"}
    for pat in pats:
        occ = [i for i in range(n - len(pat) + 1) if text.startswith(pat, i)]
        assert csa.locate(pat) == occ


def test_banana() -> None:
    csa = CompressedSuffixArray("banana", step=2, block=4)
    assert [csa.psi(i) for i in range(7)] == [4, 0, 5, 6, 3, 1, 2]
    assert [csa.first(i) for i in range(7)] == [0, 1, 1, 1, 2, 3, 3]
    assert csa.find("ana") == (2, 4) and csa.locate("ana") == [1, 3]
    assert csa.lookup(2) == 3 and csa.extract(1, 4) == list("ana")
    check("banana", 2, 4)


def test_special() -> None:
    check("", 3, 2)
    for n in range(1, 30):
        check("a" * n, 4, 8)
        check("ab" * n, 3, 5)


def test_random() -> None:
    rng = random.Random(3)
    for trial in range(600):
        sigma = (1, 2, 4)[trial % 3]
        text = "".join(rng.choice("acgt"[:sigma]) for _ in range(rng.randrange(0, 30)))
        check(text, rng.randrange(1, 7), rng.randrange(1, 10))

What the next level needs

  • the tests did not run in this build

Read the full page about Compressed suffix array