String Algorithms
String Fundamentals
String algorithms underpin text search, bioinformatics, compilers, log processing and version control. The recurring theme in this chapter is that the obvious O(nm) approach to pattern matching can almost always be reduced to O(n + m) — by preprocessing the pattern so that a mismatch tells you something useful instead of forcing you to start over.
Two implementation notes before the algorithms. In Python, strings are immutable, so building one up
with s += c in a loop is O(n²) — collect the pieces in a list and
"".join() once. And if you are working with human text rather than ASCII, remember that
a Python character is a Unicode code point, not a user-perceived character: an emoji or an accented
letter may be several code points, so naive slicing and reversal can corrupt it.
Pattern Matching: The Naive Approach
Find every occurrence of a pattern of length m inside a text of length n. The direct method tries the pattern at each of the n − m + 1 starting positions:
def naive_search(text, pattern):
n, m = len(text), len(pattern)
matches = []
for i in range(n - m + 1):
j = 0
while j < m and text[i + j] == pattern[j]:
j += 1
if j == m:
matches.append(i)
return matches
# Time: O(n * m) Space: O(1)
On ordinary text this is usually fine — mismatches typically happen on the first character, so
the inner loop rarely runs. The worst case is a text and pattern that almost match
everywhere: searching "AAAAAB" in "AAAAAAAAAAAAAAAA" compares 5 characters
at every position before failing. Every algorithm below exists to eliminate that repeated work.
KMP (Knuth-Morris-Pratt)
The insight: when a mismatch occurs after matching k characters, you already know what those k characters were — they are a prefix of the pattern. If that prefix has a proper suffix which is also a prefix of the pattern, you can slide the pattern forward to align them and resume, without ever moving backwards in the text.
The failure function
Precompute, for each position i, the length of the longest proper prefix of
pattern[0..i] that is also a suffix of it. This array is usually called
LPS (longest proper prefix which is also suffix).
pattern: A B A B C A B A B
index: 0 1 2 3 4 5 6 7 8
lps: 0 0 1 2 0 1 2 3 4
^
lps[8] = 4: "ABAB" is both a prefix and a suffix of "ABABCABAB",
so after a mismatch here we resume with 4 characters already matched.
def build_failure(pattern):
"""lps[i] = length of the longest proper prefix of pattern[:i+1]
that is also a suffix of it."""
lps = [0] * len(pattern)
length = 0 # length of the current matching prefix
i = 1
while i < len(pattern):
if pattern[i] == pattern[length]:
length += 1
lps[i] = length
i += 1
elif length > 0:
length = lps[length - 1] # fall back to the next shorter candidate
else:
lps[i] = 0
i += 1
return lps
The search
def kmp_search(text, pattern):
"""Return the start index of every occurrence of pattern in text."""
if not pattern:
return list(range(len(text) + 1))
lps = build_failure(pattern)
matches = []
i = j = 0 # i indexes text, j indexes pattern
while i < len(text):
if text[i] == pattern[j]:
i += 1
j += 1
if j == len(pattern):
matches.append(i - j)
j = lps[j - 1] # keep going: find overlapping matches too
elif j > 0:
j = lps[j - 1] # slide the pattern, do NOT rewind i
else:
i += 1
return matches
# Time: O(n + m) Space: O(m)
i never decreases, which is what guarantees linear time: each character of the text is
examined at most twice. Because the search continues after a match using
j = lps[j-1], KMP correctly reports overlapping occurrences — searching
for "AA" in "AAAA" yields 0, 1 and 2.
The failure function is useful on its own. The shortest repeating unit of a string s is
len(s) - lps[-1] when that divides len(s), which solves "is this string
built from a repeated substring?" in linear time.
Rabin-Karp
Compare hashes instead of characters. A rolling hash updates in O(1) as the window slides, so the whole scan is O(n + m) on average — and unlike the other algorithms here, it extends naturally to searching for many patterns at once, or to 2-D pattern matching.
def rabin_karp(text, pattern, base=256, mod=(1 << 61) - 1):
n, m = len(text), len(pattern)
if m == 0 or m > n:
return []
high = pow(base, m - 1, mod) # value of the leading digit's place
pattern_hash = text_hash = 0
for i in range(m): # hash the pattern and the first window
pattern_hash = (pattern_hash * base + ord(pattern[i])) % mod
text_hash = (text_hash * base + ord(text[i])) % mod
matches = []
for i in range(n - m + 1):
# Hash equality is necessary but not sufficient - always verify.
if pattern_hash == text_hash and text[i:i + m] == pattern:
matches.append(i)
if i < n - m: # roll: drop text[i], append text[i+m]
text_hash = ((text_hash - ord(text[i]) * high) * base
+ ord(text[i + m])) % mod
return matches
# Time: O(n + m) average, O(n * m) worst case (every window collides)
# Space: O(1)
Two things matter for correctness and speed. Always verify a hash match with a real comparison — equal hashes do not mean equal strings. And choose the modulus carefully: a small or predictable modulus lets an adversary construct input where every window collides, degrading the search to O(nm). This is the same hash-flooding concern that affects hash tables; randomising the base per run is the standard defence.
Boyer-Moore and Boyer-Moore-Horspool
Boyer-Moore matches the pattern right to left and, on a mismatch, uses the
offending text character to jump forward — often by the pattern's whole length. It is the only
algorithm here that is sublinear in practice: it can skip characters entirely without ever
looking at them. This is what makes it the basis of most real-world implementations, including GNU
grep and many memmem routines.
Horspool's simplification keeps only the bad-character rule, which is most of the benefit for a fraction of the complexity:
def bmh_search(text, pattern):
"""Boyer-Moore-Horspool."""
n, m = len(text), len(pattern)
if m == 0 or m > n:
return []
# For each character, how far we can safely shift if it is the mismatch.
# Characters absent from the pattern let us skip the full pattern length.
shift = {pattern[i]: m - 1 - i for i in range(m - 1)}
matches = []
i = 0
while i <= n - m:
if text[i:i + m] == pattern:
matches.append(i)
i += shift.get(text[i + m - 1], m)
return matches
# Time: O(n/m) best case, O(n * m) worst Space: O(alphabet)
Best case is genuinely O(n/m): searching a 20-character pattern in a text sharing none of its characters examines only every 20th character. Full Boyer-Moore adds a second heuristic (the good suffix rule), which bounds the worst case at O(n + m) at the cost of a more involved preprocessing step.
Which to use? Boyer-Moore for long patterns over large alphabets — searching
natural-language text. KMP when you need a guaranteed linear bound, when the alphabet is tiny (DNA),
or when the input arrives as a stream and cannot be rewound. Rabin-Karp for multiple patterns or
multi-dimensional matching. In practice, call your language's built-in find first; it
is usually a tuned hybrid.
Z-Algorithm
For each position i, z[i] is the length of the longest substring starting at i that is
also a prefix of the whole string. It carries the same information as KMP's failure function but is
often easier to reason about, and it solves several problems more directly.
def z_function(s):
n = len(s)
z = [0] * n
if n:
z[0] = n
l = r = 0 # [l, r) is the rightmost match found so far
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l]) # reuse what we already know
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1 # extend by brute force
if i + z[i] > r:
l, r = i, i + z[i]
return z
def z_search(text, pattern, sep="\x00"):
"""Pattern matching: build pattern + separator + text, then read off z."""
if not pattern:
return []
combined = pattern + sep + text # sep must not occur in either string
z = z_function(combined)
m = len(pattern)
return [i - m - 1 for i in range(m + 1, len(combined)) if z[i] >= m]
# Time: O(n + m) Space: O(n + m)
Common String Problems
Longest Palindromic Substring — Manacher's Algorithm
The obvious approach expands around each of the 2n − 1 possible centres in O(n²). Manacher's does it in O(n) by reusing palindromes already found: inside a known palindrome, the radius at a position mirrors the radius at its reflection.
def longest_palindrome(s):
if not s:
return ""
# Interleave with '#' so even- and odd-length palindromes are handled
# uniformly: "aba" -> "#a#b#a#", "abba" -> "#a#b#b#a#"
t = "#" + "#".join(s) + "#"
n = len(t)
p = [0] * n # p[i] = palindrome radius centred at i
centre = right = 0
for i in range(n):
if i < right:
p[i] = min(right - i, p[2 * centre - i]) # mirror position
while (i - p[i] - 1 >= 0 and i + p[i] + 1 < n
and t[i - p[i] - 1] == t[i + p[i] + 1]):
p[i] += 1
if i + p[i] > right:
centre, right = i, i + p[i]
k = max(range(n), key=lambda i: p[i])
start = (k - p[k]) // 2 # map back to the original string
return s[start:start + p[k]]
# Time: O(n) Space: O(n)
Longest Common Substring
Distinct from longest common subsequence — a substring must be contiguous. The DP recurrence differs accordingly: a mismatch resets the running length to 0 rather than carrying the best result forward.
def longest_common_substring(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
best, end = 0, 0
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
if dp[i][j] > best:
best, end = dp[i][j], i
# else: dp[i][j] stays 0 - contiguity is broken
return s1[end - best:end]
# Time: O(m * n) Space: O(m * n), reducible to O(n) with two rows.
# Suffix automata solve this in O(m + n) if you need it.
Anagram Detection
from collections import Counter
def is_anagram(a, b):
return Counter(a) == Counter(b) # O(n), beats sorting's O(n log n)
def group_anagrams(words):
"""Group words that are anagrams of one another."""
groups = {}
for w in words:
key = tuple(sorted(w)) # canonical form; a 26-tuple of
groups.setdefault(key, []).append(w) # counts is faster for long words
return list(groups.values())
String Compression (Run-Length Encoding)
def compress(s):
"""'aabcccccaaa' -> 'a2b1c5a3'. Returns the original if that is shorter."""
if not s:
return s
parts = [] # build a list, then join once:
count = 1 # repeated s += c would be O(n^2)
for i in range(1, len(s) + 1):
if i < len(s) and s[i] == s[i-1]:
count += 1
else:
parts.append(s[i-1] + str(count))
count = 1
compressed = "".join(parts)
return compressed if len(compressed) < len(s) else s
Related, and covered elsewhere on the site: Edit distance and longest common subsequence are both DP problems, and tries are the structure of choice for prefix matching and autocomplete.
Further Topics
- Aho-Corasick: searches for many patterns simultaneously in O(n + total pattern length + matches). It is a trie augmented with KMP-style failure links, and it is what intrusion-detection systems and virus scanners use to match thousands of signatures in one pass.
- Suffix arrays: all suffixes of a string in sorted order, buildable in O(n log n) or O(n). With an LCP array alongside, they answer substring queries, longest repeated substring, and longest common substring efficiently. Far more memory-friendly than suffix trees, which is why they dominate in practice.
- Suffix automaton: recognises every substring of a string, built online in O(n). The most powerful tool here, and the least widely known.
- Burrows-Wheeler Transform: a reversible permutation that clusters similar characters together, making text far more compressible. It is the core of
bzip2and of read aligners such as BWA and Bowtie. - Regular expressions: Thompson's construction compiles a regex to an NFA and matches in guaranteed O(nm). Note that most language runtimes (Python, Java, JavaScript, Perl) instead use backtracking engines, which support backreferences but can blow up exponentially — the cause of ReDoS vulnerabilities. Go and Rust use RE2-style automata specifically to avoid this.
- Approximate matching: the bitap (shift-or) algorithm handles fuzzy search with a bounded number of errors, using bit-parallelism to process a whole word at a time.
What's Next?
Now that you understand string algorithms, explore related topics:
- Encryption Algorithms - Hashing and cryptographic primitives
- Machine Learning Algorithms - ML algorithms and techniques
- Dynamic Programming - Edit distance and LCS are DP problems