Algorithm Search⌘K

Enhanced suffix array in Python

A suffix array with LCP array and child table, simulating every suffix tree operation.

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

from collections.abc import Hashable, Sequence

NIL = -1


def suffix_array(codes: list[int]) -> list[int]:
    """Suffix array of an integer string by prefix doubling with library sorts."""
    n = len(codes)
    rank = list(codes)
    sa = list(range(n))
    h = 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


def lcp_array(codes: list[int], sa: list[int]) -> list[int]:
    """LCP[0] = 0 and LCP[i] = lcp of the suffixes at ranks i - 1 and i, by Kasai's method."""
    n = len(codes)
    rank = [0] * n
    for i, p in enumerate(sa):
        rank[p] = i
    lcp, h = [0] * n, 0
    for p in range(n):
        if rank[p] > 0:
            q = sa[rank[p] - 1]
            while p + h < n and q + h < n and codes[p + h] == codes[q + h]:
                h += 1
            lcp[rank[p]] = h
            if h:
                h -= 1
        else:
            h = 0
    return lcp


class EnhancedSuffixArray:
    """Suffix array, LCP array and child table of T$ (Abouelhoda, Kurtz and Ohlebusch).

    A sentinel $ smaller than every symbol is appended, so there are N = n + 1
    suffixes, ranks 0..n, and rank 0 is the suffix "$" at position n. Positions and
    ranks are 0-based. The alphabet is general ordered. L has N + 1 entries with
    L[0] = 0 and L[N] = -1. The fields up, down and next hold NIL when undefined.
    """

    def __init__(self, text: Sequence[Hashable]) -> None:
        self.text = text
        n = len(text)
        code = {c: r + 1 for r, c in enumerate(sorted(set(text)))}
        codes = [code[c] for c in text] + [0]
        self.sa = suffix_array(codes)
        self.lcp = lcp_array(codes, self.sa) + [-1]
        self.n, self.size = n, n + 1
        self.up = [NIL] * (n + 2)
        self.down = [NIL] * (n + 2)
        self.next = [NIL] * (n + 2)
        self._child_table()

    def _child_table(self) -> None:
        L, N = self.lcp, self.size
        last, stack = NIL, [0]  # @line L1
        for i in range(1, N + 1):  # @line L2
            while stack and L[i] < L[stack[-1]]:  # @line L3
                last = stack.pop()  # @line L4
                if stack and L[i] <= L[stack[-1]] and L[stack[-1]] != L[last]:  # @line L5
                    self.down[stack[-1]] = last
            if last != NIL:  # @line L6
                self.up[i], last = last, NIL
            stack.append(i)  # @line L7
        stack = [0]  # @line L8
        for i in range(1, N + 1):  # @line L9
            while stack and L[i] < L[stack[-1]]:  # @line L10
                stack.pop()
            if stack and L[i] == L[stack[-1]]:  # @line L11
                self.next[stack.pop()] = i
            stack.append(i)  # @line L12

    def first_index(self, i: int, j: int) -> int:
        """The first l-index of the lcp-interval [i..j], i < j."""
        if (i, j) == (0, self.n):
            return self.next[0]
        u = self.up[j + 1]
        return u if i < u <= j else self.down[i]

    def lcp_value(self, i: int, j: int) -> int:
        """The value l of the lcp-interval [i..j], i < j: the string depth of the node."""
        return 0 if (i, j) == (0, self.n) else self.lcp[self.first_index(i, j)]

    def children(self, i: int, j: int) -> list[tuple[int, int]]:
        """The child intervals of the lcp-interval [i..j], i < j, in rank order; leaves are [r..r]."""
        out = []
        k = self.first_index(i, j)  # @line L13 @line L14
        out.append((i, k - 1))  # @line L15
        while self.next[k] != NIL:  # @line L16
            out.append((k, self.next[k] - 1))
            k = self.next[k]
        out.append((k, j))  # @line L17
        return out

    def symbol(self, r: int, d: int) -> Hashable | None:
        """Symbol at depth d of the suffix at rank r; None stands for the sentinel."""
        p = self.sa[r] + d
        return self.text[p] if p < self.n else None

    def child(self, i: int, j: int, c: Hashable) -> tuple[int, int] | None:
        """The child interval of [i..j] whose suffixes have c at the depth of [i..j]."""
        d = self.lcp_value(i, j)
        for a, b in self.children(i, j):
            if self.symbol(a, d) == c:
                return (a, b)
        return None

    def find(self, pattern: Sequence[Hashable]) -> tuple[int, int]:
        """Rank range [lo, hi) of the suffixes of T$ that start with pattern, top down."""
        m, n = len(pattern), self.n
        i, j, d = 0, n, 0
        while d < m:
            if i == j:
                return (0, 0)
            hit = self.child(i, j, pattern[d])
            if hit is None:
                return (0, 0)
            i, j = hit
            ell = n - self.sa[i] if i == j else self.lcp_value(i, j)
            e, p = min(ell, m), self.sa[i]
            if any(self.text[p + t] != pattern[t] for t in range(d + 1, e)):
                return (0, 0)
            d = e
        return (i, j + 1)

    def intervals(self) -> list[tuple[int, int, int]]:
        """All lcp-intervals as (value, lb, rb), by a top-down walk with an explicit stack."""
        if self.n == 0:
            return [(0, 0, 0)]
        out, todo = [], [(0, self.n)]
        while todo:
            i, j = todo.pop()
            out.append((self.lcp_value(i, j), i, j))
            todo.extend((a, b) for a, b in self.children(i, j) if a < b)
        return sorted(out, key=lambda x: (x[1], -x[2], x[0]))
test_enhanced_suffix_array.py 111 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/enhanced-suffix-array/. The authors are listed in prov/VFC544.jsonl.

import random

from enhanced_suffix_array import NIL, EnhancedSuffixArray


def naive(text: str) -> tuple[list[int], list[int]]:
    """SA of T$ by sorting all suffixes, and L with L[0] = 0 and L[N] = -1 by brute force."""
    t = [ord(c) for c in text] + [0]
    sa = sorted(range(len(t)), key=lambda p: t[p:])

    def lcp(a: int, b: int) -> int:
        k = 0
        while t[a + k] == t[b + k]:
            k += 1
        return k

    return sa, [0] + [lcp(sa[i - 1], sa[i]) for i in range(1, len(t))] + [-1]


def by_definition(L: list[int]) -> tuple[list[int], list[int], list[int]]:
    """up, down and next from their definitions."""
    N = len(L) - 1
    up, down, nxt = [NIL] * (N + 1), [NIL] * (N + 1), [NIL] * (N + 1)
    for q in range(N + 1):
        for k in range(q):
            if L[k] > L[q] and all(L[r] >= L[k] for r in range(k + 1, q)):
                up[q] = k
                break
        for k in range(N, q, -1):
            if L[k] > L[q] and all(L[r] > L[k] for r in range(q + 1, k)):
                down[q] = k
                break
        for k in range(q + 1, N + 1):
            if L[k] == L[q] and all(L[r] > L[q] for r in range(q + 1, k)):
                nxt[q] = k
                break
    return up, down, nxt


def naive_tree(text: str) -> dict[tuple[int, int], list[tuple[int, int]]]:
    """Internal nodes of the suffix tree of T$ as rank ranges, with their children."""
    t = text + "\0"
    sa = sorted(range(len(t)), key=lambda p: t[p:])
    nexts: dict[str, set[str]] = {}
    for i in range(len(text)):
        for j in range(i, len(text) + 1):
            nexts.setdefault(text[i:j], set()).add(t[j])
    labels = {u for u, s in nexts.items() if len(s) >= 2}
    labels = labels | {""} if text else set()
    rng = {u: (min(r for r, p in enumerate(sa) if t.startswith(u, p)), max(r for r, p in enumerate(sa) if t.startswith(u, p))) for u in labels}
    tree = {}
    for u, (a, b) in rng.items():
        kids, r = [], a
        while r <= b:
            best = (r, r)
            for w, (c, d) in rng.items():
                if c == r and len(w) > len(u) and w.startswith(u) and d - c > best[1] - best[0] and d <= b:
                    best = (c, d)
            kids.append(best)
            r = best[1] + 1
        tree[(a, b)] = kids
    return tree


def check(text: str) -> None:
    esa = EnhancedSuffixArray(text)
    sa, L = naive(text)
    assert esa.sa == sa and esa.lcp == L
    up, down, nxt = by_definition(L)
    assert esa.up[: len(L)] == up and esa.down[: len(L)] == down and esa.next[: len(L)] == nxt
    tree = naive_tree(text)
    for (a, b), kids in tree.items():
        assert esa.children(a, b) == kids, (text, a, b)
        assert esa.lcp_value(a, b) == min(L[a + 1 : b + 1])
    got = {(i, j) for _, i, j in esa.intervals()}
    assert got == set(tree) or (not text and got == {(0, 0)})
    t = text + "\0"
    pats = {text[i:j] for i in range(len(text)) for j in range(i, min(len(text), i + 6) + 1)} | {"x", "ba", "aaa", "gg"}
    for pat in pats:
        lo, hi = esa.find(pat)
        expect = [r for r, p in enumerate(sa) if t.startswith(pat, p)]
        assert list(range(lo, hi)) == expect, (text, pat)


def test_mississippi() -> None:
    esa = EnhancedSuffixArray("mississippi")
    assert esa.sa == [11, 10, 7, 4, 1, 0, 9, 8, 6, 3, 5, 2]
    assert esa.lcp == [0, 0, 1, 1, 4, 0, 0, 1, 0, 2, 1, 3, -1]
    assert esa.children(0, 11) == [(0, 0), (1, 4), (5, 5), (6, 7), (8, 11)]
    assert esa.children(8, 11) == [(8, 9), (10, 11)]
    assert esa.find("ssi") == (10, 12)
    check("mississippi")


def test_special() -> None:
    check("")
    for n in range(1, 22):
        check("a" * n)
        check("ab" * n)
        check("ab" * n + "a")


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

What the next level needs

  • the tests did not run in this build

Read the full page about Enhanced suffix array