Algorithm Search⌘K

Farach's algorithm in Python

Linear-time suffix tree construction for integer alphabets, via a suffix array of odd positions.

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

from collections.abc import Hashable, Sequence


class Tree:
    """A rooted tree with string depths. Node 0 is the root; label[v] is a suffix position or -1."""

    def __init__(self) -> None:
        self.depth: list[int] = []
        self.kids: list[list[int]] = []
        self.label: list[int] = []
        self.pos: list[int] = []  # a suffix position in the subtree: the leftmost leaf

    def new(self, depth: int, label: int = -1) -> int:
        self.depth.append(depth)
        self.kids.append([])
        self.label.append(label)
        self.pos.append(label)
        return len(self.depth) - 1

    def add(self, parent: int, child: int) -> None:
        if not self.kids[parent] and self.label[parent] < 0:
            self.pos[parent] = self.pos[child]
        self.kids[parent].append(child)


def tree_from_sa(sa: list[int], lcp: list[int], size: int) -> Tree:
    """The compacted trie of the suffixes sa, in order, with their adjacent LCP values (stack build)."""
    tree = Tree()
    stack = [tree.new(0)]
    for k, p in enumerate(sa):
        h, last = (lcp[k] if k else 0), -1
        while tree.depth[stack[-1]] > h:
            last = stack.pop()
            if tree.depth[stack[-1]] >= h:
                tree.add(stack[-1], last)
                last = -1
        if tree.depth[stack[-1]] < h:
            v = tree.new(h)
            tree.add(v, last)
            stack.append(v)
        stack.append(tree.new(size - p, p))
    while len(stack) > 1:
        last = stack.pop()
        tree.add(stack[-1], last)
    return tree


class SparseMin:
    """Range minimum values over a fixed list in O(1) per query."""

    def __init__(self, a: list[int]) -> None:
        self.t = [a[:]]
        j = 1
        while 2 * j <= len(a):
            prev = self.t[-1]
            self.t.append([min(prev[i], prev[i + j]) for i in range(len(a) - 2 * j + 1)])
            j *= 2

    def query(self, lo: int, hi: int) -> int:
        """min a[lo .. hi], lo <= hi."""
        k = (hi - lo + 1).bit_length() - 1
        return min(self.t[k][lo], self.t[k][hi - (1 << k) + 1])


def farach(codes: list[int]) -> tuple[list[int], list[int]]:
    """SA and LCP of t = codes + [0], where the codes are positive integers.

    LCP[0] = 0 and LCP[k] is the lcp of the suffixes at ranks k - 1 and k. The
    recursion is on half the length, so its depth is O(log n).
    """
    t = codes + [0]
    N = len(t)
    if N <= 4:  # @line L1
        sa = sorted(range(N), key=lambda p: t[p:])
        return sa, [0] + [_lcp_naive(t, sa[k - 1], sa[k]) for k in range(1, N)]
    pairs = [(t[p], t[p + 1] if p + 1 < N else 0) for p in range(1, N, 2)]  # @line L2
    name = {pr: r + 1 for r, pr in enumerate(sorted(set(pairs)))}
    sa_r, lcp_r = farach([name[pr] for pr in pairs])  # @line L3
    sa_o = [2 * r + 1 for r in sa_r[1:]]  # @line L4
    lcp_o = [0] * len(sa_o)
    for k in range(1, len(sa_o)):
        h = lcp_r[k + 1]
        x, y = sa_o[k - 1] + 2 * h, sa_o[k] + 2 * h
        lcp_o[k] = 2 * h + (t[x] == t[y])
    rank = [0] * N
    for k, p in enumerate(sa_o):
        rank[p] = k
    sa_e = sorted(range(0, N, 2), key=lambda p: (t[p], rank[p + 1] if p + 1 < N else -1))  # @line L5
    rmq = SparseMin(lcp_o)
    lcp_e = [0] * len(sa_e)
    for k in range(1, len(sa_e)):  # @line L6
        a, b = sa_e[k - 1], sa_e[k]
        if t[a] == t[b]:
            lo, hi = sorted((rank[a + 1], rank[b + 1]))
            lcp_e[k] = 1 + rmq.query(lo + 1, hi)
    tree_o, tree_e = tree_from_sa(sa_o, lcp_o, N), tree_from_sa(sa_e, lcp_e, N)  # @line L7
    m = Merged(t, tree_o, tree_e)  # @line L8
    sa, i, j = [], 0, 0
    while i < len(sa_o) or j < len(sa_e):  # @line L9
        if j == len(sa_e) or (i < len(sa_o) and m.less(sa_o[i], sa_e[j])):
            sa.append(sa_o[i])
            i += 1
        else:
            sa.append(sa_e[j])
            j += 1
    lcp = [0] + [m.lcp(sa[k - 1], sa[k]) for k in range(1, N)]  # @line L10
    return sa, lcp  # @line L11


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


class Merged:
    """The overmerged tree M of the odd tree and the even tree, with LCA and the d-tree depths."""

    def __init__(self, t: list[int], tree_o: Tree, tree_e: Tree) -> None:
        self.t, self.n = t, len(t) - 1
        self.M = M = Tree()
        root = M.new(0)  # @line L12
        stack = [(root, (0, 0), (0, 0))]
        while stack:
            v, so, se = stack.pop()
            co, ce = self._next(tree_o, so), self._next(tree_e, se)
            for a in sorted(set(co) | set(ce)):  # @line L13
                x, y = co.get(a), ce.get(a)
                d = min(tree_o.depth[x] if x is not None else 1 << 62, tree_e.depth[y] if y is not None else 1 << 62)  # @line L14
                c = M.new(d)
                for tree, z in ((tree_o, x), (tree_e, y)):
                    if z is not None and tree.depth[z] == d and tree.label[z] >= 0:
                        M.label[c] = M.pos[c] = tree.label[z]
                M.add(v, c)
                stack.append((c, (x, d) if x is not None else None, (y, d) if y is not None else None))  # @line L15
        self._euler()
        self.memo: dict[int, int] = {}

    def _next(self, tree: Tree, s: tuple[int, int] | None) -> dict[int, int]:
        """First symbol -> target node of the edges below the point s = (node, depth) of tree."""
        if s is None:
            return {}
        v, d = s
        if d < tree.depth[v]:
            return {self.t[tree.pos[v] + d]: v}
        return {self.t[tree.pos[c] + d]: c for c in tree.kids[v]}

    def _euler(self) -> None:
        M = self.M
        self.leaf = [0] * len(self.t)
        self.first = [0] * len(M.depth)
        tour: list[int] = []
        stack = [(0, 0)]
        while stack:
            v, k = stack.pop()
            if k == 0:
                self.first[v] = len(tour)
                if M.label[v] >= 0:
                    self.leaf[M.label[v]] = v
            tour.append(v)
            if k < len(M.kids[v]):
                stack.append((v, k + 1))
                stack.append((M.kids[v][k], 0))
        self.tour = tour
        enc = [M.depth[v] * len(M.depth) + v for v in tour]  # depth first, then the node id
        self.rmq = SparseMin(enc)

    def lca(self, a: int, b: int) -> int:
        """LCA in M of the leaves of the suffixes a and b."""
        i, j = sorted((self.first[self.leaf[a]], self.first[self.leaf[b]]))
        return self.rmq.query(i, j) % len(self.M.depth)

    def ell(self, x: int, y: int) -> int:
        """lcp of suffixes x and y of different parity: the depth of LCA(x, y) in the d-tree."""
        chain = []
        while True:
            v = self.lca(x, y)  # @line L16
            if v == 0:
                base = 0
                break
            if v in self.memo:  # @line L17
                base = self.memo[v]
                break
            chain.append(v)  # @line L18
            x, y = x + 1, y + 1
        for v in reversed(chain):
            base += 1
            self.memo[v] = base
        return base

    def lcp(self, a: int, b: int) -> int:
        if (a - b) % 2 == 0:
            return self.M.depth[self.lca(a, b)]
        return self.ell(a, b)

    def less(self, a: int, b: int) -> bool:
        h = self.lcp(a, b)
        return self.t[a + h] < self.t[b + h]


def suffix_array_lcp(text: Sequence[Hashable]) -> tuple[list[int], list[int]]:
    """SA and LCP of text + $, with $ unique and smaller than every symbol; 0-based."""
    code = {c: r + 1 for r, c in enumerate(sorted(set(text)))}
    return farach([code[c] for c in text])


def suffix_tree(text: Sequence[Hashable]) -> Tree:
    """The suffix tree of text + $, built from Farach's SA and LCP."""
    sa, lcp = suffix_array_lcp(text)
    return tree_from_sa(sa, lcp, len(text) + 1)
test_farach_algorithm.py 94 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/farach-algorithm/. The authors are listed in prov/5GVP55.jsonl.

import random

from farach_algorithm import Merged, farach, suffix_array_lcp, suffix_tree, tree_from_sa


def naive(text: str) -> tuple[list[int], list[int]]:
    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[k - 1], sa[k]) for k in range(1, len(t))]


def check(text: str) -> None:
    assert suffix_array_lcp(text) == naive(text), text
    tree = suffix_tree(text)
    t = text + "\0"
    nodes = {t[:0]}
    for v in range(len(tree.depth)):
        p = tree.pos[v]
        nodes.add(t[p:p + tree.depth[v]])
    branching = {u for u in {text[i:j] for i in range(len(text)) for j in range(i, len(text) + 1)} if len({t[i + len(u)] for i in range(len(text)) if t.startswith(u, i)}) >= 2}
    assert nodes == branching | {t[p:] for p in range(len(t))} | {""}
    assert len(tree.depth) == len(nodes)


def check_merge(text: str) -> None:
    """The d-tree depths equal the true lcp of every odd-even pair at the top level."""
    code = {c: r + 1 for r, c in enumerate(sorted(set(text)))}
    t = [code[c] for c in text] + [0]
    N = len(t)
    sa, lcp = naive(text)
    odd = [p for p in sa if p % 2 == 1]
    even = [p for p in sa if p % 2 == 0]

    def adj(order: list[int]) -> list[int]:
        return [0] + [naive_lcp(t, order[k - 1], order[k]) for k in range(1, len(order))]

    m = Merged(t, tree_from_sa(odd, adj(odd), N), tree_from_sa(even, adj(even), N))
    for x in odd:
        for y in even:
            assert m.ell(x, y) == naive_lcp(t, x, y), (text, x, y)
            assert m.less(x, y) == (t[x:] < t[y:])


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


def test_examples() -> None:
    sa, lcp = suffix_array_lcp("mississippi")
    assert sa == [11, 10, 7, 4, 1, 0, 9, 8, 6, 3, 5, 2]
    assert lcp == [0, 0, 1, 1, 4, 0, 0, 1, 0, 2, 1, 3]
    assert farach([2, 1, 2, 1]) == ([4, 3, 1, 2, 0], [0, 0, 1, 0, 2])
    check("mississippi")
    check_merge("mississippi")


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


def test_random() -> None:
    rng = random.Random(41)
    for trial in range(900):
        sigma = (1, 2, 4)[trial % 3]
        text = "".join(rng.choice("acgt"[:sigma]) for _ in range(rng.randrange(0, 60)))
        check(text)
        if len(text) < 30:
            check_merge(text)


def test_long() -> None:
    rng = random.Random(7)
    for sigma in (1, 2, 4):
        text = "".join(rng.choice("acgt"[:sigma]) for _ in range(3000))
        assert suffix_array_lcp(text) == naive(text)

What the next level needs

  • the tests did not run in this build

Read the full page about Farach's algorithm