Karp-Rabin multiple-pattern matching in Python
Rabin-Karp fingerprinting over a set of equal-length patterns, looking up each window's hash in a table.
- File
karp_rabin_multiple_patterns.py, 79 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/karp-rabin-multiple-patterns/. The authors are listed in prov/HKXC4R.jsonl.
import random
from collections.abc import Sequence
MERSENNE_61 = (1 << 61) - 1
def codes(x: str | Sequence[int]) -> tuple[int, ...]:
"""Character codes from 1: ord(ch) + 1 for a str, value + 1 for a sequence of non-negative ints."""
if isinstance(x, str):
return tuple(ord(ch) + 1 for ch in x)
return tuple(v + 1 for v in x)
def poly_hash(x: Sequence[int], r: int, p: int) -> int:
"""Horner's rule: sum of x[i] * r^(len(x) - 1 - i) modulo p."""
v = 0
for c in x:
v = (v * r + c) % p
return v
def karp_rabin_set(
text: str | Sequence[int],
patterns: Sequence[str | Sequence[int]],
*,
p: int = MERSENNE_61,
base: int | None = None,
verify: bool = True,
stats: dict[str, int] | None = None,
) -> list[tuple[int, int]]:
"""Return all pairs (s, j) such that patterns[j] occurs in text at the 0-based position s.
Occurrences may overlap, a pattern listed twice is reported once for each index, and
an empty pattern occurs at every s in 0..len(text). The list is sorted. p must be a prime
larger than every character code. With verify=False, every hash match is reported (a
Monte Carlo answer that may contain false pairs). If stats is a dict, the numbers of
verifications and of failed verifications are stored in it.
"""
t = codes(text)
n = len(t)
if any(v >= p for v in t):
raise ValueError("every character code must be below p")
by_length: dict[int, dict[tuple[int, ...], list[int]]] = {}
for j, pat in enumerate(patterns):
q = codes(pat)
if any(v >= p for v in q):
raise ValueError("every character code must be below p")
by_length.setdefault(len(q), {}).setdefault(q, []).append(j)
checks = failed = 0
out: list[tuple[int, int]] = []
r = random.randrange(p) if base is None else base % p # @line L1
for ell, group in by_length.items(): # @line L2
table: dict[int, list[tuple[tuple[int, ...], list[int]]]] = {} # @line L3
for q, idx in group.items(): # @line L4
table.setdefault(poly_hash(q, r, p), []).append((q, idx))
if ell == 0: # @line L5
out.extend((s, j) for s in range(n + 1) for j in group[()])
continue
if ell > n: # @line L6
continue
big_r = pow(r, ell - 1, p) # @line L7
ht = poly_hash(t[:ell], r, p)
for s in range(n - ell + 1): # @line L8
for q, idx in table.get(ht, ()): # @line L9
checks += 1
if not verify or t[s : s + ell] == q: # @line L10
out.extend((s, j) for j in idx)
else:
failed += 1
if s < n - ell: # @line L11
ht = ((ht - t[s] * big_r) * r + t[s + ell]) % p
if stats is not None:
stats["checks"] = checks
stats["failed"] = failed
out.sort()
return out
test_karp_rabin_multiple_patterns.py 111 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/karp-rabin-multiple-patterns/. The authors are listed in prov/HKXC4R.jsonl.
import random
from karp_rabin_multiple_patterns import karp_rabin_set, poly_hash
def naive(t, pats) -> list[tuple[int, int]]:
out = []
for j, q in enumerate(pats):
for s in range(len(t) - len(q) + 1):
if t[s : s + len(q)] == q:
out.append((s, j))
return sorted(out)
def ints(x: str) -> list[int]:
return ["abcd".index(ch) for ch in x]
def test_example() -> None:
# a and b have the codes 1 and 2, as on the page; p = 13 and r = 5.
t = ints("abababbb")
pats = [ints(x) for x in ["aba", "abb", "aba", "baa"]]
assert [poly_hash([v + 1 for v in q], 5, 13) for q in pats] == [10, 11, 10, 4]
stats: dict[str, int] = {}
got = karp_rabin_set(t, pats, p=13, base=5, stats=stats)
assert got == [(0, 0), (0, 2), (2, 0), (2, 2), (4, 1)]
assert stats == {"checks": 4, "failed": 1}
mc = karp_rabin_set(t, pats, p=13, base=5, verify=False)
assert mc == sorted(got + [(5, 0), (5, 2)])
def test_small_cases() -> None:
assert karp_rabin_set("", []) == []
assert karp_rabin_set("", [""]) == [(0, 0)]
assert karp_rabin_set("ab", ["", "x", "abc"]) == [(0, 0), (1, 0), (2, 0)]
assert karp_rabin_set("aaaa", ["aa", "a", "aa"]) == sorted(
[(0, 0), (1, 0), (2, 0), (0, 2), (1, 2), (2, 2)] + [(s, 1) for s in range(4)]
)
assert karp_rabin_set("ushers", ["he", "she", "his", "hers"]) == [(1, 1), (2, 0), (2, 3)]
def test_bad_bases_stay_exact() -> None:
# r = 0 and r = 1 make many windows collide; the output is still exact.
t = "abbaabab" * 5
pats = ["ab", "ba", "aabb", "bbaa", "abab", "baba", ""]
for r in (0, 1, 2):
assert karp_rabin_set(t, pats, base=r) == naive(t, pats)
def random_text(rng: random.Random, sigma: int, n: int) -> str:
kind = rng.randrange(3)
if kind == 0:
return "".join(rng.choice("abcd"[:sigma]) for _ in range(n))
period = "".join(rng.choice("abcd"[:sigma]) for _ in range(rng.randint(1, 4)))
s = (period * (n // len(period) + 1))[:n]
if kind == 2 and n > 0:
i = rng.randrange(n)
s = s[:i] + rng.choice("abcd"[:sigma]) + s[i + 1 :]
return s
def random_patterns(rng: random.Random, t: str, sigma: int) -> list[str]:
pats = []
for _ in range(rng.randint(1, 6)):
kind = rng.randrange(5)
if kind == 0 and t:
i = rng.randrange(len(t))
pats.append(t[i : i + rng.randint(1, 5)])
elif kind == 1 and pats:
q = rng.choice(pats)
pats.append(q[: rng.randint(0, len(q))]) # a prefix of another pattern
elif kind == 2 and pats:
q = rng.choice(pats)
pats.append(q[rng.randint(0, len(q)) :]) # a suffix of another pattern
elif kind == 3 and pats:
pats.append(rng.choice(pats)) # a pattern listed twice
else:
pats.append(random_text(rng, sigma, rng.randint(0, 5)))
return pats
def test_random_against_naive() -> None:
rng = random.Random(20261106)
cases = 0
for sigma in (1, 2, 4):
for _ in range(400):
t = random_text(rng, sigma, rng.randint(0, 30))
pats = random_patterns(rng, t, sigma)
want = naive(t, pats)
assert karp_rabin_set(t, pats) == want
# A tiny prime forces many failed verifications; the answer stays exact.
# The codes are then 1 to 4, below every such prime.
p = rng.choice([5, 7, 11, 13])
ti, pi = ints(t), [ints(q) for q in pats]
assert karp_rabin_set(ti, pi, p=p) == want
# Without verification the answer contains every true pair.
mc = karp_rabin_set(ti, pi, p=p, verify=False)
assert set(want) <= set(mc)
cases += 1
assert cases >= 500
def test_int_sequences() -> None:
rng = random.Random(7)
for _ in range(200):
t = [rng.randrange(3) for _ in range(rng.randint(0, 25))]
pats = [[rng.randrange(3) for _ in range(rng.randint(0, 4))] for _ in range(rng.randint(1, 5))]
assert karp_rabin_set(t, pats) == naive(t, pats)
What the next level needs
- the tests did not run in this build
Read the full page about Karp-Rabin multiple-pattern matching