Algorithm Search⌘K

McCreight's algorithm in Python

Linear-time suffix tree construction inserting suffixes from longest to shortest.

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

from collections.abc import Hashable, Iterable
from typing import Any


class _End:
    """The sentinel: equal only to itself, and smaller than every symbol."""

    def __repr__(self) -> str:
        return "$"


END = _End()


class McCreightTree:
    """McCreight's suffix tree construction, inserting the suffixes of S$ from longest to shortest.

    Symbols are any hashable values, found in child dicts by hashing; order is
    used only by suffix_array. Positions are 0-based. The unique sentinel END
    is appended, so the result is the suffix tree of S$ with one leaf for each
    of its n + 1 suffixes. Node v has the edge label t[start[v]:end[v]] and the
    string depth depth[v]. O(n) time for a constant alphabet; no recursion.
    The list trace records, for each suffix i, how its head was found.
    """

    def __init__(self, text: Iterable[Hashable] = ()) -> None:
        t: list[Any] = list(text)
        t.append(END)
        self.t = t
        n1 = len(t)  # n + 1 suffixes, numbered 0 to n
        self.start, self.end, self.depth = [0], [0], [0]
        self.parent, self.link, self.leaf = [0], [0], [-1]
        self.children: list[dict[Any, int]] = [{}]
        self.trace: list[tuple[int, str, int]] = []  # (i, case, depth of head_i)
        self._add_leaf(0, 0)  # @line L1: the leaf of suffix 0; head_0 is the root
        h = 0
        for i in range(1, n1):  # @line L2
            if h == 0:  # @line L3: the head was the root, scan from the root
                w, case = 0, "scan from root"
            elif self.link[h] >= 0:  # @line L4: an old node, follow its suffix link
                w, case = self.link[h], "link"
            else:  # @line L5: a new node, go to its parent and rescan its edge
                p = self.parent[h]
                a, b = self.start[h], self.end[h]
                if p == 0:
                    a += 1
                    v = 0
                else:
                    v = self.link[p]
                w, inside = self._rescan(v, a, b)  # @line L6
                self.link[h] = w  # @line L7
                case = "rescan, new node" if inside else "rescan"
                if inside:  # head_i is the new node w: no scan
                    self._add_leaf(w, i)
                    self.trace.append((i, case, self.depth[w]))
                    h = w
                    continue
            hd = self._scan(w, i)  # @line L8: compare symbols until the first mismatch
            self._add_leaf(hd, i)  # @line L9
            self.trace.append((i, case, self.depth[hd]))
            h = hd  # @line L10
        # @line L11: the tree is complete

    def _new(self, a: int, b: int, p: int, leaf: int) -> int:
        self.start.append(a)
        self.end.append(b)
        self.depth.append(self.depth[p] + (b - a))
        self.parent.append(p)
        self.link.append(-1 if leaf < 0 else 0)
        self.leaf.append(leaf)
        self.children.append({})
        self.children[p][self.t[a]] = len(self.start) - 1
        return len(self.start) - 1

    def _split(self, w: int, k: int) -> int:
        """Split the edge into w after k symbols; return the new inner node."""
        p, a = self.parent[w], self.start[w]
        u = self._new(a, a + k, p, -1)
        self.start[w] = a + k
        self.parent[w] = u
        self.children[u][self.t[a + k]] = w
        return u

    def _add_leaf(self, v: int, i: int) -> None:
        self._new(i + self.depth[v], len(self.t), v, i)

    def _rescan(self, v: int, a: int, b: int) -> tuple[int, bool]:
        """Go down from v along t[a:b], known to be in the tree, by edge lengths only."""
        t = self.t
        while a < b:
            w = self.children[v][t[a]]
            ln = self.end[w] - self.start[w]
            if ln > b - a:
                return self._split(w, b - a), True
            v, a = w, a + ln
        return v, False

    def _scan(self, v: int, i: int) -> int:
        """From the node v on the path of suffix i, compare symbols down to the mismatch."""
        t = self.t
        k = i + self.depth[v]
        while True:
            w = self.children[v].get(t[k])
            if w is None:
                return v
            a, b = self.start[w], self.end[w]
            j = 1
            while j < b - a and t[a + j] == t[k + j]:
                j += 1
            if j < b - a:
                return self._split(w, j)
            v, k = w, k + j

    def contains(self, p: Iterable[Hashable]) -> bool:
        """Whether p is a substring of S."""
        v, k, t = 0, 0, self.t
        for c in p:
            if c is END:
                return False
            if k == self.end[v] - self.start[v]:
                w = self.children[v].get(c)
                if w is None:
                    return False
                v, k = w, 0
            if t[self.start[v] + k] != c:
                return False
            k += 1
        return True

    def suffix_array(self) -> list[int]:
        """The suffix array of S, by a depth-first walk in symbol order."""
        n = len(self.t) - 1
        out, stack = [], [0]
        while stack:
            v = stack.pop()
            if 0 <= self.leaf[v] < n:
                out.append(self.leaf[v])
            keys = sorted(self.children[v], key=lambda c: (c is not END, 0 if c is END else c))
            stack.extend(self.children[v][c] for c in reversed(keys))
        return out

    def node_count(self) -> int:
        return len(self.start)

    def check_invariant(self) -> None:
        """Compact trie of S$; suffix links drop one symbol; leaves spell the suffixes."""
        t = self.t
        order, stack = [], [0]
        while stack:
            v = stack.pop()
            order.append(v)
            for c, w in self.children[v].items():
                assert t[self.start[w]] == c and self.end[w] > self.start[w]
                assert self.parent[w] == v
                assert self.depth[w] == self.depth[v] + self.end[w] - self.start[w]
                stack.append(w)
        assert len(order) == len(self.start)

        def label(v: int) -> list[Any]:
            parts = []
            while v != 0:
                parts.append(t[self.start[v]:self.end[v]])
                v = self.parent[v]
            return [x for part in reversed(parts) for x in part]

        for v in order:
            if self.leaf[v] >= 0:
                assert not self.children[v] and label(v) == t[self.leaf[v]:]
            elif v != 0:
                assert len(self.children[v]) >= 2
                assert self.link[v] >= 0 and label(self.link[v]) == label(v)[1:]
        assert sorted(self.leaf[v] for v in order if self.leaf[v] >= 0) == list(range(len(t)))
test_mccreight_algorithm.py 75 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/mccreight-algorithm/. The authors are listed in prov/1S27ZF.jsonl.

import random

from mccreight_algorithm import McCreightTree


def naive_node_count(s: str) -> int:
    """Root, n + 1 leaves, and one inner node per right-branching substring of S$."""
    t = list(s) + [None]
    ext: dict[str, set] = {}
    for i in range(len(s)):
        for j in range(i + 1, len(s) + 1):
            ext.setdefault(s[i:j], set()).add(t[j])
    return len(s) + 2 + sum(1 for x in ext.values() if len(x) >= 2)


def test_example() -> None:
    tr = McCreightTree("aaabaaa")
    tr.check_invariant()
    assert tr.node_count() == 12
    assert tr.suffix_array() == [6, 5, 4, 0, 1, 2, 3]
    assert [(i, d) for i, _, d in tr.trace] == [(1, 2), (2, 1), (3, 0), (4, 3), (5, 2), (6, 1), (7, 0)]
    assert [c for _, c, _ in tr.trace] == [
        "scan from root", "rescan, new node", "rescan", "scan from root", "rescan", "link", "link"]
    tr = McCreightTree("banana")
    tr.check_invariant()
    assert tr.suffix_array() == [5, 3, 1, 0, 4, 2] and tr.node_count() == 11
    assert tr.contains("nan") and not tr.contains("nab") and tr.contains("")


def test_edges() -> None:
    tr = McCreightTree("")
    tr.check_invariant()
    assert tr.suffix_array() == [] and tr.node_count() == 2
    for n in (1, 2, 7, 500):
        tr = McCreightTree("a" * n)
        if n < 50:
            tr.check_invariant()
        assert tr.node_count() == 2 * n + 1
        assert tr.suffix_array() == list(range(n - 1, -1, -1))
    s = "ab" * 300
    tr = McCreightTree(s)
    assert tr.suffix_array() == sorted(range(600), key=lambda i: s[i:])
    tr = McCreightTree([3, 1, 4, 1, 5, 9, 2, 6])
    tr.check_invariant()
    assert tr.suffix_array() == [1, 3, 6, 0, 2, 4, 7, 5]


def test_against_naive() -> None:
    rng = random.Random(1976)
    for trial in range(800):
        sigma = "abcd"[: rng.choice([1, 2, 4])]
        n = rng.randint(0, 24)
        s = "".join(rng.choice(sigma) for _ in range(n))
        if trial % 25 == 0:
            s = rng.choice(["a" * n, "ab" * (n // 2), ""])
        tr = McCreightTree(s)
        tr.check_invariant()
        assert tr.suffix_array() == sorted(range(len(s)), key=lambda i: s[i:])
        assert tr.node_count() == naive_node_count(s)
        for _ in range(8):
            p = "".join(rng.choice(sigma + "e") for _ in range(rng.randint(0, 4)))
            assert tr.contains(p) == (p in s)


def test_linear_work() -> None:
    """Long random and periodic strings build without recursion and sort correctly."""
    rng = random.Random(7)
    for s in ["".join(rng.choice("ab") for _ in range(4000)), "abc" * 1500, "a" * 3000 + "b"]:
        tr = McCreightTree(s)
        assert tr.node_count() <= 2 * len(s) + 1
        sa = tr.suffix_array()
        assert len(sa) == len(s) and all(s[sa[r - 1]:] < s[sa[r]:] for r in range(1, min(len(s), 300)))

What the next level needs

  • the tests did not run in this build

Read the full page about McCreight's algorithm