Algorithm Search⌘K

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
fischer_paterson_algorithm.pyRawGitHub
# 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

Read the full page about Fischer-Paterson algorithm