Scanning a text of length n for every occurrence of a pattern of length m by restarting the comparison at each position re-reads characters an earlier partial match already covered, costing O(nm). The Z-algorithm removes that rescan by computing, in one left-to-right pass over a string S, the Z-array: z[i] is the length of the longest substring starting at index i that also matches a prefix of S.

For S = "aabaab", z = [·, 1, 0, 3, 1, 0]. The block starting at index 3 ("aab") matches the prefix "aab" for three characters, so z[3] = 3; index 1 shares only the leading a, so z[1] = 1. (z[0] spans the whole string and is left undefined or set to n by convention.) The array records, for every suffix, how far it agrees with the prefix — the same prefix-overlap information KMP’s failure function encodes, expressed as a forward match length rather than a recursive fallback.

The pass stays linear because it never recompares a character already known to sit inside an earlier match. A window [l, r] — the Z-box — remembers the match reaching furthest right; positions inside it read their value from an already-computed mirror instead of scanning again.

Core condition: a match interval whose right edge r only ever advances → each character is compared a bounded number of times → Θ(|S|) Z-array in Θ(|S|) space.

A step trace over S = "aabxaabxay" would show each z[i] being copied or extended as the box slides, but no renderer covers it yet.

Visualization pending

Planned StepTrace: a string-matching card showing the [l, r] Z-box sliding along the string, each position’s Z-value either read from a prior box or computed by extending a prefix match. No matching renderer exists in engine.js yet.

How the Z-box avoids rescanning

The pass carries one interval, the box [l, r]: the match with the largest right endpoint proven equal to a prefix, so S[l..r] == S[0..r-l]. Processing index i takes one of two paths.

  • i > r — outside the box. Nothing is known at i, so compare S[i], S[i+1], … against S[0], S[1], … directly, stopping at the first mismatch. z[i] is the matched length; if the match ends past the old r, the box slides to [i, i + z[i] - 1].
  • i <= r — inside the box. Because S[i..r] equals S[i-l..r-l], position i mirrors position k = i - l. When z[k] < r - i + 1, the mirrored match ends strictly before the box edge and is fully determined: z[i] = z[k], no comparisons. When z[k] >= r - i + 1, the mirror only guarantees a match up to r; past that the characters were never verified, so z[i] starts at the box remainder r - i + 1 and extends by direct comparison from r + 1, then slides the box.

The invariant that licenses the copy: everything at or left of r inside the box is a verified prefix match, so a mirror wholly inside the box needs no recheck. Only extension past r performs real comparisons.

Why the pass is linear. Direct comparisons happen only while extending beyond r. Each one either fails — ending work at i — or succeeds and pushes r one position right. r never moves left and stops at |S| - 1, so successful extensions total at most |S| comparisons, plus one failing comparison per index: Θ(|S|) overall. This is the amortized bound KMP also reaches, arrived at by tracking a forward match length instead of a fallback link.

The pass over S = "aabxaabxay" makes the reuse concrete:

Z-array of S = "aabxaabxay"   (indices 0..9)
 
i=1: outside. S[1]='a'==S[0], S[2]='b'!=S[1]. z[1]=1, box -> [1,1].
i=2: outside (i > r). S[2]='b'!=S[0]. z[2]=0.
i=3: outside. S[3]='x'!=S[0]. z[3]=0.
i=4: outside. 'a a b x a' match S[0..4], S[9]='y'!=S[5]. z[4]=5, box -> [4,8].
i=5: inside [4,8]. k=1, z[1]=1 < r-i+1=4 -> copy z[5]=1, no comparisons.
i=6: inside. k=2, z[2]=0 < 3 -> copy z[6]=0.
i=7: inside. k=3, z[3]=0 < 2 -> copy z[7]=0.
i=8: inside, edge. k=4, z[4]=5 >= 1 -> reset to remainder 1, extend from r+1=9:
     S[9]='y'!=S[1]. z[8]=1.
i=9: outside (i > r). S[9]='y'!=S[0]. z[9]=0.
 
Z = [10, 1, 0, 0, 5, 1, 0, 0, 1, 0]

Indices 5, 6, and 7 spend zero comparisons: their values are copied from the mirror inside [4, 8]. That reuse is what keeps the total linear rather than quadratic.

Matching a pattern by concatenation

Single-pattern search reduces to one Z-array. Build S = P + sep + T, where sep is a character occurring in neither P nor T, and compute z over S. Any index i in the T region with z[i] >= |P| marks an occurrence: the substring at i reproduces the whole pattern prefix in |P| characters that lie entirely inside T. A proper separator caps every text-region Z-value at |P| — no match can run across the boundary — so here >= and == coincide. With |P| = m and |T| = n, |S| = n + m + 1, giving Θ(n + m) time and Θ(n + m) space.

Complexity

ComputationTimeAuxiliary spaceCause
Z-array of a string SΘ(|S|)Θ(1) beyond the arrayr advances monotonically; each character drives at most one failed and one successful comparison
Search P in TΘ(n + m)Θ(n + m)scratch S = P + sep + T and its Z-array, both discarded after the scan

Best, average, and worst cases coincide: the pass is Θ(|S|) whether the string is all-distinct or highly periodic, because the box bounds total comparisons independently of content. The linear bound is unconditional — unlike Rabin-Karp, whose expected-linear scan can degrade to O(nm) when hash collisions force full verifications.

When the assumptions stop holding

A separator drawn from the alphabet — and why >= survives it. A separator outside the input alphabet caps every text-region z[i] at m: exceeding m would require matching S[m], the separator, against a text character, which cannot happen. That cap keeps z[i] == m and z[i] >= m equivalent and stops any match from spanning the P/T boundary. Let the separator back into the alphabet and the cap is gone. Searching for P = "ab" in T = "aba" with sep = 'a' builds "ab" + "a" + "aba" = "abaaba", whose Z-array is [6, 0, 1, 3, 0, 1]. The real occurrence of "ab" at text position 0 lands at index 3, where the match runs on through the separator-turned-a into the prefix and gives z[3] = 3. A strict z[i] == m test checks for 2 and misses it — a genuine hit dropped. The shipped FindAll uses z[i] >= m, which is robust: any text-region index (scanned from m + 1 on) with z[i] >= m has S[i..i+m-1] equal to P in m consecutive characters lying wholly inside T — an occurrence whatever sep is. So the separator’s job is narrower than correctness: with >=, an in-alphabet separator costs only the == m equivalence, not the result. A sentinel outside the alphabet — a \0 byte, or -1 over an integer sequence — keeps the two tests interchangeable.

Copying a mirror that reaches the box edge. Inside the box the mirror z[i-l] is exact only while it ends before r. When z[i-l] >= r - i + 1, taking it verbatim asserts a match over characters past r that were never compared. On S = "aaabaaa", index 2 mirrors index 1 with z[1] = 2, but copying that would claim S[2..3] = "ab" matches the prefix "aa"; the true value is z[2] = 1. The mirror at the edge is only a lower bound, so z[i] must be reset to the box remainder and re-extended from r + 1.

The extra array is the cost KMP avoids. Matching materializes S and its Z-array over the full n + m + 1 characters — Θ(n + m) scratch memory that exists only to be scanned once. KMP builds an O(m) table over the pattern alone and streams T in place, so on a large text under tight memory its footprint is smaller for the same linear time.

Reference drawer

Questions

References

  • Z-function — derivation of the box-based linear algorithm and the P + sep + T matching reduction, with the amortized O(n) argument.
  • Competitive Programmer’s Handbook — Antti Laaksonen; the string chapter covers the Z-array alongside the prefix function and their shared applications.