1. The Z-Algorithm
The Z-algorithm builds a Z-array for a string s, where
z[i] is the length of the longest substring starting at i
that matches a prefix of s (by convention, z[0] is left
undefined or set to 0). To search for a pattern p in text t,
build the Z-array of p + '#' + t (a separator character that appears in
neither string) — every index in the t portion where z[i]
equals p's length marks a match.
#include <string>
#include <vector>
using namespace std;
// O(n) time -- each index is visited a bounded number of times thanks to the [l, r] window
vector<int> buildZArray(const string& s) {
int n = s.size();
vector<int> z(n, 0);
int l = 0, r = 0; // [l, r] is the rightmost Z-box found so far
for (int i = 1; i < n; i++) {
if (i < r) {
// i is inside a known matching window -- reuse a previously computed value
z[i] = min(r - i, z[i - l]);
}
// extend the match past what the window already guaranteed
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++;
}
if (i + z[i] > r) { l = i; r = i + z[i]; } // grow the window if we extended past it
}
return z;
}
vector<int> findOccurrences(const string& pattern, const string& text) {
string combined = pattern + "#" + text;
vector<int> z = buildZArray(combined);
vector<int> matches;
int patternLen = pattern.size();
for (int i = patternLen + 1; i < (int)combined.size(); i++) {
if (z[i] == patternLen) {
matches.push_back(i - patternLen - 1); // convert back to an index into text
}
}
return matches;
}
The [l, r] window is what makes this O(n) rather than the
naive O(n²): whenever index i falls inside an
already-known matching window, z[i - l] (the Z-value for the
corresponding position near the start of the string) already tells you a safe lower
bound for z[i] without rechecking characters one by one — the
while loop then only needs to verify characters past what the
window already guarantees. Every character gets compared inside that
while loop at most a constant number of extra times across the whole
run, which is the same amortized argument that keeps KMP's failure-function
construction linear.
Both give O(n) pattern matching, but the Z-array is often easier to reason about because z[i] has a direct, plain-English meaning ("match length against the prefix, starting here"), while KMP's failure function is defined more indirectly. When a problem needs the Z-array's values for something beyond matching — like the "count distinct substrings" or "shortest repeating unit" family of problems — reach for it over KMP.
2. Manacher's Algorithm: Longest Palindromic Substring in O(n)
Week 4's expand-around-center approach checks every one of the 2n - 1
possible centers (each character, plus each gap between two characters, for
even-length palindromes) and expands outward, costing O(n) per center
and O(n²) overall. Manacher's algorithm reuses previously computed
palindrome radii the same way the Z-algorithm reuses its window, bringing the total
down to O(n).
The trick that avoids handling even- and odd-length palindromes as separate cases is
to transform the string first, inserting a separator between every character
(and at both ends) so every palindrome in the transformed string is odd-length:
"aba" becomes "#a#b#a#", and "abba" becomes
"#a#b#b#a#".
#include <string>
#include <vector>
using namespace std;
// O(n) time -- same amortized "reuse the window" argument as the Z-algorithm
string longestPalindrome(const string& s) {
if (s.empty()) return "";
// Transform: insert '#' between characters and at both ends
string t = "#";
for (char c : s) { t += c; t += '#'; }
int n = t.size();
vector<int> radius(n, 0);
int center = 0, right = 0; // rightmost palindrome boundary found so far
for (int i = 0; i < n; i++) {
if (i < right) {
int mirror = 2 * center - i; // i's mirror across `center`
radius[i] = min(right - i, radius[mirror]);
}
// expand past what the mirror already guaranteed
while (i - radius[i] - 1 >= 0 && i + radius[i] + 1 < n &&
t[i - radius[i] - 1] == t[i + radius[i] + 1]) {
radius[i]++;
}
if (i + radius[i] > right) { center = i; right = i + radius[i]; }
}
int bestCenter = 0, bestRadius = 0;
for (int i = 0; i < n; i++) {
if (radius[i] > bestRadius) { bestRadius = radius[i]; bestCenter = i; }
}
// Map back to the original string: the palindrome spans [start, start + bestRadius)
int start = (bestCenter - bestRadius) / 2;
return s.substr(start, bestRadius);
}
radius[i] in the transformed string, divided by two, is exactly the
length of the palindrome centered at that position in the original string — which is
why inserting separators isn't just a convenience, it's what makes every center in
the transformed string correspond cleanly to either a single character or a gap in
the original.
Manacher's [center, right] window and the Z-algorithm's [l, r] window are the same idea applied to different questions — "how far does a match/palindrome extend from here" reusable via a mirror or an offset. Recognizing that shared skeleton means you only have to internalize the amortized-window argument once.
3. Rabin-Karp: Multi-Pattern Search with Rolling Hash
Week 4 used a rolling hash to check a single pattern against a text. Rabin-Karp is
the same rolling-hash idea applied to searching for many patterns at
once: hash every pattern into a set first, then slide a same-length window across the
text, updating its hash in O(1) per step, and check the set on every
step instead of re-scanning every pattern individually.
#include <string>
#include <vector>
#include <unordered_map>
using namespace std;
const long long MOD = 1'000'000'007;
const long long BASE = 131;
long long hashOf(const string& s) {
long long h = 0;
for (char c : s) h = (h * BASE + c) % MOD;
return h;
}
long long power(long long base, int exp, long long mod) {
long long result = 1;
base %= mod;
while (exp > 0) {
if (exp & 1) result = result * base % mod;
base = base * base % mod;
exp >>= 1;
}
return result;
}
// Find every text index where any pattern of length `patternLen` starts a match.
// O(text.size() * patternLen) worst case for verification, O(text.size()) expected
// with a good hash and few collisions.
vector<int> searchAll(const vector<string>& patterns, const string& text, int patternLen) {
unordered_map<long long, vector<string>> byHash;
for (const string& p : patterns) byHash[hashOf(p)].push_back(p);
vector<int> matches;
if ((int)text.size() < patternLen) return matches;
long long highPower = power(BASE, patternLen - 1, MOD);
long long windowHash = hashOf(text.substr(0, patternLen));
for (int i = 0; ; i++) {
auto it = byHash.find(windowHash);
if (it != byHash.end()) {
// Hash collisions are possible -- verify with a real string comparison
for (const string& p : it->second) {
if (text.compare(i, patternLen, p) == 0) matches.push_back(i);
}
}
if (i + patternLen >= (int)text.size()) break;
// Roll the hash forward by one character in O(1)
windowHash = (windowHash - text[i] * highPower % MOD + MOD) % MOD;
windowHash = (windowHash * BASE + text[i + patternLen]) % MOD;
}
return matches;
}
Rolling the hash forward — subtracting the outgoing character's contribution and
adding the incoming one — is what keeps each step O(1) instead of
recomputing the whole window's hash from scratch. The verification step,
text.compare(i, patternLen, p), is non-negotiable: a hash match is
strong evidence of a real match but not proof, since two different strings can
collide to the same hash, and skipping verification turns rare hash collisions into
silent incorrect answers.
For a single pattern, KMP or the Z-algorithm are simpler and have a guaranteed worst-case bound with no collision risk. Rabin-Karp earns its place specifically when you're checking many patterns of the same length against one text — hashing every pattern once up front and then sliding a single window is the move that KMP and the Z-algorithm don't offer directly.
4. Comparing the Pattern-Matching Family
By now you have five tools that all answer some version of "find X in a string" — naive matching and KMP from Week 4, plus this week's Z-algorithm, Manacher's, and Rabin-Karp. Under interview time pressure, matching the tool to the exact question asked matters more than memorizing all five implementations equally well.
- Single pattern, single text: KMP or the Z-algorithm — both O(n + m), pick whichever you can implement correctly fastest.
- Many patterns, one text, same length: Rabin-Karp — hash every pattern once, then one O(n) sliding pass.
- Longest palindromic substring: Manacher's for the guaranteed O(n) answer; expand-around-center from Week 4 is a reasonable fallback if you can't recall Manacher's under pressure, since O(n²) still passes on modest input sizes.
- Many patterns, varying lengths: a trie (Week 11) is usually the better structural fit than repeated Rabin-Karp calls.
If Manacher's index arithmetic isn't fresh in your memory during an interview, saying "I'd use Manacher's for a guaranteed O(n), but here's the O(n²) expand-around-center version working correctly" is a perfectly strong answer — a correct O(n²) solution you can actually finish beats an O(n) one you get halfway through and can't recover.
5. Hands-on Exercise
Build a string-matching benchmark suite
Implement all three algorithms and confirm they agree with each other and with brute force.
Requirements:
- Implement
buildZArrayandfindOccurrences; confirm the matches agree exactly with a brute-forceO(nm)substring search on at least five test cases. - Implement
longestPalindromeand confirm it agrees with Week 4's expand-around-center implementation on at least five strings, including one with no repeated characters and one that's a palindrome in its entirety. - Implement
searchAlland test it with at least three same-length patterns against one text, confirming every true occurrence is found and no false one is reported. - Deliberately construct two different same-length strings with the same hash under a small modulus (e.g.
MOD = 101) to force a collision, and confirm your verification step correctly rejects the false match. - Time all three algorithms against naive baselines on a text of at least 100,000 characters and record the speedup in a comment.
For requirement 4, a small modulus like 101 makes collisions common enough to find quickly just by hashing a handful of same-length random strings and checking for duplicates — you don't need to construct a collision by hand.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does z[i] mean in the Z-array, and how does that definition directly give you a pattern-matching algorithm?
What does z[i] mean in the Z-array, and how does that definition directly give you a pattern-matching algorithm?
z[i] is the length of the longest substring starting at index i that matches a prefix of the string. If you build the combined string pattern + '#' + text, then any index in the text portion where z[i] equals the pattern's length means that position matches the pattern's full prefix — in other words, the pattern occurs starting there.
Q2
Why does Manacher's algorithm insert a separator character between every character of the input before running?
Why does Manacher's algorithm insert a separator character between every character of the input before running?
Without the separator, palindromes come in two shapes — odd-length (a single character center) and even-length (a center between two characters) — which would need two separate cases to handle. Inserting a separator between every character and at both ends makes every palindrome in the transformed string odd-length with a single-character center, so one unified algorithm handles both original shapes at once.
Q3
In Rabin-Karp, why is the string-comparison verification step after a hash match non-negotiable rather than an optional safety check?
In Rabin-Karp, why is the string-comparison verification step after a hash match non-negotiable rather than an optional safety check?
A hash function maps a much larger space of possible strings down to a fixed-size number, so by the pigeonhole principle two different strings can produce the same hash — a collision. Skipping verification means those collisions get silently reported as real matches, producing incorrect results; the O(patternLen) comparison after a hash hit is what guarantees the reported matches are actually correct rather than merely hash-plausible.
Q4
Given a problem that needs to search for 50 different fixed-length patterns against one large text, why is Rabin-Karp a better fit than running KMP 50 separate times?
Given a problem that needs to search for 50 different fixed-length patterns against one large text, why is Rabin-Karp a better fit than running KMP 50 separate times?
Running KMP 50 times means scanning the entire text 50 separate times, once per pattern. Rabin-Karp instead hashes all 50 patterns once into a lookup set, then makes a single O(1)-per-step sliding pass over the text, checking each window's hash against the whole set at once — turning 50 full text scans into one, at the cost of needing a verification step to rule out hash collisions.