Algorithm Search⌘K

Bitap algorithm in Python

Bit-parallel shift-and extension that tracks up to k errors per pattern prefix.

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

"""Bitap (shift-add) search for a pattern with at most k mismatches.

Edit model: Hamming distance (substitutions only, no insertions, deletions
or transpositions). Indices are 0-based. The result is the sorted list of
start positions i in 0..len(text)-len(pattern) such that the window
text[i:i+m] differs from the pattern in at most k positions. An empty
pattern matches at every i in 0..len(text).

Python integers have no fixed width, so the state vector is one integer of
m * B bits, where B = ceil(log2(k + 1)) + 1.
"""


def field_width(k: int) -> int:
    """Return B, the least width with 2**(B-1) >= k + 1."""
    return (k).bit_length() + 1


def bitap_mismatches(pattern: str, text: str, k: int) -> list[int]:
    """Return the start positions of the windows with at most k mismatches.

    Precondition: k >= 0. Cost: O(len(text) + len(pattern) * sigma) word
    operations on vectors of len(pattern) * B bits, where sigma is the number
    of distinct characters of the pattern.
    """
    if k < 0:
        raise ValueError("k must be at least 0")
    m, n = len(pattern), len(text)
    if m == 0:
        return list(range(n + 1))
    b = field_width(k)  # @line L1
    full = (1 << (m * b)) - 1
    high = 0
    for i in range(m):
        high |= 1 << (i * b + b - 1)
    ones = 0
    for i in range(m):
        ones |= 1 << (i * b)
    table: dict[str, int] = {}  # @line L2
    for i, c in enumerate(pattern):
        table.setdefault(c, ones)
    for c in table:
        for i in range(m):
            if pattern[i] == c:
                table[c] &= ~(1 << (i * b))
    shift_last = (m - 1) * b
    field_mask = (1 << b) - 1
    state = 0  # @line L3
    over = 0
    starts: list[int] = []
    for j in range(n):  # @line L4
        state = ((state << b) + table.get(text[j], ones)) & full  # @line L5
        over = ((over << b) | (state & high)) & full  # @line L6
        state &= ~high  # @line L7
        if j >= m - 1 and ((state | over) >> shift_last) & field_mask <= k:  # @line L8
            starts.append(j - m + 1)
    return starts


def mismatch_fields(pattern: str, text: str, k: int) -> list[list[int | None]]:
    """Return, after each text character, the decoded field of each prefix.

    Entry [j][i] is the mismatch count of pattern[0..i] against text[j-i..j],
    or None when the overflow bit says the count is at least 2**(B-1). Only
    fields with i <= j are meaningful; the others are None. For examples.
    """
    m = len(pattern)
    b = field_width(k)
    full = (1 << (m * b)) - 1
    high = sum(1 << (i * b + b - 1) for i in range(m))
    ones = sum(1 << (i * b) for i in range(m))
    state = over = 0
    rows: list[list[int | None]] = []
    for j, t in enumerate(text):
        vec = ones
        for i in range(m):
            if pattern[i] == t:
                vec &= ~(1 << (i * b))
        state = ((state << b) + vec) & full
        over = ((over << b) | (state & high)) & full
        state &= ~high
        row: list[int | None] = []
        for i in range(m):
            if i > j or (over >> (i * b)) & ((1 << b) - 1):
                row.append(None)
            else:
                row.append((state >> (i * b)) & ((1 << b) - 1))
        rows.append(row)
    return rows
test_bitap_algorithm.py 60 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bitap-algorithm/. The authors are listed in prov/F82Z8Y.jsonl.

import random

import pytest
from bitap_algorithm import bitap_mismatches, field_width, mismatch_fields


def brute(pattern: str, text: str, k: int) -> list[int]:
    m = len(pattern)
    return [
        i for i in range(len(text) - m + 1)
        if sum(a != b for a, b in zip(pattern, text[i:i + m])) <= k
    ]


def test_examples() -> None:
    assert bitap_mismatches("abc", "abxabcbc", 1) == [0, 3, 5]
    assert bitap_mismatches("abc", "abxabcbc", 0) == [3]
    assert bitap_mismatches("ACGTA", "ACGAAACGTTACCTA", 1) == [0, 5, 10]
    rows = mismatch_fields("abc", "abxabcbc", 1)
    assert [r[2] for r in rows[2:]] == [1, None, None, 0, None, 1]


def test_field_width() -> None:
    assert [field_width(k) for k in range(8)] == [1, 2, 3, 3, 4, 4, 4, 4]
    for k in range(100):
        b = field_width(k)
        assert 2 ** (b - 1) >= k + 1 > 2 ** (b - 2) or b == 1


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


def test_random_against_brute_force() -> None:
    rng = random.Random(1992)
    for alphabet in ("ab", "acgt"):
        for _ in range(1500):
            p = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 9)))
            t = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 25)))
            k = rng.randint(0, 10)
            assert bitap_mismatches(p, t, k) == brute(p, t, k), (p, t, k)


def test_text_characters_outside_the_pattern() -> None:
    rng = random.Random(7)
    for _ in range(500):
        p = "".join(rng.choice("ab") for _ in range(rng.randint(1, 6)))
        t = "".join(rng.choice("abxy") for _ in range(rng.randint(0, 20)))
        k = rng.randint(0, 3)
        assert bitap_mismatches(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 Bitap algorithm