Algorithm Search⌘K

Generalized suffix tree in Python

A suffix tree over a set of strings, used for multi-string problems such as longest common substring.

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

from bisect import bisect_right
from collections.abc import Sequence


def _suffix_array(z: list[int]) -> list[int]:
    """Suffix array of z by prefix doubling with sorted rank pairs."""
    n = len(z)
    sa = list(range(n))
    rank = z[:]
    k = 1
    while n > 1:
        key = [(rank[i], rank[i + k] if i + k < 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 - 1]] < key[sa[r]])
        rank = new
        if rank[sa[-1]] == n - 1:
            break
        k *= 2
    return sa


def _lcp_array(z: list[int], sa: list[int]) -> list[int]:
    """LCP array of z by Kasai's algorithm; lcp[0] = 0."""
    n = len(z)
    rank = [0] * n
    for i, p in enumerate(sa):
        rank[p] = i
    lcp = [0] * n
    h = 0
    for p in range(n):
        r = rank[p]
        if r == 0:
            h = 0
            continue
        q = sa[r - 1]
        while p + h < n and q + h < n and z[p + h] == z[q + h]:
            h += 1
        lcp[r] = h
        if h > 0:
            h -= 1
    return lcp


class GeneralizedSuffixTree:
    """Suffix tree of m strings, built from the suffix array of S_0 $_0 S_1 $_1 ... S_{m-1} $_{m-1}.

    Symbols need a total order. They are coded m, m + 1, ... in their order, and
    separator $_k is coded k, so the separators are distinct and smaller than all
    symbols. Positions are 0-based. Node 0 is the root. Each leaf is one nonempty
    suffix S_k[j ..] (with its separator) and is labeled (k, j).
    """

    def __init__(self, strings: Sequence[Sequence]) -> None:
        self.strings = [list(s) for s in strings]
        m = len(self.strings)
        self.m = m
        alphabet = sorted({c for s in self.strings for c in s})
        self.code = {c: m + r for r, c in enumerate(alphabet)}
        self.symbol = {m + r: c for r, c in enumerate(alphabet)}
        z: list[int] = []
        self.starts: list[int] = []
        for k, s in enumerate(self.strings):
            self.starts.append(len(z))
            z.extend(self.code[c] for c in s)
            z.append(k)
        self.z = z
        sa = _suffix_array(z)  # @line L1
        lcp = _lcp_array(z, sa)
        self.depth: list[int] = [0]  # @line L2
        self.pos: list[int] = [0]
        self.children: list[dict[int, int]] = [{}]
        self.leaf: list[tuple[int, int] | None] = [None]
        stack = [0]
        for i in range(m, len(z)):  # @line L3
            p, h = sa[i], lcp[i]  # @line L4
            last = -1
            while self.depth[stack[-1]] > h:  # @line L5
                last = stack.pop()
            top = stack[-1]
            if self.depth[top] < h:  # @line L6
                v = self._new_node(h, self.pos[last], None)  # @line L7
                self.children[v][z[self.pos[last] + h]] = last
                self.children[top][z[self.pos[last] + self.depth[top]]] = v  # @line L8
                stack.append(v)
                top = v
            k = bisect_right(self.starts, p) - 1
            end = self.starts[k] + len(self.strings[k])
            leaf = self._new_node(end + 1 - p, p, (k, p - self.starts[k]))  # @line L9
            self.children[top][z[p + self.depth[top]]] = leaf  # @line L10
            stack.append(leaf)

    def _new_node(self, depth: int, pos: int, label: tuple[int, int] | None) -> int:
        self.depth.append(depth)
        self.pos.append(pos)
        self.children.append({})
        self.leaf.append(label)
        return len(self.depth) - 1

    def node_count(self) -> tuple[int, int]:
        """Return (number of internal nodes including the root, number of leaves)."""
        leaves = sum(1 for x in self.leaf if x is not None)
        return len(self.depth) - leaves, leaves

    def _leaves_below(self, v: int) -> list[int]:
        out, todo = [], [v]
        while todo:
            x = todo.pop()
            if self.leaf[x] is not None:
                out.append(x)
            todo.extend(self.children[x].values())
        return out

    def _locus(self, pattern: Sequence) -> int:
        """Return the highest node whose path label starts with pattern, or -1."""
        q = []
        for c in pattern:
            if c not in self.code:
                return -1
            q.append(self.code[c])
        v, d = 0, 0
        while d < len(q):
            w = self.children[v].get(q[d], -1)
            if w < 0:
                return -1
            e = min(self.depth[w], len(q))
            while d < e:
                if self.z[self.pos[w] + d] != q[d]:
                    return -1
                d += 1
            v = w
        return v

    def find(self, pattern: Sequence) -> list[tuple[int, int]]:
        """Return the sorted occurrences (k, j) of a nonempty pattern in the strings."""
        v = self._locus(pattern)
        if v < 0 or not pattern:
            return []
        return sorted(self.leaf[x] for x in self._leaves_below(v))  # type: ignore[misc]

    def strings_containing(self, pattern: Sequence) -> list[int]:
        """Return the sorted ids k of the strings that contain a nonempty pattern."""
        return sorted({k for k, _ in self.find(pattern)})

    def longest_common_substring(self, k: int) -> list:
        """Return a longest string that occurs in at least k of the strings (k >= 1)."""
        n = len(self.depth)
        mask = [0] * n
        order, todo = [], [0]
        while todo:
            x = todo.pop()
            order.append(x)
            todo.extend(self.children[x].values())
        best, best_node = 0, 0
        for x in reversed(order):
            if self.leaf[x] is not None:
                mask[x] = 1 << self.leaf[x][0]
                length = self.depth[x] - 1
            else:
                for y in self.children[x].values():
                    mask[x] |= mask[y]
                length = self.depth[x]
            if length > best and bin(mask[x]).count("1") >= k:
                best, best_node = length, x
        p = self.pos[best_node]
        return [self.symbol[c] for c in self.z[p : p + best]]

    def check_invariant(self) -> None:
        z = self.z
        _, leaves = self.node_count()
        assert leaves == sum(len(s) for s in self.strings)
        for u in range(len(self.depth)):
            kids = self.children[u]
            if self.leaf[u] is not None:
                assert not kids
                k, j = self.leaf[u]
                p = self.starts[k] + j
                assert self.pos[u] == p and self.depth[u] == len(self.strings[k]) - j + 1
            elif u != 0:
                assert len(kids) >= 2
            for c, w in kids.items():
                assert self.depth[w] > self.depth[u]
                assert z[self.pos[w] + self.depth[u]] == c
                for t in range(self.depth[u]):
                    assert z[self.pos[w] + t] == z[self.pos[u] + t]
test_generalized_suffix_tree.py 94 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/generalized-suffix-tree/. The authors are listed in prov/4HGXBV.jsonl.

import random

from generalized_suffix_tree import GeneralizedSuffixTree


def one_string(rng: random.Random) -> str:
    kind = rng.randrange(6)
    n = rng.randrange(9)
    if kind == 0:
        return "a" * n
    if kind == 1:
        return "ab" * (n // 2)
    sigma = rng.choice([1, 2, 4])
    return "".join(rng.choice("abcd"[:sigma]) for _ in range(n))


def collections(seed: int, count: int = 600) -> list[list[str]]:
    rng = random.Random(seed)
    out = [[], [""], ["a"], ["", ""], ["xabxa", "babxba"], ["banana", "cabana"], ["aaaa", "aa"], ["abab", "baba"]]
    for _ in range(count):
        out.append([one_string(rng) for _ in range(rng.randint(1, 4))])
    return out


def naive_find(strings: list[str], p: str) -> list[tuple[int, int]]:
    return sorted((k, j) for k, s in enumerate(strings) for j in range(len(s) - len(p) + 1) if s[j : j + len(p)] == p)


def naive_internal_labels(strings: list[str]) -> list[str]:
    """Right-branching substrings: the path labels of the internal nodes, root included."""
    follow: dict[str, set] = {}
    for k, s in enumerate(strings):
        for i in range(len(s)):
            for j in range(i, len(s) + 1):
                nxt = s[j] if j < len(s) else ("$", k)
                follow.setdefault(s[i:j], set()).add(nxt)
    labels = {u for u, f in follow.items() if len(f) >= 2}
    labels.add("")
    return sorted(labels)


def naive_lcs_length(strings: list[str], k: int) -> int:
    best = 0
    subs = [{s[i:j] for i in range(len(s)) for j in range(i + 1, len(s) + 1)} for s in strings]
    for u in set().union(*subs) if subs else set():
        if len(u) > best and sum(u in t for t in subs) >= k:
            best = len(u)
    return best


def label(t: GeneralizedSuffixTree, v: int) -> str:
    return "".join(t.symbol[c] for c in t.z[t.pos[v] : t.pos[v] + t.depth[v]])


def test_example() -> None:
    t = GeneralizedSuffixTree(["xabxa", "babxba"])
    t.check_invariant()
    assert t.node_count() == (8, 11)
    assert t.find("bx") == [(0, 2), (1, 2)]
    assert t.find("xa") == [(0, 0), (0, 3)]
    assert t.strings_containing("ba") == [1]
    assert "".join(t.longest_common_substring(2)) == "abx"
    assert "".join(t.longest_common_substring(1)) == "babxba"


def test_structure_against_brute_force() -> None:
    for strings in collections(1):
        t = GeneralizedSuffixTree(strings)
        t.check_invariant()
        internal = sorted(label(t, v) for v in range(len(t.depth)) if t.leaf[v] is None)
        assert internal == naive_internal_labels(strings), strings


def test_find_against_brute_force() -> None:
    rng = random.Random(2)
    for strings in collections(3):
        t = GeneralizedSuffixTree(strings)
        pats = {s[i:j] for s in strings for i in range(len(s)) for j in range(i + 1, len(s) + 1)}
        pats = sorted(pats)[:12] + ["".join(rng.choice("abcde") for _ in range(rng.randint(1, 4))) for _ in range(4)]
        for p in pats:
            assert t.find(p) == naive_find(strings, p), (strings, p)
            assert t.strings_containing(p) == sorted({k for k, _ in naive_find(strings, p)})


def test_longest_common_substring_against_brute_force() -> None:
    for strings in collections(4):
        t = GeneralizedSuffixTree(strings)
        for k in range(1, len(strings) + 1):
            u = "".join(t.longest_common_substring(k))
            assert len(u) == naive_lcs_length(strings, k), (strings, k)
            assert sum(u in s for s in strings) >= k or u == ""

What the next level needs

  • the tests did not run in this build

Read the full page about Generalized suffix tree