Algorithm Search⌘K

Galil-Park algorithm in Python

O(nk) algorithm for k-differences that uses constant-time longest-common-extension queries.

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

"""The Galil-Park algorithm for approximate string matching with k differences.

Edit model: unit-cost insertion, deletion and substitution (Levenshtein),
no transpositions. Indices are 0-based. The result is the sorted list of end
positions j in 0..len(text) such that some substring text[h:j] has edit
distance at most k to the pattern.

The furthest rows L[d][e] of the diagonals are computed in order of the
anti-diagonals c = d + e. A pattern-text extension is derived from a table of
pattern-pattern extensions and a reference triple (q, y, f), which records that
text[q:q+f] equals pattern[y:y+f] and that the match cannot be extended. The
text is never indexed by a suffix structure.
"""

NONE = -(10**18)  # stands for "no cell of the diagonal has a value <= e"


def pattern_extensions(pattern: str) -> list[list[int]]:
    """Return pp with pp[a][b] = length of the longest common prefix of
    pattern[a:] and pattern[b:], for 0 <= a, b <= m. Cost: Theta(m^2)."""
    m = len(pattern)
    pp = [[0] * (m + 1) for _ in range(m + 1)]
    for a in range(m - 1, -1, -1):
        row, below = pp[a], pp[a + 1]
        for b in range(m - 1, -1, -1):
            if pattern[a] == pattern[b]:
                row[b] = below[b + 1] + 1
    return pp


def galil_park(pattern: str, text: str, k: int) -> list[int]:
    """Return the end positions of the approximate matches of pattern in text.

    Precondition: k >= 0. Uses Theta(m^2) space for the pattern table and
    O(k) space for the furthest rows and the reference triples.
    """
    if k < 0:
        raise ValueError("k must be at least 0")
    m, n = len(pattern), len(text)
    pp = pattern_extensions(pattern)  # @line L1
    ref: list[tuple[int, int, int] | None] = [None] * (k + 1)  # @line L2
    front = 0  # text positions below front have been read
    rows = [[NONE] * (k + 1) for _ in range(3)]  # rows[c % 3][e] = L[c - e][e]
    ends = []
    for c in range(n + k + 1):  # @line L3
        cur, p1, p2 = rows[c % 3], rows[(c - 1) % 3], rows[(c - 2) % 3]
        for e in range(k + 1):  # @line L4
            d = c - e
            if d > n or d < -m:
                cur[e] = NONE
                continue
            r = max(0, -d)  # @line L5
            if e > 0:
                if c >= 1 and p1[e - 1] != NONE:
                    r = max(r, p1[e - 1] + 1)  # L[d][e-1] + 1
                if c >= 2 and p2[e - 1] != NONE:
                    r = max(r, p2[e - 1])  # L[d-1][e-1]
                if cur[e - 1] != NONE:
                    r = max(r, cur[e - 1] + 1)  # L[d+1][e-1] + 1
            r = min(r, m, n - d)
            r0, x = r, r + d  # @line L6
            while r < m and x < n:  # @line L7
                if x >= front:  # @line L8
                    if pattern[r] != text[x]:
                        front = x + 1
                        break
                    r, x = r + 1, x + 1
                    front = x
                    continue
                best = None
                for t in ref:  # @line L9
                    if t is not None and t[0] <= x < t[0] + t[2]:
                        if best is None or t[0] + t[2] > best[0] + best[2]:
                            best = t
                if best is not None:
                    q, y, f = best
                    g, h = pp[r][y + x - q], q + f - x
                    if g != h:  # @line L10
                        r += min(g, h)
                        break
                    r, x = r + g, x + g
                elif pattern[r] == text[x]:  # @line L11
                    r, x = r + 1, x + 1
                else:
                    break
            cur[e] = r  # @line L12
            if r > r0:
                old = ref[e]
                if old is None or r + d > old[0] + old[2]:
                    ref[e] = (r0 + d, r0, r - r0)
            if e == k and r == m:  # @line L13
                ends.append(m + d)
    return ends
test_galil_park_algorithm.py 71 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/galil-park-algorithm/. The authors are listed in prov/0F6W2E.jsonl.

import random

import pytest
from galil_park_algorithm import galil_park, pattern_extensions


def edit_distance(a: str, b: str) -> int:
    d = [[i + j if i * j == 0 else 0 for j in range(len(b) + 1)] for i in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] != b[j - 1]))
    return d[len(a)][len(b)]


def brute(pattern: str, text: str, k: int) -> list[int]:
    return [
        j for j in range(len(text) + 1)
        if min(edit_distance(pattern, text[h:j]) for h in range(j + 1)) <= k
    ]


def test_example() -> None:
    assert galil_park("GATAA", "CAGATAAGAGAA", 1) == [6, 7, 8, 12]
    assert galil_park("GATAA", "CAGATAAGAGAA", 0) == [7]
    assert galil_park("survey", "surgery", 2) == [5, 6, 7]


def test_edges() -> None:
    assert galil_park("", "", 0) == [0]
    assert galil_park("", "ab", 0) == [0, 1, 2]
    assert galil_park("ab", "", 1) == []
    assert galil_park("ab", "", 2) == [0]
    assert galil_park("ab", "xyz", 5) == [0, 1, 2, 3]
    with pytest.raises(ValueError):
        galil_park("a", "a", -1)


def test_pattern_extensions() -> None:
    rng = random.Random(5)
    for _ in range(200):
        p = "".join(rng.choice("ab") for _ in range(rng.randint(0, 9)))
        pp = pattern_extensions(p)
        for a in range(len(p) + 1):
            for b in range(len(p) + 1):
                q = 0
                while a + q < len(p) and b + q < len(p) and p[a + q] == p[b + q]:
                    q += 1
                assert pp[a][b] == q


def test_random_against_brute_force() -> None:
    rng = random.Random(1990)
    for alphabet in ("ab", "acgt"):
        for _ in range(500):
            p = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 6)))
            t = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 12)))
            k = rng.randint(0, 4)
            assert galil_park(p, t, k) == brute(p, t, k)


def test_long_random_against_brute_force() -> None:
    rng = random.Random(89)
    for alphabet in ("ab", "acgt"):
        for _ in range(60):
            p = "".join(rng.choice(alphabet) for _ in range(rng.randint(5, 12)))
            t = "".join(rng.choice(alphabet) for _ in range(rng.randint(20, 40)))
            k = rng.randint(0, 3)
            assert galil_park(p, t, k) == brute(p, t, k)

What the next level needs

  • the tests did not run in this build

Read the full page about Galil-Park algorithm