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
# 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