Lesson 21 / 25
Rabin–Karp
Pattern matching with a rolling hash, constant-cost window updates, collision verification, worst case, and its advantage in multi-pattern search.
Contents
Brute-force search compared characters one by one in every window. This lesson’s idea turns comparison into a completely different operation: reduce every window of the text to a number and compare numbers.
Comparing numbers is a single operation. The problem is that computing each window’s number takes — as it stands, there is no gain at all. The solution is to not compute the number from scratch.
Rolling Hash
When a window shifts one character to the right, if the new hash value can be obtained from the old one with a constant number of operations, the method is called a rolling hash.
The hash used treats characters as digits of a number base. For base and prime modulus , the value of a window of length is:
When the window shifts right, the contribution of the leftmost character is subtracted, the remainder is shifted one digit, and the new character is added:
Three operations, independent of length: the update is .
Implementation
def rabin_karp(text: str, pattern: str, base: int = 256, modulus: int = 1_000_003) -> tuple[list[int], int, int]: """(positions, hash-match count, characters verified)""" n, m = len(text), len(pattern) if m > n: return [], 0, 0 power = pow(base, m - 1, modulus) # b^(m-1) mod q hash_pattern = hash_text = 0 for i in range(m): # first window and the pattern hash_pattern = (hash_pattern * base + ord(pattern[i])) % modulus hash_text = (hash_text * base + ord(text[i])) % modulus positions: list[int] = [] matches, verifications = 0, 0 for shift in range(n - m + 1): if hash_text == hash_pattern: # candidate: hashes equal matches += 1 verifications += m if text[shift:shift + m] == pattern: positions.append(shift) if shift < n - m: # roll the window hash_text = (hash_text - ord(text[shift]) * power) % modulus hash_text = (hash_text * base + ord(text[shift + m])) % modulus return positions, matches, verifications text = "ABABDABACDABABCABAB" pattern = "ABABCABAB" print(rabin_karp(text, pattern)) # ([10], 1, 9) print(rabin_karp("ABABAB", "AB")) # ([0, 2, 4], 3, 6)
Of the text’s eleven windows, only one passes the hash test, and nine characters are read for verification. The difference is clear against the 29 comparisons brute force made on the same input.
Because Python’s remainder operation produces a non-negative result on negative numbers, intermediate values need no correction; in languages that use fixed-width integers, a modulus correction is added after the subtraction. The signed-arithmetic difference from the How Computers Work course can turn into a concrete bug here.
Collision and Verification
Hash equality does not prove that two sequences are equal. Different sequences can fall on the same value — the same collision concept from the Data Structures course.
For this reason, every hash match is verified by character comparison. Without verification, the algorithm produces a wrong result.
# If the modulus is deliberately chosen small, collisions increase print(rabin_karp("ABABDABACDABABCABAB", "ABABCABAB", modulus=7)) # ([10], 4, 36)
With a small modulus, four windows pass the hash test, but only one is a real match; the other three are eliminated during verification. The result is still correct — the cost is the wasted character comparisons.
This is the general pattern of hash-based methods: the hash filters, verification decides.
Cost
Expected cost is : each window takes constant work, and the collision probability is negligible once the modulus is chosen large enough.
The worst case is and arises in two ways. The first is that there genuinely are
a large number of matches (searching for "AA" inside "AAAA…") — this is unavoidable,
because the output itself is large. The second is text constructed to produce
collisions against the chosen hash function; choosing the modulus at random turns this
attack into a mere probability.
| Criterion | Brute force | Rabin–Karp |
|---|---|---|
| Preprocessing | None | |
| Expected search | worst case | |
| Worst-case search | ||
| Extra space |
Choosing the Base and the Modulus
Two parameters determine the method’s behavior.
The base should not be smaller than the alphabet size; if it is, distinct character sequences regularly fall on the same value. For byte sequences, 256 works; for small alphabets, a value equal to the alphabet size is enough.
The modulus is chosen prime. A non-prime modulus that shares factors with the base piles hash values onto particular residues and increases collisions.
The size of the modulus is a question of overflow: the intermediate product hash * base must not exceed the language’s integer width. In sixty-four-bit arithmetic,
keeping the modulus under roughly guarantees this. Python’s unbounded integers
remove this constraint, at the cost of higher operation cost for large values.
The collision probability for a random modulus is approximately per window. If a stronger guarantee is needed, two independent hashes are used together; the probability of both colliding shrinks to the product of the two, and verification does almost no wasted work.
The Real Advantage: Multiple Patterns
The method’s clearest gain shows up not in single-pattern search but in searching for many patterns of the same length together. The patterns’ hash values are kept in a set; the text is scanned in a single pass, with one set lookup per window.
def multi_rabin_karp(text: str, patterns: list[str], base: int = 256, modulus: int = 1_000_003) -> dict[str, list[int]]: m = len(patterns[0]) if any(len(p) != m for p in patterns): raise ValueError("all patterns must have the same length") targets: dict[int, list[str]] = {} for p in patterns: h = 0 for character in p: h = (h * base + ord(character)) % modulus targets.setdefault(h, []).append(p) power = pow(base, m - 1, modulus) h = 0 for i in range(m): h = (h * base + ord(text[i])) % modulus result: dict[str, list[int]] = {p: [] for p in patterns} for shift in range(len(text) - m + 1): for candidate in targets.get(h, []): if text[shift:shift + m] == candidate: result[candidate].append(shift) if shift < len(text) - m: h = (h - ord(text[shift]) * power) % modulus h = (h * base + ord(text[shift + m])) % modulus return result print(multi_rabin_karp("ABABDABACDABABCABAB", ["ABAB", "BACD", "CABA"])) # {'ABAB': [0, 10, 15], 'BACD': [6], 'CABA': [14]}
Three patterns are found in a single pass over the text. For patterns, the cost is ; searching for each pattern separately with brute force would be .
The same idea extends to two dimensions: rolling row hashes make it possible to search for a rectangular pattern inside an image. The rolling hash is also used in areas such as content-defined chunking — the same constant-cost window update, put to a different purpose.
Summary
- Rabin–Karp reduces windows to a hash value and turns comparison into number equality.
- The rolling hash updates its value with a constant number of operations as the window shifts.
- Hash equality is not proof; every candidate is verified by character comparison.
- Expected cost is , worst case ; a random modulus protects against constructed inputs.
- The real advantage is searching for many patterns of the same length in a single pass: .
Next Step
Rabin–Karp compressed the pattern’s content into a number but never used its
structure. Yet the overlap between the prefix and suffix of the pattern ABABCABAB
tells us, at the moment of a mismatch, how many positions can safely be skipped. The
next lesson computes this structure as a prefix function and builds a search that never
backtracks in the text.
To keep your progress and take notes, Log in
My notes
Log in to take notes.