Algorithm Search⌘K

Apostolico-Giancarlo algorithm in Python

Boyer-Moore variant that records previous match information to bound the total comparisons linearly.

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

from collections.abc import Hashable, Sequence


def suffixes(p: Sequence[Hashable]) -> list[int]:
    """suff[i] = length of the longest common suffix of p[:i+1] and p."""
    m = len(p)
    suff = [0] * m
    if m == 0:
        return suff
    suff[m - 1] = m
    g = f = m - 1
    for i in range(m - 2, -1, -1):
        if i > g and suff[i + m - 1 - f] < i - g:
            suff[i] = suff[i + m - 1 - f]
        else:
            g = min(g, i)
            f = i
            while g >= 0 and p[g] == p[g + m - 1 - f]:
                g -= 1
            suff[i] = f - g
    return suff


def good_suffix(p: Sequence[Hashable], suff: list[int]) -> list[int]:
    """Strong good-suffix table; gs[0] is the smallest period of p."""
    m = len(p)
    gs = [m] * m
    j = 0
    for i in range(m - 1, -1, -1):
        if suff[i] == i + 1:
            while j < m - 1 - i:
                if gs[j] == m:
                    gs[j] = m - 1 - i
                j += 1
    for i in range(m - 1):
        gs[m - 1 - suff[i]] = m - 1 - i
    return gs


def apostolico_giancarlo(text: Sequence[Hashable], pattern: Sequence[Hashable], stats: dict | None = None) -> list[int]:
    """All shifts s with text[s:s+m] == pattern, in increasing order.

    skip[t % m] holds, for a text position t of the current window, the length of the
    longest suffix of the pattern known to end at t (0 if unknown).
    If stats is a dict, stats["comparisons"] receives the number of character comparisons.
    """
    n, m = len(text), len(pattern)  # @line L1
    if m == 0:  # @line L2
        if stats is not None:
            stats["comparisons"] = 0
        return list(range(n + 1))
    suff = suffixes(pattern)  # @line L3
    gs = good_suffix(pattern, suff)
    last = {c: k for k, c in enumerate(pattern)}
    skip = [0] * m  # @line L4
    out, comparisons = [], 0
    s = 0
    while s <= n - m:  # @line L5
        i = m - 1  # @line L6
        while i >= 0:  # @line L7
            k = skip[(s + i) % m]  # @line L8
            h = suff[i]
            if k > 0:  # @line L9
                if k > h:  # @line L10
                    i = -1 if h == i + 1 else i - h
                    break
                i -= k  # @line L11
                if k < h:
                    break
            else:
                comparisons += 1  # @line L12
                if pattern[i] == text[s + i]:
                    i -= 1
                else:
                    break
        if i < 0:  # @line L13
            out.append(s)
            skip[(s + m - 1) % m] = m
            shift = gs[0]
        else:  # @line L14
            skip[(s + m - 1) % m] = m - 1 - i
            shift = max(gs[i], i - last.get(text[s + i], -1))
        for t in range(s, s + min(shift, m)):  # @line L15
            skip[t % m] = 0
        s += shift  # @line L16
    if stats is not None:
        stats["comparisons"] = comparisons
    return out
test_apostolico_giancarlo.py 62 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/apostolico-giancarlo/. The authors are listed in prov/7XHZHF.jsonl.

import itertools
import random

from apostolico_giancarlo import apostolico_giancarlo, good_suffix, suffixes


def oracle(t: str, p: str) -> list[int]:
    return [s for s in range(len(t) - len(p) + 1) if t.startswith(p, s)]


def test_example() -> None:
    stats: dict = {}
    assert apostolico_giancarlo("gcatcgcagagagtatacagtacg", "gcagagag", stats) == [5]
    assert stats["comparisons"] == 15


def test_tables() -> None:
    p = "gcagagag"
    suff = suffixes(p)
    assert suff == [1, 0, 0, 2, 0, 4, 0, 8]
    assert good_suffix(p, suff) == [7, 7, 7, 2, 7, 4, 7, 1]


def test_edges() -> None:
    assert apostolico_giancarlo("abc", "") == [0, 1, 2, 3]
    assert apostolico_giancarlo("", "") == [0]
    assert apostolico_giancarlo("", "a") == []
    assert apostolico_giancarlo("ab", "abc") == []
    assert apostolico_giancarlo("a" * 10, "a" * 4) == list(range(7))
    assert apostolico_giancarlo("a" * 10, "baaa") == []


def test_periodic_input_is_linear() -> None:
    stats: dict = {}
    assert apostolico_giancarlo("a" * 1000, "a" * 10, stats) == list(range(991))
    assert stats["comparisons"] <= 1000


def test_exhaustive_binary() -> None:
    for m in range(1, 5):
        for p in itertools.product("ab", repeat=m):
            ps = "".join(p)
            for n in range(0, 9):
                for t in itertools.product("ab", repeat=n):
                    ts = "".join(t)
                    stats: dict = {}
                    assert apostolico_giancarlo(ts, ps, stats) == oracle(ts, ps)
                    assert 2 * stats["comparisons"] <= 3 * n


def test_random_against_oracle() -> None:
    rng = random.Random(1986)
    for alphabet in ("a", "ab", "abcd"):
        for _ in range(700):
            t = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 40)))
            p = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 7)))
            stats: dict = {}
            assert apostolico_giancarlo(t, p, stats) == oracle(t, p)
            assert 2 * stats["comparisons"] <= 3 * len(t)

What the next level needs

  • the tests did not run in this build

Read the full page about Apostolico-Giancarlo algorithm