Sequences & Strings/String Matching: Naive & Rabin–Karp

Lesson 5.52,273 words

String Matching: Naive & Rabin–Karp

Given a text TT of length nn and a pattern PP of length mm, find every occurrence of PP in TT. The naive scan costs O(nm)O(nm) and re-reads text it has already seen.

╌╌╌╌

We have spent the previous lessons treating a sequence as a bag of comparable keys. A string is more rigid: its characters come in a fixed order and that order is the information. The fundamental question is string matching: given a text and a pattern over some alphabet , report every shift such that occurs in starting at position , i.e. . There are candidate shifts, and the art is to test them without paying comparisons apiece.

Correctness of a matcher has the two standard halves of any search procedure: it must be sound — every reported shift is a genuine occurrence — and complete — every occurrence is reported. The naive scan is trivially both (it returns a shift only after verifying all characters, and it tries every shift); the interest in what follows is keeping both guarantees while doing far less work.

The naive answer does pay that price, and there are two distinct redundancies to attack. The first is the cost per alignment: naive spends up to comparisons to test one shift. The second is re-reading: after a partial match fails, naive slides the pattern by one and re-examines text characters it already knows. This lesson removes the first with Rabin–Karp, which replaces a length- comparison by a length- hash update. The companion lesson removes the second with KMP and the Z-function, which precompute the pattern's self-overlap so no text character is ever re-derived.

The naive scan, and why it wastes work

Try every alignment; at each, compare characters until one disagrees or the whole pattern matches.

Algorithm:Naive-Match(T,P)\textsc{Naive-Match}(T, P) — test all nm+1n-m+1 alignments
  1. 1
    for s0s \gets 0 to nmn - m do
  2. 2
    j0j \gets 0
  3. 3
    while j<mj < m and T[s+j]=P[j]T[s + j] = P[j] do
  4. 4
    jj+1j \gets j + 1
  5. 5
    if j=mj = m then
  6. 6
    report occurrence at shift ss

On random or low-repetition text the inner loop almost always dies on the first character, so naive matching averages and is the right tool when is tiny or the text is unstructured.1 The pathology is repetitive patterns; the fix is to stop discarding what a partial match revealed. The asymptotic gap between the worst and average cases is what Rabin–Karp — and the KMP and Z-function of the companion lesson — close.

naive_match.pypython
def naive_match(text: str, pattern: str) -> list[int]:
  """
    Every shift `s` where `pattern` occurs in `text`, in increasing order.\n
    An empty pattern matches at every position `0 .. len(text)`.\n
  """
  text_length: int = len(text)
  pattern_length: int = len(pattern)
  shifts: list[int] = []

  # only shifts that leave room for the whole pattern can match.
  for shift in range(text_length - pattern_length + 1):
    matched: int = 0
    while matched < pattern_length and text[shift + matched] == pattern[matched]:
      matched += 1
    if matched == pattern_length:
      shifts.append(shift)
  return shifts
Why the naive scan is : on , every shift re-reads the same as, matching of them before failing on the final character (red). Each row is one alignment

Rabin–Karp: matching by rolling hash

Rabin–Karp turns are these characters equal? into are these two numbers equal? by hashing each window. Interpret each length- block of text as an -digit number in base (mapping characters to digits), reduced modulo a prime to keep it machine-word-sized. Precompute the pattern's hash and the first window's hash . Slide the window one step at a time; if , the block might match, so verify it character-by-character to rule out a hash collision (a spurious hit).

This verification is what makes the matcher sound. The hash test alone is complete — equal blocks always hash equal, so a true occurrence never escapes the filter — but it is not sound on its own: a collision ( on differing blocks) would report a phantom match. The character-by-character recheck discharges that, so soundness lives in the verification and completeness lives in the hash filter; reporting a hash match without rechecking would be an unsound algorithm.

The key step is the rolling hash: when the window slides from position to , we do not recompute the hash from scratch. We drop the contribution of the departing high-order digit , shift the remaining digits up by one place (multiply by ), and add the incoming low-order digit :

The factor is precomputed once. Each slide is arithmetic, so building all window hashes costs in total.

The rolling hash slides the length- window by one: drop the departing high digit , multiply the rest by , add the incoming low digit — one update giving
Algorithm:Rabin-Karp(T,P,b,q)\textsc{Rabin-Karp}(T, P, b, q) — hash, slide, verify
  1. 1
    p0; t0; ρbm1modqp \gets 0;\ t \gets 0;\ \rho \gets b^{\,m-1} \bmod q
  2. 2
    for j0j \gets 0 to m1m - 1 do
    hash PP and window 0
  3. 3
    p(bp+P[j])modqp \gets (b \cdot p + P[j]) \bmod q
  4. 4
    t(bt+T[j])modqt \gets (b \cdot t + T[j]) \bmod q
  5. 5
    for s0s \gets 0 to nmn - m do
  6. 6
    if t=pt = p then
    verify, kill collisions
  7. 7
    if T[s..s+m1]=PT[s \mathinner{\ldotp\ldotp} s+m-1] = P then report occurrence at shift ss
  8. 8
    if s<nms < n - m then
    roll window forward
  9. 9
    t(b(tT[s]ρ)+T[s+m])modqt \gets (b\,(t - T[s]\cdot\rho) + T[s+m]) \bmod q

The arithmetic, worked

Decimal strings make the digits literal. Take , prime , pattern (so ), and text (so ).2 Two precomputations:

The first window is , and , so . Now roll, one digit out and one digit in each time:

  • (window ): drop the leading , whose place value mod is ; append the new low digit . Check directly: , as claimed.
  • (window ): drop the , append .
  • (window ): drop the , append . because . The intermediate value went negative — the subtraction removed more than the running hash held — and the final mod folds it back into . Implementations add a multiple of before reducing (or use a language whose mod is already non-negative); forgetting this is the classic Rabin–Karp bug.
  • (window ): drop the , append . since exactly.

None of equals , so these four shifts are dismissed with no character comparisons at all. Continuing the scan, the window at is with hash : the filter fires, verification compares all five characters, and a genuine occurrence is reported. But the window at is , and — hash again. The filter fires on a block that is not the pattern, verification compares and rejects, and the scan moves on. That is a spurious hit: two different 5-digit numbers that happen to agree mod . A matcher that skipped verification would have reported a phantom occurrence at shift .

One roll step with the numbers of the worked example (, , ). The window slides from () to (): subtract the departing digit times , multiply by , add the arriving digit, reduce mod

The estimate can be made precise. A spurious hit at shift means divides the nonzero difference , where reads a block as a base- number. Since , the number has fewer than distinct prime factors (each prime factor is at least , so factors force ). If is drawn uniformly from the primes below some bound — and there are roughly of them — the chance that happens to divide is at most

and summing over all shifts, choosing drives the expected total number of spurious hits below : with probability tending to , no shift collides spuriously and the whole run is . The randomness lives in the choice of , not in the input; an adversary who sees before choosing can still manufacture collisions at every shift, which is why a fresh random prime per run (or per process) is the correct way to deploy the algorithm. With a fixed , the heuristic treats the hash values as uniform — accurate for typical data, void as a worst-case guarantee.

Rabin–Karp is most useful when matching many patterns of the same length at once (hash them all, look each window up in a set) or when the comparison is naturally numeric. The rolling-hash idea reappears in deduplication, plagiarism detection, and content-defined chunking.3

rabin_karp.pypython
def rabin_karp(
  text: str,
  pattern: str,
  base: int = 256,
  prime: int = 1_000_000_007,
) -> list[int]:
  """
    Every shift `s` where `pattern` occurs in `text`, in increasing order.\n
    `base` is the alphabet radix and `prime` the modulus; the verification\n
    step keeps the result correct regardless of hash collisions.\n
  """
  text_length: int = len(text)
  pattern_length: int = len(pattern)

  # an empty pattern matches at every position; a too-long one never does.
  if pattern_length == 0:
    return list(range(text_length + 1))
  if pattern_length > text_length:
    return []

  # high-order place value of the window, b^(m-1) mod prime.
  high_place: int = pow(base, pattern_length - 1, prime)
  pattern_hash: int = 0
  window_hash: int = 0
  for index in range(pattern_length):
    pattern_hash = (base * pattern_hash + ord(pattern[index])) % prime
    window_hash = (base * window_hash + ord(text[index])) % prime

  shifts: list[int] = []
  for shift in range(text_length - pattern_length + 1):

    # equal hashes are only a candidate; verify to kill spurious hits.
    if window_hash == pattern_hash and text[shift:shift + pattern_length] == pattern:
      shifts.append(shift)

    # roll the window one step forward, unless we just hit the last shift.
    if shift < text_length - pattern_length:
      departing: int = ord(text[shift]) * high_place
      incoming: int = ord(text[shift + pattern_length])
      window_hash = (base * (window_hash - departing) + incoming) % prime
  return shifts

When Rabin–Karp is the right tool

Rabin–Karp's guarantee is expected-time, not worst-case, and its soundness depends on the verify step. In exchange, it handles two settings the worst-case matchers of the companion lesson cannot handle cheaply.

  • Many patterns at once. Hash all same-length patterns into a set; each window costs one hash update plus one set lookup, so searching for all patterns together is expected — where a per-pattern rerun of a single-pattern matcher would pay . This is the natural tool for does the text contain any of these forbidden words of length ?
  • Numeric or higher-dimensional comparison. When the characters are already numbers, or the objects are 2-D blocks, the fingerprint idea extends directly: a 2-D rolling hash matches an pattern in an grid, and the same fingerprint answers find any repeated length- block — the core of duplicate detection.

Reject Rabin–Karp when you need a hard worst-case bound with no randomness, or when a single fixed could be attacked: an adversary who sees before choosing can force a collision at every shift, degrading the run to . A fresh random prime per run avoids this.

Fingerprints, deduplication, and anti-hash attacks

The rolling hash is one instance of a fingerprint: a short, cheaply updated summary of a long object such that equal objects always share a fingerprint and unequal ones rarely do. Karp and Rabin's original paper (Karp & Rabin, Efficient Randomized Pattern-Matching Algorithms, IBM J. Res. Dev., 1987) framed it exactly this way, and the same idea now runs far beyond substring search. Content-defined chunking — used by the rsync protocol (Tridgell & Mackerras, 1996) and by modern deduplicating backup and version-control systems — slides a rolling hash across a file and cuts a new chunk boundary whenever the hash hits a distinguished value, so that inserting a byte near the front shifts only one chunk instead of realigning the entire file. Rabin fingerprinting over polynomials (Rabin, Fingerprinting by Random Polynomials, 1981) is the same construction with xor-based arithmetic, chosen for provably low collision probability, and underlies network deduplication and similarity detection.

The polynomial hash also connects to a subtle practical failure. A single fixed modulus is vulnerable to anti-hash tests: adversarial inputs built to collide, which is why competitive-programming folklore uses a random base and a 64-bit modulus, or two independent hashes combined, to make a collision astronomically unlikely without needing per-run randomness. The general lesson — that a hash's worst case is only as good as the attacker's ignorance of its parameters — is the same one that pushed hash tables toward universal hashing, and it is why security-sensitive code uses cryptographic hashes rather than the fast polynomial ones.

Takeaways

  • String matching seeks all shifts where pattern ( chars) occurs in text ( chars). The naive scan tries all alignments in worst case, but is fine for tiny patterns or unstructured text — and it is what production strstr/memmem typically use, in a hardware-tuned form.
  • Rabin–Karp compares a rolling hash of each length- window, updated in via , then verifies on hash matches to kill collisions: expected , worst case .
  • A hash match that is not the pattern is a spurious hit; the character recheck is what keeps the matcher sound, and choosing a random prime makes spurious hits rare in expectation over the randomness of , not the input.
  • Rabin–Karp is the matcher of choice for many patterns at once and for numeric or 2-D fingerprinting; its guarantee is expected-time only.

This continues in String Matching: KMP & the Z-Function, which removes the re-reading entirely and delivers a worst-case bound with no randomness.

Footnotes

  1. CLRS, Ch. 32 — String Matching (§32.1): the naive matcher and its bound.
  2. CLRS, Ch. 32 — String Matching (§32.2): the Rabin–Karp algorithm; the text is CLRS's own worked example, spurious hit included.
  3. Skiena, § — String Algorithms: the Rabin–Karp rolling hash and its use for substring search and fingerprinting.
Practice

╌╌ END ╌╌