Fischer-Paterson algorithm in Python
Computes Hamming distances at all alignments with one FFT-based convolution per alphabet symbol, in O(σ n log m) time.
- File
fischer_paterson_algorithm.py, 91 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/fischer-paterson-algorithm/. The authors are listed in prov/PNQWEP.jsonl.
"""Hamming distances at all alignments by one convolution per symbol.
Edit model: Hamming distance (substitutions only). Indices are 0-based.
The result h has one entry per start position i in 0..len(text)-len(pattern):
h[i] is the number of q with pattern[q] != text[i + q]. An empty pattern
gives len(text) + 1 zeros, and a pattern longer than the text gives [].
The convolutions use a number-theoretic transform modulo the prime
998244353, which is exact because every count is at most len(pattern).
"""
MOD = 998244353
ROOT = 3 # a primitive root modulo MOD
def ntt(a: list[int], invert: bool) -> None:
"""Transform a in place; len(a) is a power of two that divides 2**23."""
n = len(a)
j = 0
for i in range(1, n):
bit = n >> 1
while j & bit:
j ^= bit
bit >>= 1
j |= bit
if i < j:
a[i], a[j] = a[j], a[i]
length = 2
while length <= n:
w = pow(ROOT, (MOD - 1) // length, MOD)
if invert:
w = pow(w, MOD - 2, MOD)
half = length >> 1
for start in range(0, n, length):
wn = 1
for k in range(start, start + half):
u = a[k]
v = a[k + half] * wn % MOD
a[k] = (u + v) % MOD
a[k + half] = (u - v) % MOD
wn = wn * w % MOD
length <<= 1
if invert:
inv_n = pow(n, MOD - 2, MOD)
for i in range(n):
a[i] = a[i] * inv_n % MOD
def hamming_distances(pattern: str, text: str) -> list[int]:
"""Return h[0..n-m], the Hamming distance of pattern to each window.
Cost: O(s * n * log m) time for s distinct pattern symbols and
O(n + m) extra space, with text blocks of length 2m - 1.
"""
m, n = len(pattern), len(text)
if m == 0:
return [0] * (n + 1)
if m > n:
return []
agree = [0] * (n - m + 1) # @line L1
size = 1
while size < 3 * m - 2:
size <<= 1
for c in sorted(set(pattern)): # @line L2
x = [0] * size
for q in range(m): # @line L3
if pattern[m - 1 - q] == c:
x[q] = 1
ntt(x, False)
for b in range(0, n - m + 1, m): # @line L4
seg = text[b:b + 2 * m - 1]
y = [0] * size
for j, t in enumerate(seg): # @line L5
if t == c:
y[j] = 1
ntt(y, False)
z = [u * v % MOD for u, v in zip(x, y)] # @line L6
ntt(z, True)
for t in range(min(m, n - m + 1 - b)): # @line L7
agree[b + t] += z[t + m - 1]
return [m - a for a in agree] # @line L8
def k_mismatch_starts(pattern: str, text: str, k: int) -> list[int]:
"""Return the start positions with at most k mismatches (k >= 0)."""
if k < 0:
raise ValueError("k must be at least 0")
return [i for i, d in enumerate(hamming_distances(pattern, text)) if d <= k]
test_fischer_paterson_algorithm.py 70 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fischer-paterson-algorithm/. The authors are listed in prov/PNQWEP.jsonl.
import random
import pytest
from fischer_paterson_algorithm import MOD, hamming_distances, k_mismatch_starts, ntt
def brute(pattern: str, text: str) -> list[int]:
m = len(pattern)
return [
sum(a != b for a, b in zip(pattern, text[i:i + m]))
for i in range(len(text) - m + 1)
]
def test_examples() -> None:
assert hamming_distances("abc", "abxabcbc") == [1, 3, 3, 0, 3, 1]
assert hamming_distances("ACGTA", "ACGAAACGTTACCTA") == [1, 4, 5, 4, 4, 1, 3, 5, 5, 5, 1]
assert k_mismatch_starts("abc", "abxabcbc", 1) == [0, 3, 5]
def test_edges() -> None:
assert hamming_distances("", "") == [0]
assert hamming_distances("", "ab") == [0, 0, 0]
assert hamming_distances("a", "") == []
assert hamming_distances("abc", "ab") == []
assert hamming_distances("ab", "ab") == [0]
assert hamming_distances("ab", "xyzw") == [2, 2, 2]
assert k_mismatch_starts("ab", "xyzw", 0) == []
with pytest.raises(ValueError):
k_mismatch_starts("a", "a", -1)
def test_ntt_round_trip_and_product() -> None:
rng = random.Random(5)
for size in (1, 2, 4, 8, 32):
a = [rng.randrange(100) for _ in range(size // 2 or 1)] + [0] * (size - (size // 2 or 1))
b = [rng.randrange(100) for _ in range(size // 2 or 1)] + [0] * (size - (size // 2 or 1))
want = [0] * size
for i, u in enumerate(a):
for j, v in enumerate(b):
if i + j < size:
want[i + j] += u * v
fa, fb = a[:], b[:]
ntt(fa, False)
ntt(fb, False)
prod = [u * v % MOD for u, v in zip(fa, fb)]
ntt(prod, True)
assert prod == want
def test_random_against_brute_force() -> None:
rng = random.Random(1974)
for alphabet in ("ab", "acgt"):
for _ in range(700):
p = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 9)))
t = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 30)))
assert hamming_distances(p, t) == brute(p, t), (p, t)
k = rng.randint(0, 5)
assert k_mismatch_starts(p, t, k) == [i for i, d in enumerate(brute(p, t)) if d <= k]
def test_text_symbols_outside_the_pattern() -> None:
rng = random.Random(8)
for _ in range(300):
p = "".join(rng.choice("ab") for _ in range(rng.randint(1, 7)))
t = "".join(rng.choice("abcd") for _ in range(rng.randint(0, 25)))
assert hamming_distances(p, t) == brute(p, t)
What the next level needs
- the tests did not run in this build