Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Advanced/A8
42 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣA8 · Part 3 · Advanced

String algorithms

Preprocess the pattern once, and you never have to look backward in the text again.

33 min read2 diagrams18 sections13 exercises
42 of 48

In this chapter

  • The insight: a mismatch still tells you something
  • The prefix function: what KMP actually precomputes
  • Building the failure table, step by step
  • KMP search: the text pointer never moves backward
  • Rabin-Karp: compare hashes, not characters

and 13 more

about 33 min
18On this page
  1. The insight: a mismatch still tells you something
  2. The prefix function: what KMP actually precomputes
  3. Building the failure table, step by step
  4. KMP search: the text pointer never moves backward
  5. Rabin-Karp: compare hashes, not characters
  6. Prefix hashes: compare any two substrings in O(1)
  7. Longest duplicate substring: binary search on the length
  8. Double hashing, and the hash that anyone can break
  9. The Z-function: the same information, easier to reason about
  10. Sum of scores: reading the whole Z array
  11. Manacher's algorithm: every palindrome, in O(n)
  12. Choosing between them
  13. The failure table answers more than "where is the pattern"
  14. Why the prefix function finds the longest palindromic prefix
  15. Minimal rotation: the smallest cyclic shift
  16. Aho-Corasick: many patterns, one pass over the text
  17. Suffix arrays: every suffix, in sorted order
  18. Recognizing it in an unseen problem

The insight: a mismatch still tells you something

The naive substring search tries every alignment and, on a mismatch, throws away everything it just learned — it slides the pattern one step right and re-compares from character zero. That's O(n·m). But a mismatch after k matched characters is not zero information: you now know exactly what the last k characters of the text were, because they were the first k characters of the pattern. Every fast string algorithm in this chapter is a different way of cashing in that information.

text a b a b a b c a b a b a c a b a b a c 5 characters match mismatch at p[5] naive: shift 1, recompare all 6 KMP: fail[4] = 3 keep the matched "aba", shift 2, resume at p[3]
The three already-matched characters in the shifted row were never re-read from the text — that reuse is the entire speedup.

The prefix function: what KMP actually precomputes

For a pattern p, define fail[i] as the length of the longest proper prefix of p[0..i] that is also a suffix of p[0..i]. "Proper" means it can't be the whole thing — otherwise the answer would trivially always be i + 1. This single array is all KMP needs: when a mismatch happens after len matched characters, fail[len - 1] tells you the largest number of characters you may keep without re-reading the text.

0 1 2 3 4 5 6 a b a b a c a 0 0 1 2 3 0 1 len falls 3 → 1 → 0 before 'c' settles fail[i] = longest proper prefix of p[0..i] that is also a suffix of p[0..i] p[0..4] = "ababa" prefix "aba" = suffix "aba" so fail[4] = 3
Notice index 5: a single character can force several fallbacks in a row, yet the build stays linear because each fallback is paid for by an earlier increase of len.

Building the failure table, step by step

The build is the same algorithm as the search, run with the pattern matched against itself. len holds "how many characters of the prefix currently match the suffix ending at i - 1". On a mismatch you don't reset len to 0 — you fall back to the next shorter candidate, fail[len - 1], and try again.

// prefix function / failure table — O(m) time, O(m) space
function buildFailure(pattern) {
  const fail = new Array(pattern.length).fill(0);
  let len = 0; // length of the current prefix-suffix match

  for (let i = 1; i < pattern.length; i++) { // fail[0] is always 0 — a single char has no proper prefix
    while (len > 0 && pattern[i] !== pattern[len]) {
      len = fail[len - 1]; // fall back to the next-shorter border, don't reset to 0
    }
    if (pattern[i] === pattern[len]) len++;
    fail[i] = len;
  }
  return fail;
}

buildFailure("ababaca"); // [0, 0, 1, 2, 3, 0, 1]

Dry run: buildFailure("ababaca")

ip[i]len beforefallback chainfail[i]
1b0—0
2a0—1
3b1—2
4a2—3
5c33 → fail[2]=1 → fail[0]=00
6a0—1

The while loop looks like it could make this quadratic. It can't: len grows by at most 1 per iteration of the outer loop, so across the whole build it increases at most m times — and the while loop only ever decreases it. Total decreases ≤ total increases ≤ m. This is the same amortized argument as the sliding window's left pointer, and it is the sentence to say out loud.

KMP search: the text pointer never moves backward

// all occurrences of pattern in text — O(n + m) time, O(m) space
function kmpSearch(text, pattern) {
  if (pattern.length === 0) return [];
  const fail = buildFailure(pattern);
  const hits = [];
  let len = 0; // how many pattern chars currently matched

  for (let i = 0; i < text.length; i++) { // i only ever increases — no backtracking in the text
    while (len > 0 && text[i] !== pattern[len]) {
      len = fail[len - 1];
    }
    if (text[i] === pattern[len]) len++;

    if (len === pattern.length) {
      hits.push(i - pattern.length + 1);
      len = fail[len - 1]; // keep going — allows overlapping matches
    }
  }
  return hits;
}

kmpSearch("aaaaa", "aa"); // [0, 1, 2, 3] — overlaps included
The one-line mental model KMP is a state machine whose state is "how many characters of the pattern I've matched so far." Reading a text character either advances the state by one or drops it to a state no larger than before — and it never rewinds the input. That's why it streams: you can feed KMP a network socket you can't seek backward in.
⚠ After a full match, resetting len to 0 loses overlaps If the question is "count occurrences of aa in aaaa," the answer is 3, not 2. Setting len = 0 after a hit gives you the non-overlapping count. Setting len = fail[len - 1] gives you overlapping matches. Ask the interviewer which they want — and note that you deliberately chose.

Rabin-Karp: compare hashes, not characters

KMP is clever about which comparisons to skip. Rabin-Karp is clever about making each comparison O(1): treat every length-m window of the text as a base-B number mod a large prime, and roll that number forward as the window slides — subtract the outgoing character's contribution, multiply by the base, add the incoming character. A window can only be a match if its hash equals the pattern's hash, so you compare m characters only on a hash hit.

// Rabin-Karp — O(n + m) expected, O(n·m) worst case, O(1) extra space
const BASE = 256;
const MOD = 1000000007; // large prime → collisions are rare, not impossible

function rabinKarp(text, pattern) {
  const n = text.length, m = pattern.length;
  if (m === 0 || m > n) return [];

  let high = 1; // BASE^(m-1) mod MOD — the weight of the leftmost char
  for (let i = 0; i < m - 1; i++) high = (high * BASE) % MOD;

  let patHash = 0, winHash = 0;
  for (let i = 0; i < m; i++) {
    patHash = (patHash * BASE + pattern.charCodeAt(i)) % MOD;
    winHash = (winHash * BASE + text.charCodeAt(i)) % MOD;
  }

  const hits = [];
  for (let i = 0; i + m <= n; i++) {
    // hash equality is necessary but NOT sufficient — always verify
    if (winHash === patHash && text.startsWith(pattern, i)) hits.push(i);

    if (i + m < n) {
      winHash = (winHash - (text.charCodeAt(i) * high) % MOD + MOD) % MOD; // drop the left char (+MOD keeps it non-negative)
      winHash = (winHash * BASE + text.charCodeAt(i + m)) % MOD;           // shift left, add the right char
    }
  }
  return hits;
}
⚠ Two bugs that bite everyone who writes this from memory (1) Negative modulo. JavaScript's % returns a negative result for negative operands, so -3 % 7 is -3, not 4. Always add MOD back before the final %. (2) Silent overflow. JS numbers are exact only up to 2⁵³. With MOD near 10⁹, the product winHash * BASE reaches ~2.6 × 10¹¹ and charCode * high reaches ~6.5 × 10¹³ — both safe. Push MOD to 10¹⁵ "because bigger is better" and the products silently lose precision and the algorithm returns wrong answers on large inputs. Use BigInt if you truly need a bigger modulus.

Collisions are handled by the startsWith verification, so Rabin-Karp is never wrong — only occasionally slow. An adversary who knows your BASE and MOD can construct a text where every window collides, degrading it to O(n·m); production implementations pick BASE randomly at startup for exactly this reason. Mentioning that unprompted reads as real experience.

Say it like this → "Rabin-Karp buys me O(1) per window with a rolling hash, and I verify on hash equality so collisions cost time but never correctness. I'd reach for it over KMP when I'm searching for many patterns of the same length at once — I hash all of them into a set and still do one pass — or for 2D pattern search, where KMP doesn't generalize cleanly."

Prefix hashes: compare any two substrings in O(1)

The rolling hash above slides one fixed-length window. Many problems instead ask "are these two substrings equal?" for arbitrary positions and lengths, again and again. Comparing characters costs O(length) each time. The fix is to precompute a hash of every prefix once, so the hash of any substring becomes a subtraction.

Let H[i] be the hash of the first i characters, built left to right: H[0] = 0 and H[i + 1] = H[i] · B + code(s[i]) (mod p). Unrolling the recurrence, H[r] is the polynomial whose highest power of B multiplies s[0]. The first l characters contribute H[l] multiplied by B once for each of the r − l characters that follow them, so

hash(s[l..r)) = H[r] − H[l] · Br−l (mod p)

Two substrings of the same length are equal exactly when their hashes are equal, up to collisions. A small worked example with base 10, modulus 101 and a = 1, b = 2 on "abab": the prefix hashes are H = [0, 1, 12, 20, 0]. The substring "ab" at [0, 2) has hash 12 − 0 · 100 = 12. The one at [2, 4) has hash 0 − 12 · 100 = −1200, which is 12 modulo 101 (since 1200 = 11 · 101 + 89 and 101 − 89 = 12). Equal, as they should be.

⚠ The multiplication is where JavaScript bites With a modulus near 109, H[l] · pow[r − l] is a product of two numbers below 109, up to about 1018, well past the 253 that a Number holds exactly. The rolling hash above stayed safe because one factor was a character code or a small base; here both factors are large. Either use BigInt (correct, but slow in a hot loop), keep the modulus below about 9.4 × 107 so the product fits, or multiply in two halves as below. The helper splits b into its upper and lower 16 bits, so no intermediate value exceeds 247.
// (a * b) % mod without leaving the exact integer range; needs 0 <= a, b < mod < 2^31
function mulMod(a, b, mod) {
  const high = Math.floor(b / 65536); // b = high * 65536 + low
  const low = b % 65536;
  return (((a * high) % mod) * 65536 + a * low) % mod;
}

// two independent hashes of every prefix, so substring hashes cost O(1) after O(n) setup
class SubstringHasher {
  constructor(s) {
    const n = s.length;
    this.mods = [1000000007, 998244353];
    this.prefix = [];
    this.power = [];
    for (const mod of this.mods) {
      const base = 256 + Math.floor(Math.random() * (mod - 512)); // random base, chosen at run time
      const prefix = new Array(n + 1).fill(0);
      const power = new Array(n + 1).fill(1); // power[i] = base^i mod mod
      for (let i = 0; i < n; i++) {
        prefix[i + 1] = (mulMod(prefix[i], base, mod) + s.charCodeAt(i)) % mod;
        power[i + 1] = mulMod(power[i], base, mod);
      }
      this.prefix.push(prefix);
      this.power.push(power);
    }
  }

  // hash of s[l..r), as a string key that is safe to use in a Map
  hash(l, r) {
    const parts = this.mods.map((mod, k) => {
      const removed = mulMod(this.prefix[k][l], this.power[k][r - l], mod);
      return (this.prefix[k][r] - removed + mod) % mod; // + mod keeps the difference non-negative
    });
    return parts.join(",");
  }
}

Setup is O(n) time and space; every hash(l, r) after that is O(1) (two mulMod calls). Two independent moduli are used for a reason explained two sections below. The hash is returned as a string such as "123456,789012" because the two numbers together are too large to pack into one exact Number.

Longest duplicate substring: binary search on the length

Find the longest substring that occurs at least twice (the two copies may overlap). Checking every pair of start positions with a character comparison is O(n³) in the worst case. Two observations make it O(n log n).

  • Monotone in the length. If some substring of length L occurs twice, so does its first L − 1 characters. So "is there a duplicate of length L?" is true for small L and false from some point on, which is exactly the shape binary search needs.
  • One length is one pass. For a fixed L, slide a window over the string, hash every window in O(1) with the prefix hashes, and store the hashes in a Map. The first window whose hash is already present is a candidate duplicate.
// O(n log n) expected time, O(n) space. Overlapping copies count; any longest one may be returned.
function longestDupSubstring(s) {
  const n = s.length;
  const hasher = new SubstringHasher(s);

  // start of the second copy of some repeated substring of this length, or -1
  function duplicateStart(len) {
    const seen = new Map(); // hash key -> starts of windows with that hash
    for (let i = 0; i + len <= n; i++) {
      const key = hasher.hash(i, i + len);
      const starts = seen.get(key);
      if (starts === undefined) {
        seen.set(key, [i]);
        continue;
      }
      const here = s.slice(i, i + len);
      if (starts.some((j) => s.startsWith(here, j))) return i; // verify: equal hashes may still be a collision
      starts.push(i);
    }
    return -1;
  }

  let lo = 1, hi = n - 1, best = "";
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    const at = duplicateStart(mid);
    if (at !== -1) {
      best = s.slice(at, at + mid); // a duplicate exists: remember it and try longer
      lo = mid + 1;
    } else {
      hi = mid - 1;
    }
  }
  return best;
}

Dry run: longestDupSubstring("banana")

lo, himidwindows of that lengthduplicate?then
1, 53ban, ana, nan, anayes: "ana" again at 3best = "ana", lo = 4
4, 54bana, anan, nananohi = 3
4, 3lo > hi, stop: the answer is "ana" (positions 1 and 3, the two copies share the "a" at position 3)

Cost: about log n rounds, each one pass of n window hashes, each O(1), plus the verification. Verification happens only on a hash hit, and a true hit ends the round immediately, so it adds O(L) once per round in the usual case: the total expected time stays O(n log n). Correctness never depends on luck: a collision costs time (an extra comparison), never a wrong answer.

⚠ Four details that decide whether this passes The search range. A duplicate cannot be as long as the whole string, so the lengths run from 1 to n − 1. Starting hi at n would call the check with a window as long as the string, which has only one position and never repeats. Return the string, not the length. Remember the best match each time the check succeeds. Overlap is legal. "aaaa" has the duplicate "aaa", at positions 0 and 1; a solution that requires the copies not to overlap is answering a different problem. Mind the memory. The Map holds up to n keys per round, but only one round at a time, so the space stays O(n).

Double hashing, and the hash that anyone can break

A single modulus near 109 has only about a billion possible hash values. By the birthday paradox, among about the square root of that many different strings (tens of thousands) two will probably share a hash. In longestDupSubstring that is harmless, because it verifies, but in code that trusts the hash to mean equality it is a real bug waiting for a big input. Double hashing uses two independent moduli (and bases) and treats two substrings as equal only if both hashes agree. The combined space is about 1018 values, so the collision probability among 105 strings, roughly (105)² / 2 divided by 1018, is about 5 × 10−9.

The other defence is where the base comes from. If the base and modulus are constants in the source, anyone who can read the problem can construct inputs that collide on purpose. Choosing the base at run time, as SubstringHasher does, means a test written in advance cannot target it.

The most instructive failure is the one with no modulus at all. Hashing with the machine's natural 32-bit overflow looks harmless, and it is trivial to break. Build two strings by the Thue–Morse rule: start from T = "a" and S = "b", and repeat T, S = T + S, S + T. After seven rounds the two strings are 128 characters long and differ in every position (one is the other with a and b swapped), and yet they have exactly the same hash for every odd base:

function overflowHash(s, base) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (Math.imul(h, base) + s.charCodeAt(i)) | 0; // wraps at 2^32
  return h;
}

function thueMorsePair(rounds) {
  let t = "a", u = "b";
  for (let i = 0; i < rounds; i++) [t, u] = [t + u, u + t];
  return [t, u];
}

const [t, u] = thueMorsePair(7);
t === u;                                 // false, 128 characters each
overflowHash(t, 31) === overflowHash(u, 31); // true, for any odd base

The reason is that the difference of the two hashes is, up to sign, a product of factors B2i − 1, each divisible by a growing power of two, so after a few rounds it is divisible by 232 and vanishes. (We checked every odd base below 20,000.) With a 64-bit wrap the same construction needs only ten rounds, 1,024 characters. A prime modulus does not have this weakness, which is why the code uses one.

Hash designCollisionsUse it when
One prime modulus, fixed baseabout √p strings find a collision by chance; an adversary can aim for onenever trust it without verifying
One prime modulus, random basechance collisions only, but with p near 109 they are likely on large inputsyou verify every hit
Two prime moduli, random basesabout 5 × 10−9 chance per 105 stringscomparing many substrings, still verifying if the answer must be exact
Natural overflow (no modulus)broken by Thue–Morse strings, whatever the basenever, if the input is not under your control
Say it like this → "I'll precompute prefix hashes so any substring's hash is one subtraction, then binary search the length: if a duplicate of length L exists, so does one of length L − 1. Each length is one pass with a hash map. I use two moduli and a random base so collisions are vanishingly unlikely, and I confirm a hit with a real comparison so a collision can only cost time, never correctness."

The Z-function: the same information, easier to reason about

z[i] is the length of the longest substring starting at i that is also a prefix of the whole string. It's computed with a "Z-box" — the rightmost interval [l, r) known to match a prefix — which lets you copy an already-known answer from the mirror position instead of recomputing it. Many people find Z easier to derive under pressure than KMP's failure table, and it solves substring search by a trick: concatenate.

// Z-function — O(n) time, O(n) space
function zFunction(s) {
  const n = s.length;
  if (n === 0) return [];
  const z = new Array(n).fill(0);
  z[0] = n;
  let l = 0, r = 0; // [l, r) = rightmost segment known to match a prefix

  for (let i = 1; i < n; i++) {
    if (i < r) z[i] = Math.min(r - i, z[i - l]); // reuse the mirror, clamped to the box
    while (i + z[i] < n && s[z[i]] === s[i + z[i]]) z[i]++; // extend past the box the slow way
    if (i + z[i] > r) { l = i; r = i + z[i]; } // this match reaches further right — adopt it
  }
  return z;
}

// substring search: glue with a separator assumed to appear in neither string
function zSearch(text, pattern) {
  if (pattern.length === 0) return [];
  const combined = pattern + "#" + text;
  const z = zFunction(combined);
  const hits = [];
  for (let i = pattern.length + 1; i < combined.length; i++) {
    if (z[i] === pattern.length) hits.push(i - pattern.length - 1);
  }
  return hits;
}

The separator must be a character that cannot occur in either string. If it does, a prefix match can run across the boundary, z[i] exceeds pattern.length, and the === test misses a real hit: zSearch("a#", "a") returns []. This code uses "#", so it assumes the strings hold no # (lowercase letters, say); state that assumption out loud, and pick another character if the input can contain one. All three searchers here (kmpSearch, rabinKarp, zSearch) return [] for an empty pattern, a choice to state aloud, since indexOf("") would call it a match at 0.

Sum of scores: reading the whole Z array

The Z-function gives you, for every position, how far the string agrees with its own beginning. Some problems ask for an aggregate of that. Here is one. Build a string by prepending characters one at a time, so the strings are the suffixes of s of length 1, 2, ..., n. The score of a suffix is the length of its longest common prefix with the full string s. What is the sum of the scores of all n suffixes?

The suffix of length i starts at position n − i, and its score is exactly z[n − i], the length of the longest match with the prefix there. As i runs from 1 to n, the position n − i runs through every index of the string, so the answer is the sum of the whole Z array. The suffix of length n is the string itself and scores n, which is why zFunction sets z[0] = n.

// O(n) time, O(n) space; uses zFunction from above
function sumScores(s) {
  return zFunction(s).reduce((total, value) => total + value, 0);
}

sumScores("babab"); // 9
suffix startsuffix of "babab"common prefix with "babab"z
0bababbabab5
1ababnone0
2babbab3
3abnone0
4bb1

The scores 5 + 0 + 3 + 0 + 1 add up to 9. The brute force, comparing every suffix against the string character by character, is O(n²) in the worst case ("aaaa..."). The Z array does all those comparisons in one linear pass. The same sum counts, over all lengths k, how many positions the prefix of length k occurs at, so "how many times does each prefix occur" is also one read of the Z array: the number of occurrences of the length-k prefix is the number of positions with z[i] >= k.

Manacher's algorithm: every palindrome, in O(n)

Expand-around-center for the longest palindromic substring is O(n²): 2n−1 centers, each expansion up to O(n). Manacher makes it linear with the same reuse idea as the Z-function — a palindrome centered at c reaching to right means positions inside it are mirror images of positions already solved, so you start each expansion from a known lower bound rather than from zero.

First, the unification trick. Odd-length and even-length palindromes need different center handling, which is where most hand-written attempts get tangled. Interleave a separator: "abba" becomes "#a#b#b#a#". The transformed string always has odd length 2n + 1, so every palindrome in it is odd-length and has a single character center — even-length palindromes of the original become odd-length palindromes centered on a #. Better still, the radius p[i] in the transformed string is exactly the palindrome's length in the original string.

originaltransformedcenterradius poriginal length
"aba"#a#b#a#index 3 ('b')33
"abba"#a#b#b#a#index 4 ('#')44
"a"#a#index 1 ('a')11
// longest palindromic substring — O(n) time, O(n) space
function longestPalindrome(s) {
  if (s.length < 2) return s;

  const t = "#" + s.split("").join("#") + "#"; // always odd length: 2n + 1
  const n = t.length;
  const p = new Array(n).fill(0); // p[i] = palindrome radius at i (= length in s)
  let center = 0, right = 0; // rightmost palindrome found so far

  for (let i = 0; i < n; i++) {
    if (i < right) {
      const mirror = 2 * center - i;
      p[i] = Math.min(right - i, p[mirror]); // clamp: beyond "right" nothing is verified yet
    }
    // expand only past what the mirror already guaranteed
    while (i - p[i] - 1 >= 0 && i + p[i] + 1 < n &&
           t[i - p[i] - 1] === t[i + p[i] + 1]) {
      p[i]++;
    }
    if (i + p[i] > right) { center = i; right = i + p[i]; } // new rightmost reach
  }

  let best = 0, bestCenter = 0;
  for (let i = 0; i < n; i++) {
    if (p[i] > best) { best = p[i]; bestCenter = i; }
  }
  const start = (bestCenter - best) / 2; // map transformed index back to s
  return s.slice(start, start + best);
}

longestPalindrome("babad");   // "bab" (or "aba" — both valid)
longestPalindrome("cbbd");    // "bb"
longestPalindrome("forgeeksskeegfor"); // "geeksskeeg"
⚠ The clamp is what makes it linear, and it's the line people drop Math.min(right - i, p[mirror]) — without the right - i term you'd copy a mirror radius that extends past the verified region, and you'd report palindromes that don't exist. Without p[mirror] you'd start every expansion at 0 and be back to O(n²). The linearity argument: right only moves forward, and every iteration of the inner while loop pushes right one step further, so the total work in all expansions is bounded by n.
Say it like this → "Expand-around-center is O(n²) and is a completely acceptable answer here — let me code that first, then I'll tell you how Manacher gets it to O(n). The trick is interleaving a separator so odd and even cases unify, then reusing the mirror radius inside the current rightmost palindrome so each expansion starts from a known lower bound instead of from scratch."

That's a genuinely good interview move. Manacher is rarely required — but showing you know the O(n²) baseline, can implement it cleanly, and can explain the linear improvement is worth more than a memorized Manacher you can't justify.

Choosing between them

AlgorithmPreprocessSearchExtra spaceReach for it when
Naive—O(n·m)O(1)m is tiny, or it's the stated baseline
KMPO(m)O(n) guaranteedO(m)One pattern, worst-case guarantee needed, streaming input
Rabin-KarpO(m)O(n) expectedO(1)Many equal-length patterns, 2D search, dedup/fingerprinting
Z-functionO(n+m)O(n+m)O(n+m)Prefix-flavoured questions; easier to re-derive live
Manacher—O(n)O(n)Palindromes specifically
Prefix hashes + binary searchO(n)O(1) per substring comparisonO(n)Equality of arbitrary substrings; "longest ... that repeats" where the length is monotone
Aho-CorasickO(total pattern length)O(n + matches)O(total pattern length)Many patterns of any lengths in one pass; banned words, keyword tagging
Suffix array + LCPO(n log² n)O(m log n) per patternO(n)All substrings of one string: distinct count, longest repeat, many queries
Minimal rotation—O(n)O(1)A canonical form for a cyclic string, grouping rotations

In real code you would call indexOf, which engines implement with a tuned hybrid (V8's is Boyer-Moore-Horspool based). Say that too — knowing when not to hand-roll is part of the signal. These algorithms earn their keep when the built-in doesn't fit the shape: streaming, multi-pattern, or when the failure table itself is the answer (shortest palindrome, longest repeated prefix, string periodicity).

The failure table answers more than "where is the pattern"

A surprising number of string questions reduce to "compute the prefix function and read one entry."

// Shortest palindrome: prepend the fewest chars to make s a palindrome.
// Trick: the answer hinges on the longest palindromic PREFIX of s.
function shortestPalindrome(s) {
  const rev = s.split("").reverse().join("");
  const fail = buildFailure(s + "#" + rev);
  const overlap = fail[fail.length - 1]; // longest prefix of s that is a suffix of reverse(s)
  return rev.slice(0, s.length - overlap) + s;
}

// Smallest repeating unit: "abcabcabc" → "abc". Returns s itself if none.
function repeatedUnit(s) {
  const fail = buildFailure(s);
  const period = s.length - fail[s.length - 1];
  return s.length % period === 0 ? s.slice(0, period) : s;
}

As in zSearch, the "#" in shortestPalindrome must not occur in s: if it does, the table's last entry can exceed s.length and the answer is wrong, as shortestPalindrome("a#a") shows.

n - fail[n-1] being the smallest period of a string is a genuinely useful identity — it's the core of the answer to "Repeated Substring Pattern" (plus a check that the period divides n) and to several string-rotation questions.

Why the prefix function finds the longest palindromic prefix

shortestPalindrome above is short enough to look like a trick. Here is the reasoning that makes it a method. To make s a palindrome by adding characters only at the front, you keep the longest prefix of s that is already a palindrome, P, and mirror everything after it onto the front. The cost is the length of the part that is not in P, so the whole problem is to find the longest palindromic prefix.

Now connect that to borders. The suffix of reverse(s) of length k is the reverse of the prefix of s of length k. A string is a palindrome exactly when it equals its own reverse. So the prefix of length k is a palindrome if and only if it equals the length-k suffix of reverse(s). In s + "#" + reverse(s), that says the length-k prefix of the whole string equals its length-k suffix, which is a border, and the last entry of the prefix function is the length of the longest border. The # keeps the border from being longer than n, because no match can contain it (it occurs once).

Dry run: s = "aacecaaa", rev = "aaacecaa", glued = "aacecaaa#aaacecaa"

i012345678910111213141516
charaacecaaa#aaacecaa
fail01000122012234567

The last value is 7, so "aacecaa" (the first seven characters) is the longest palindromic prefix. The characters left over are s.slice(7) = "a"; its reverse is prepended, and the result is "a" + "aacecaaa" = "aaacecaaa". In code, rev.slice(0, n - overlap) is that reversed leftover. The method is O(n) where checking every prefix for being a palindrome is O(n²). The same idea, with the roles of prefix and suffix swapped, gives the shortest string to append.

Minimal rotation: the smallest cyclic shift

A rotation moves a prefix to the end: the rotations of "bca" are "bca", "cab" and "abc". Two questions come up. Is t a rotation of s? And what is the lexicographically smallest rotation of s, a canonical form that lets you compare cyclic strings or group strings that are rotations of each other?

The first has a clean answer: t is a rotation of s exactly when the lengths match and t occurs inside s + s, because every rotation of s is a window of length n in s + s. With KMP that is O(n):

// O(n) time using kmpSearch from above
function isRotation(s, t) {
  return s.length === t.length && (s.length === 0 || kmpSearch(s + s, t).length > 0);
}

For the smallest rotation, trying all n rotations and comparing them costs O(n²). The two-pointer method below does it in O(n). Keep two candidate starts, i and j, and compare the rotations that begin at them, character by character, with k counting how many characters agree. At the first difference:

  • If the character at i + k is larger, the rotation starting at i is not minimal. Neither is any start in (i, i + k]: the start i + t is dominated by the start j + t, which matched the same characters and is at least as small afterwards. Move i to i + k + 1.
  • If it is smaller, the same argument applies to j.
// start index of the lexicographically smallest rotation — O(n) time, O(1) space
function minimalRotationStart(s) {
  const n = s.length;
  let i = 0, j = 1, k = 0;
  while (i < n && j < n && k < n) {
    const a = s[(i + k) % n], b = s[(j + k) % n]; // the % n is what makes the string circular
    if (a === b) { k++; continue; }
    if (a > b) i += k + 1;
    else j += k + 1;
    if (i === j) j++; // the candidates must stay different
    k = 0;
  }
  return Math.min(i, j);
}

function minimalRotation(s) {
  const at = minimalRotationStart(s);
  return s.slice(at) + s.slice(0, at);
}

minimalRotation("bca");   // "abc"
minimalRotation("cabab"); // "ababc"

Dry run: minimalRotationStart("cabab")

i, j, kcompareaction
0, 1, 0s[0] = c vs s[1] = ac is larger, so skip i to 0 + 0 + 1 = 1; now i = j, so j = 2; k = 0
1, 2, 0s[1] = a vs s[2] = bb is larger, so skip j to 2 + 0 + 1 = 3; k = 0
1, 3, 0s[1] = a, s[3] = a: equal; k = 1, s[2] = b vs s[4] = b: equal; k = 2, s[3] = a vs s[(3 + 2) % 5] = s[0] = cc is larger, so skip j to 3 + 2 + 1 = 6; j is no longer below n, stop
return min(1, 6) = 1, so the smallest rotation is s.slice(1) + s.slice(0, 1) = "ababc"

Each comparison either raises k or, on a mismatch, moves one pointer forward by k + 1, so the pointers advance at least as far as the work done, and each can only go up to about n. The total is O(n). Use it for "group the strings that are rotations of each other" by keying on minimalRotation(s), and for any cyclic comparison.

⚠ Periodic strings and the i = j rule For a string like "abab" several starts give the same rotation, and the loop ends when k reaches n. That is fine: the returned index may be any of them, and the rotated string is identical. The two things to get right are the % n in the comparison (without it you read past the end instead of wrapping) and the if (i === j) j++ step (without it you compare a start with itself and never make progress).

Aho-Corasick: many patterns, one pass over the text

KMP finds one pattern. A list of banned words, a dictionary of keywords, or a set of DNA motifs needs all of them at once. Running KMP once per pattern costs O(k · n) for k patterns. A trie of all patterns plus the idea behind KMP's failure table gives one pass over the text: Aho-Corasick.

Start from the trie of the patterns (see the tries chapter). The state is the trie node for the longest suffix of the text read so far that is a prefix of some pattern. When the next character has no edge from the node, KMP would fall back to a shorter border. Here the node falls back along a failure link: the link of a node is the trie node for the longest proper suffix of its string that is also a prefix of some pattern. Links are computed in breadth-first order, so a node's link points to a shallower node that is already done. One more detail: a pattern can end inside another one ("he" inside "she"), so each node also collects the patterns that end at the nodes along its failure chain.

// patterns must be non-empty. O(total pattern length) to build, using Maps for transitions.
function buildAhoCorasick(patterns) {
  const next = [new Map()]; // next[node]: character -> child node
  const link = [0];         // failure link of each node; the root links to itself
  const out = [[]];         // out[node]: ids of patterns that END at this node or on its failure chain

  patterns.forEach((word, id) => {
    let node = 0;
    for (const ch of word) {
      if (!next[node].has(ch)) {
        next[node].set(ch, next.length);
        next.push(new Map());
        link.push(0);
        out.push([]);
      }
      node = next[node].get(ch);
    }
    out[node].push(id);
  });

  const queue = [...next[0].values()]; // depth-1 nodes keep link 0
  for (let head = 0; head < queue.length; head++) {
    const node = queue[head];
    for (const [ch, child] of next[node]) {
      let f = link[node];
      while (f !== 0 && !next[f].has(ch)) f = link[f]; // the KMP fallback, on the trie
      link[child] = next[f].has(ch) ? next[f].get(ch) : 0;
      out[child] = out[child].concat(out[link[child]]); // the link is shallower, so its list is already complete
      queue.push(child);
    }
  }
  return { next, link, out };
}

// every occurrence as [startIndex, patternId] — O(text length + matches) after the build
function ahoCorasickSearch(text, patterns) {
  const { next, link, out } = buildAhoCorasick(patterns);
  const hits = [];
  let node = 0;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    while (node !== 0 && !next[node].has(ch)) node = link[node];
    if (next[node].has(ch)) node = next[node].get(ch);
    for (const id of out[node]) hits.push([i - patterns[id].length + 1, id]);
  }
  return hits;
}

ahoCorasickSearch("ushers", ["he", "she", "his", "hers"]); // [[1, 1], [2, 0], [2, 3]]

Dry run: patterns he, she, his, hers, text "ushers"

The trie has ten nodes. The ones that matter for the search:

nodestringfailure linkpatterns ending here or along the chain
1hrootnone
2heroothe
3srootnone
4shhnone
5sheheshe, he
8herrootnone
9hersshers
icharstate after reading itreports
0uroot (no edge for u)none
1ssnone
2hshnone
3esheshe at 1, he at 2
4rshe has no r edge, falls back to he, which has one: hernone
5shershers at 2

Both she and he are reported at index 3 even though only one trie node was reached, because out[she] inherited he through the failure link. The transition from she on r is the interesting one: the text so far, "ushe", ends in "he", which is exactly where a continuation "r" can still lead to "hers". KMP would have called that falling back to a border.

Complexity: the node pointer can rise by one level per text character and every failure-link step lowers it, the same amortised argument as KMP, so scanning is O(text length) plus the number of reported matches (a text of all as with patterns a, aa, aaa, ... reports many matches per position, and no algorithm can report them faster than it writes them). The build is O(total length of the patterns) with Map transitions, plus the cost of copying the output lists (a "dictionary link" per node instead of a copied list avoids that). For a fixed small alphabet you can also precompute the full transition table so scanning never loops at all.

⚠ Where Aho-Corasick goes wrong Forgetting the inherited outputs. Without out[child] = out[child].concat(out[link[child]]) the search finds she and silently misses the he inside it. Computing links depth-first. A link must point to a node whose own link is already known, which is why the loop is breadth-first. Empty patterns. An empty pattern would end at the root and be reported at every position; decide what you want, or filter them out as the comment above says. Duplicate patterns. The same word listed twice ends at one node with two ids, and both are reported. That is correct for "report every pattern id", and wrong if you wanted a set.

Suffix arrays: every suffix, in sorted order

The suffix array of a string is the list of the starting positions of all its suffixes, ordered by the suffixes' alphabetical order. For "banana" the suffixes in order are a, ana, anana, banana, na, nana, so the suffix array is [5, 3, 1, 0, 4, 2]. Once you have it, many questions about all substrings reduce to questions about neighbours in a sorted list, because every substring is a prefix of some suffix and substrings that start alike sit next to each other.

Building it. Sorting the suffixes as full strings is O(n² log n) in the worst case. Prefix doubling avoids comparing long strings: rank the suffixes by their first character, then repeatedly rank them by their first 2, 4, 8, ... characters. To order by the first 2k characters, compare the pair (rank by the first k characters of the suffix, rank by the first k characters of the suffix that starts k later). After about log₂ n rounds all ranks are distinct.

// O(n log² n) time (about log n rounds of a comparison sort), O(n) space
function suffixArray(s) {
  const n = s.length;
  if (n === 0) return [];
  const sa = Array.from({ length: n }, (_, i) => i);
  let rank = Array.from(s, (c) => c.charCodeAt(0)); // round 0: rank by the first character

  for (let k = 1; ; k *= 2) {
    const second = (i) => (i + k < n ? rank[i + k] : -1); // a suffix shorter than k sorts first
    sa.sort((a, b) => rank[a] - rank[b] || second(a) - second(b));

    const next = new Array(n).fill(0);
    for (let i = 1; i < n; i++) {
      const same = rank[sa[i - 1]] === rank[sa[i]] && second(sa[i - 1]) === second(sa[i]);
      next[sa[i]] = next[sa[i - 1]] + (same ? 0 : 1);
    }
    rank = next;
    if (rank[sa[n - 1]] === n - 1) return sa; // every suffix has its own rank: done
  }
}

The array alone is not enough for most problems; you usually want the LCP array too: lcp[i] is the length of the longest common prefix of the neighbouring suffixes sa[i] and sa[i + 1]. Computing it naively is quadratic, but Kasai's algorithm uses one fact: when you move from suffix i to suffix i + 1, the common prefix with its sorted neighbour can shrink by at most one, so you never need to restart the count from zero.

// O(n) time, O(n) space; lcp[r] is the common prefix of suffixes sa[r] and sa[r + 1]
function lcpArray(s, sa) {
  const n = s.length;
  const rank = new Array(n);
  for (let i = 0; i < n; i++) rank[sa[i]] = i;

  const lcp = new Array(Math.max(n - 1, 0)).fill(0);
  let h = 0; // the current common-prefix length, carried from one suffix to the next
  for (let i = 0; i < n; i++) {
    if (rank[i] === n - 1) { h = 0; continue; } // the last suffix has no right neighbour
    const j = sa[rank[i] + 1];
    while (i + h < n && j + h < n && s[i + h] === s[j + h]) h++;
    lcp[rank[i]] = h;
    if (h > 0) h--; // the next suffix shares at least h - 1 characters with its neighbour
  }
  return lcp;
}

// the number of different substrings: all n(n+1)/2, minus those counted twice
function countDistinctSubstrings(s) {
  const lcp = lcpArray(s, suffixArray(s));
  return (s.length * (s.length + 1)) / 2 - lcp.reduce((total, value) => total + value, 0);
}

// the longest substring that occurs at least twice (overlaps allowed)
function longestRepeatedSubstring(s) {
  const sa = suffixArray(s);
  const lcp = lcpArray(s, sa);
  let best = 0, at = 0;
  for (let i = 0; i < lcp.length; i++) {
    if (lcp[i] > best) { best = lcp[i]; at = sa[i]; }
  }
  return s.slice(at, at + best);
}

Dry run: s = "banana"

rank rsa[r]suffixlcp[r] with the next suffix
05a1 (with "ana")
13ana3 (with "anana")
21anana0 (with "banana")
30banana0 (with "na")
44na2 (with "nana")
52nananone

There are 6 · 7 / 2 = 21 substrings counted with repeats, and the LCP values add up to 1 + 3 + 0 + 0 + 2 = 6, so there are 21 − 6 = 15 distinct substrings. The largest LCP is 3, between "ana" and "anana", so the longest repeated substring is the first 3 characters of the suffix at position 3, which is "ana". Why the distinct count works: walking the sorted suffixes, each new suffix contributes only the prefixes that are longer than what it shares with the previous one, which is its length minus the LCP.

⚠ What the comparator must say about short suffixes In the doubling step, a suffix that has fewer than k characters left has no second half. It must sort before any suffix that agrees with it on the first half and continues, because a prefix of a string sorts before the string itself. Using -1 for the missing second rank (all real ranks are 0 or more) does exactly that; using 0 would tie it with a real rank and produce a wrong order. Also, Array.prototype.sort with a comparator is a comparison sort, so each round is O(n log n), and the whole construction is O(n log² n) rather than the O(n log n) of radix-sort versions.

Linear-time constructions exist, but you will not be asked to write one in an interview. What you should be able to say: the suffix array plus the LCP array turns "distinct substrings", "longest repeated substring" and "does pattern P occur" (binary search the suffix array, O(m log n)) into short loops, at the price of O(n log² n) to build. For one-off questions about a single substring length, hashing with binary search is usually less code; the suffix array pays off when you ask many different questions about the same string.

Recognizing it in an unseen problem

  • "Find all occurrences," "does A contain B," "how many times does the pattern appear" with n and m both large enough that O(n·m) times out — that's KMP or Rabin-Karp
  • The input is a stream, or you're told you may not seek backward in the text — KMP is the only one of these that never rewinds the input pointer
  • Several patterns, all the same length, searched at once, or a 2D grid pattern — Rabin-Karp, because hashes go into a Set and generalize to rectangles
  • Anything about prefixes that are also suffixes, string periodicity, rotations, or "shortest characters to prepend/append" — build the failure table and read one entry, don't invent a new algorithm
  • "Longest palindromic substring/prefix," "count all palindromic substrings" — expand-around-center first (O(n²), always acceptable), Manacher if pressed for linear
  • Distinguish from DP: "longest palindromic subsequence" (non-contiguous) is 2D DP, not Manacher; "edit distance" and "longest common subsequence" are DP too. These algorithms are all about contiguous matches
  • Pitfall: hash equality is never proof of string equality — an implementation that skips the verification step is a bug, not an optimization
  • "Longest substring that appears twice / satisfies something monotone in the length," or many "are these two substrings equal" checks → prefix hashes plus binary search on the length, with two moduli, a random base and a verification on hits
  • A dictionary of many words searched in one text, or "censor / tag every keyword" → Aho-Corasick: a trie with failure links, one pass over the text
  • "Sum of the longest common prefixes with the whole string" → the sum of the Z array. "Is B a rotation of A" → B occurs in A + A. "Smallest or canonical rotation" → the two-pointer minimal rotation
  • "Number of distinct substrings," "longest repeated substring," "k-th smallest substring" → a suffix array and its LCP array, rather than a new algorithm per question
Before you move on
  • Build the KMP failure table for a pattern by hand, and explain why a mismatch falls back to fail[len - 1] instead of resetting len to 0.
  • Write kmpSearch from memory and explain why the text pointer i never moves backward, even on a mismatch.
  • Explain Rabin-Karp's two classic bugs — negative modulo and silent overflow — and what specifically in the code prevents each one.
  • Trace the Z-function's mirror-copy step on a short string and explain why it keeps the algorithm O(n) instead of degrading to O(n²).
  • Given a new problem, decide whether it needs occurrence search (KMP/Rabin-Karp/Z-function), palindrome structure (Manacher), or just the failure table's n - fail[n-1] identity (periodicity) — and justify the choice.
  • Derive the substring hash H[r] - H[l] * B^(r-l), explain why H[l] * pow needs a safe multiplication in JavaScript, and use the monotone-length idea to find the longest duplicate substring.
  • Say why one modulus with a fixed base is unsafe, why natural 32-bit overflow can be broken by a Thue-Morse pair, and what two moduli with a random base buy you.
  • Explain the failure link of Aho-Corasick as KMP's fallback on a trie, and why each node must inherit the outputs of its link.
  • Show why the prefix function of s + "#" + reverse(s) gives the longest palindromic prefix, and why the sum of the Z array is the sum of scores.
  • Describe how a suffix array and its LCP array count distinct substrings, and what comparison rule a suffix with no second half needs.

13Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsImplement indexOf (strStr)beginner
  • 9 testsThe KMP prefix functionintermediate
  • 9 testsRepeated Substring Patternbeginner
  • 9 testsLongest Happy Prefixadvanced
  • 9 testsRepeated DNA Sequences (rolling hash)intermediate
  • 8 testsLongest Duplicate Substringadvanced
  • 8 testsSum of Scores of Built Stringsintermediate
  • 8 testsRotate Stringintermediate
  • 8 testsShortest Palindromeadvanced
  • 8 testsStream of Charactersadvanced
  • 8 testsLexicographically Smallest Rotationadvanced
  • 8 testsCount Distinct Substringsadvanced
  • 8 testsCount Every Keyword in a Textadvanced
All 159 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

A8

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. A7Previous · A7Sparse tables and range tricks
  2. A8you are here
  3. Next · A9Design problemsThe interface is the spec — pick the data structure combo that meets the complexity contract before you write a line of code.36 min read

01On this page

18 sections · 0%

  1. 01The insight: a mismatch still tells you something
  2. 02The prefix function: what KMP actually precomputes
  3. 03Building the failure table, step by step
  4. 04KMP search: the text pointer never moves backward
  5. 05Rabin-Karp: compare hashes, not characters
  6. 06Prefix hashes: compare any two substrings in O(1)
  7. 07Longest duplicate substring: binary search on the length
  8. 08Double hashing, and the hash that anyone can break
  9. 09The Z-function: the same information, easier to reason about
  10. 10Sum of scores: reading the whole Z array
  11. 11Manacher's algorithm: every palindrome, in O(n)
  12. 12Choosing between them
  13. 13The failure table answers more than "where is the pattern"
  14. 14Why the prefix function finds the longest palindromic prefix
  15. 15Minimal rotation: the smallest cyclic shift
  16. 16Aho-Corasick: many patterns, one pass over the text
  17. 17Suffix arrays: every suffix, in sorted order
  18. 18Recognizing it in an unseen problem
Mark as read

[] chapters · t top