Z-Algorithm
The Z-algorithm computes, for a string
Sof lengthn, the Z-arrayZ[0..n-1]whereZ[i]is the length of the longest substring ofSstarting at positionithat is also a prefix ofS. It runs in O(n) time and is the workhorse for substring search via the trickpattern + "$" + text: any positioniin the concatenated string withZ[i] = mcorresponds to an occurrence ofpattern(lengthm) intext. The algorithm is conceptually simpler than Knuth-Morris-Pratt (it doesn’t require a failure-function recurrence), is presented as the canonical example in Gusfield’s 1997 textbook Algorithms on Strings, Trees, and Sequences, and shows up constantly in competitive-programming problem sets.
1. Intuition — “How Much of the Beginning Repeats Here?”
Pick any string and look at every position i ≥ 1. Ask the question: if I started reading the string from position i, how many characters in a row would also match the start of the original string?
Take S = "aabcaabxaaaz" (length 12). Starting at position 4 we see "aabxaaaz". How many characters of that match the prefix of S ("aabcaabxaaaz")? 'a' == 'a', 'a' == 'a', 'b' == 'b' — three matches; the fourth is 'x' vs 'c', so Z[4] = 3.
That’s it — the Z-array just records this number for every starting position. The leftmost entry Z[0] is conventionally either left undefined or set to n (the prefix matches itself entirely); both conventions appear in the literature.
The real-world analogy is the auto-correlation of a sequence: how does the string look like its own shifted copy? Repetitive patterns produce large Z[i] values at the period; one-off characters drop the count back to zero. A telegraph operator listening for the start of a known call sign in a noisy stream is doing exactly this — and the Z-array is what they’d memorize ahead of time about their own call sign so that, mid-listen, they can resume an interrupted match instead of restarting.
2. Tiny Worked Example
Compute the Z-array of S = "aabcaabxaaaz" (length 12) by hand to convince ourselves the answer is
i: 0 1 2 3 4 5 6 7 8 9 10 11
S: a a b c a a b x a a a z
Z: – 1 0 0 3 1 0 0 2 2 1 0
Going position by position:
i = 1:S[1..] = "abcaabxaaaz". Match against prefix"aabcaabxaaaz":a == a(1),b vs amismatch.Z[1] = 1.i = 2:S[2..] = "bcaabxaaaz".b vs aimmediate mismatch.Z[2] = 0.i = 3:c vs amismatch.Z[3] = 0.i = 4:S[4..] = "aabxaaaz".a a bmatch (3), thenx vs c.Z[4] = 3.i = 5:S[5..] = "abxaaaz".a == a(1),b vs amismatch.Z[5] = 1.i = 6:bmismatch.Z[6] = 0.i = 7:xmismatch.Z[7] = 0.i = 8:S[8..] = "aaaz".a amatch (2),a vs bmismatch.Z[8] = 2.i = 9:S[9..] = "aaz".a amatch (2),z vs bmismatch.Z[9] = 2.i = 10:S[10..] = "az".amatch (1),z vs amismatch.Z[10] = 1.i = 11:zmismatch.Z[11] = 0.
The naive computation we just did is O(n²) — for each of n positions we re-scan up to n characters. The Z-algorithm reuses information from previously computed Z values to drop this to O(n). The reuse mechanism is the [l, r] window, explained next.
3. The Algorithm — [l, r] Window of the Rightmost Z-box
We process positions i = 1, 2, …, n − 1 left to right. We maintain two indices l and r (initially l = r = 0) that record the rightmost Z-box seen so far: a half-open interval [l, r) such that S[l..r-1] is equal to the prefix S[0..r-l-1] (a previously discovered match).
When processing position i:
Case A: i ≥ r. No prior info reaches i. Compute Z[i] by naive comparison: extend a counter k while S[k] == S[i + k]. Set Z[i] = k. If i + k > r, update l = i, r = i + k.
Case B: i < r. Position i lies inside the previous Z-box [l, r). The mirror of i inside the prefix is i' = i − l. We already know Z[i']. Two sub-cases:
-
B1:
Z[i'] < r − i. The match at the mirror dies before the right boundaryr. The same death must occur ati(because everything inside the Z-box agrees with the prefix). SoZ[i] = Z[i']— no comparison needed. -
B2:
Z[i'] >= r − i. The match at the mirror reaches at least tor. Beyondr, we know nothing —rwas the right boundary precisely becauseS[r] != S[r - l]. SetZ[i] = r − iinitially, then extend by naive comparison fromS[r]vsS[r - i]. Updatel = i, r = i + Z[i].
The amortized argument (§5) shows that across all iterations, the right endpoint r only ever increases — every “extend by naive comparison” pushes r strictly forward, and r is bounded by n. So the total comparison work across all iterations is O(n).
3.1 Why the mirror trick works
Inside the Z-box [l, r), the substring S[l..r-1] is equal to the prefix S[0..r-l-1]. So whatever pattern of “matches against the prefix” appears at position i' = i - l inside the prefix also appears at position i inside the Z-box — at least up to where the Z-box ends. Beyond r, the equality breaks (that’s why r is the right boundary). So we can safely copy Z[i'] into Z[i] as long as the match doesn’t try to walk past r; if it does, we have to fall back to character-by-character comparison from r onwards.
This is exactly analogous to KMP’s failure-link reuse, just expressed in terms of a moving “rightmost match window” instead of per-position back-pointers.
4. Pseudocode
ZArray(S):
n := length(S)
Z := array of size n, all zeros
l := 0
r := 0
for i in 1 .. n-1:
if i < r:
Z[i] := min(r - i, Z[i - l]) # Case B; the min handles B1 vs B2
# Case A and the "extend" tail of B2 share this loop
while i + Z[i] < n and S[Z[i]] == S[i + Z[i]]:
Z[i] := Z[i] + 1
if i + Z[i] > r:
l := i
r := i + Z[i]
return Z
5. Python Implementation
def z_function(s: str) -> list[int]:
"""Compute the Z-array of `s` in O(len(s)) time.
Z[0] is set to 0 here by convention (some references set it to len(s)).
Z[i] for i >= 1 is the length of the longest substring starting at
position i that matches a prefix of s.
"""
n = len(s)
Z = [0] * n
l, r = 0, 0
for i in range(1, n):
if i < r:
# Mirror trick: copy from the corresponding position inside the
# prefix, but never claim more than `r - i` characters because
# beyond r we have no information.
Z[i] = min(r - i, Z[i - l])
# Extend by naive comparison. This loop is the "expensive" part, but
# the amortized argument (§6) bounds the total work across all i.
while i + Z[i] < n and s[Z[i]] == s[i + Z[i]]:
Z[i] += 1
if i + Z[i] > r:
l, r = i, i + Z[i]
return Z
def z_search(text: str, pattern: str) -> list[int]:
"""Find all start indices (0-based) of `pattern` in `text` using the Z-array.
Standard trick: build T = pattern + sep + text where `sep` is a character
that appears in neither pattern nor text, then any position i with
Z[i] >= len(pattern) corresponds to a match starting at i - (len(pattern)+1)
in the original text.
"""
if not pattern:
return list(range(len(text) + 1))
sep = "\x00"
if sep in pattern or sep in text:
# Fall back to a guaranteed-fresh sentinel by scanning the alphabet.
sentinel = max(map(ord, pattern + text)) + 1
sep = chr(sentinel)
combined = pattern + sep + text
Z = z_function(combined)
m = len(pattern)
matches = []
for i in range(m + 1, len(combined)): # skip the pattern + sep portion
if Z[i] >= m:
matches.append(i - m - 1)
return matches
# Sanity checks against §2 worked example:
assert z_function("aabcaabxaaaz") == [0, 1, 0, 0, 3, 1, 0, 0, 2, 2, 1, 0]
assert z_search("ABABDABACDABABCABAB", "ABABCABAB") == [10]The sentinel character sep matters: it must not appear in either input, otherwise a Z-value can spuriously cross the boundary and over-report a match. The implementation above falls back to picking a fresh code point if the default \x00 is present — a robustness detail that’s easy to forget in interviews.
6. Complexity
- Time: O(n) for
z_function, O(n + m) forz_search(n = text length, m = pattern length). - Space: O(n + m) for the Z-array of the concatenated string.
6.1 Amortized argument
The while loop performs character comparisons. We count two kinds:
-
Successful comparisons (
s[Z[i]] == s[i + Z[i]]): each one advancesZ[i]by 1 and, when it pushes the right boundary, also advancesrby 1 (because the newr = i + Z[i]strictly exceeds the previousr). So the total number of successful comparisons is at mostn— bounded by how many timesrcan grow, andris at mostn. -
Failing comparisons (the loop’s exit condition): each iteration of the outer
forloop incurs at most one failing comparison. Total: at mostn − 1.
Sum: at most 2n character comparisons across the entire algorithm. Each comparison is O(1). Total time: O(n).
This argument is structurally identical to the Knuth-Morris-Pratt amortized proof (§5 of that note) — both algorithms essentially “pay forward” the cost of a match by forbidding subsequent re-examination.
7. Comparison with KMP
| Aspect | Z-Algorithm | Knuth-Morris-Pratt |
|---|---|---|
| Preprocessing object | Z-array of pattern + sep + text | LPS / failure function of pattern |
| Substring search time | O(n + m) | O(n + m) |
| Streaming text? | No — needs random access to the concatenated string | Yes — text pointer is monotone |
| Conceptual complexity | Slightly simpler — no recurrence on the failure values, just one window [l, r] | Slightly trickier — the failure-function recurrence needs care |
| Useful beyond search | Counting Z-box patterns, palindrome decomposition, “string period” | DFA construction, Aho-Corasick foundation, period extraction |
| Memory | O(n + m) (concatenated string) | O(m) (just the LPS) |
Practical guidance: if you only need substring search of a single pattern, KMP is slightly more memory-efficient and supports streaming. For “all occurrences of pattern in text” plus other prefix-related queries, the Z-array is often the simplest interface. Many competitive programmers carry both as templates.
The two arrays are information-equivalent: Gusfield 1997 §1.4 shows constructive O(n) conversions in both directions. They are different encodings of the same string-self-correlation structure.
8. Variants and Use Cases
8.1 Substring search
The headline application, demonstrated in z_search above. The pattern + sep + text trick is the canonical idiom; without the separator a long match in pattern could blur into the start of text.
8.2 String period detection
The smallest period of S is n − Z[n - p] for the largest p such that i + Z[i] == n for some i = p. Equivalent to the LPS-based period trick from KMP §6.6, just expressed in Z-form.
8.3 Number of distinct substrings
Build the Z-arrays of all n suffixes (each in O(n)), totalling O(n²); use them to count newly seen substrings. The Suffix Array gives O(n log n), but for n ≤ 5000 the Z-based approach is competitive in code clarity.
8.4 Longest palindromic substring (alternative to Manacher’s Algorithm)
For each position i, compute Z-arrays of s and of s + "#" + reverse(s); align to find the longest palindromic radius at every center. Slower than Manacher’s in constants but reuses the Z infrastructure.
8.5 String matching with don’t-care characters
Replace any ”?” with a unique fresh character before applying the Z-algorithm; only matches with Z[i] = m are real matches.
8.6 Suffix Automaton / Suffix Array preprocessing
Z-arrays underpin several O(n)-construction proofs for higher-level string indexes. Conceptually they’re the “first nontrivial linear-time string structure” Gusfield introduces precisely because everything else builds on them.
9. Pitfalls
9.1 Sentinel character collision
If your separator character actually appears in the input, the Z-array will silently over-extend matches across the boundary. Always pick a sentinel guaranteed to be unused — chr(max(input_codepoints) + 1) is the safe choice, or assert the chosen character is absent.
9.2 Z[0] convention
Some references define Z[0] = n, others Z[0] = 0, others leave it undefined. The downstream algorithms care: if you pass the array to a function expecting one convention with the other, you get a one-off bug at position 0. Always check the contract.
9.3 Off-by-one in i < r vs i <= r
The window is half-open [l, r) in this presentation (so r is the first index outside the matched range). Some references use closed intervals [l, r]. The relations Z[i] = min(r - i, Z[i - l]) vs Z[i] = min(r - i + 1, Z[i - l]) differ by one accordingly. Mixing conventions silently corrupts the array.
9.4 Forgetting to update (l, r) on Case A
If the naive computation in Case A finds matches that push past the previous r, you must update (l, r) = (i, i + Z[i]). Otherwise subsequent positions can’t reuse the work and complexity degrades to O(n²) on degenerate inputs (e.g., "aaaa…").
9.5 Treating Z[i] as “longest match ending at i”
A common confusion. Z[i] is the longest match starting at i. The “match ending at i” question is answered by the Z-array of the reverse string.
9.6 Integer overflow on huge strings
For n ≥ 2³¹, Z[i] and i + Z[i] can overflow 32-bit signed integers. Use 64-bit. In Python this is automatic; in C/C++ it isn’t.
10. Diagram — The [l, r] Window Slides Right
flowchart TB subgraph "Before processing index i" A["[l,r) = previously-discovered Z-box: S[l..r-1] = S[0..r-l-1]"] end subgraph "At index i, three cases" B1["Case A: i >= r — no info; naive scan from S[0] vs S[i]"] B2["Case B1: i < r and Z[i-l] < r-i — copy Z[i] := Z[i-l]"] B3["Case B2: i < r and Z[i-l] >= r-i — set Z[i] := r-i, then extend by naive scan from S[r-i] vs S[r]"] end subgraph "After processing" C["if i + Z[i] > r: (l,r) := (i, i + Z[i]) — window slides forward"] end A --> B1 --> C A --> B2 --> C A --> B3 --> C
What this diagram shows. The three branches the algorithm chooses among at every position. Case A happens when we’ve never matched far enough to cover i yet. Cases B1 and B2 reuse work from the mirror position i' = i - l inside the prefix; B1 is the cheap “just copy” case, B2 is the “copy what we know, then extend past r” case. Only Cases A and B2 ever do new comparison work, and they always push the right boundary r strictly forward — that is the geometric invariant that powers the linear-time bound proven in §6.1.
11. Common Interview Problems
| LC# / Source | Problem | Use of Z-Algorithm |
|---|---|---|
| LC 28 | Implement strStr() | Direct Z-search via pattern + "#" + text |
| LC 459 | Repeated Substring Pattern | Z-array period detection (§8.2) |
| LC 214 | Shortest Palindrome | Z-array of s + "#" + reverse(s), like the KMP version |
| LC 3008 | Find Beautiful Indices in String | Two Z-arrays for two patterns; merge match positions |
| LC 5 | Longest Palindromic Substring | Z-based palindrome radii (alternative to Manacher) |
| LC 1392 | Longest Happy Prefix | Find largest i with i + Z[i] = n |
| Codeforces 432D | Prefixes and Suffixes | Z-array + counting to enumerate prefix-suffix matches |
| Codeforces 271D | Good Substrings | Z-array of every suffix to count distinct substrings |
12. Open Questions
- Is there a “blocked” / cache-aware variant of the Z-algorithm that runs faster than the textbook version on modern hardware? KMP has a few; Z is rarer.
- What is the cleanest constructive proof that the Z-array and the LPS array are O(n)-time inter-convertible? Gusfield gives an algorithm; many derivations sketch it but few prove it tightly.
- Why does the Z-algorithm not generalise as cleanly to Aho-Corasick-style multi-pattern search as KMP does? The failure-link structure of KMP fits the trie naturally; the Z-algorithm’s
[l,r]window does not.
13. See Also
- Knuth-Morris-Pratt — the equivalent linear-time algorithm via failure function
- Rabin-Karp — the randomized linear-time alternative via String Hashing
- Manacher’s Algorithm — the palindrome-specific cousin using a similar mirror trick
- Aho-Corasick — multi-pattern search built on KMP failure links
- Suffix Array — heavier linear-time string index
- Suffix Automaton — DFA recognising all substrings; richer than Z-array
- String Hashing — the randomized cousin
- Big-O Notation
- SWE Interview Preparation MOC