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