A trie is a tree data structure where each node represents a character of a string. The path from The root to any node spells out a prefix, and nodes marked as “end of word” represent complete Strings in the set.
__slots__ = ( " children', 'is_end', 'word')
Trie (prefix tree) for string storage and retrieval.
Insert: O(m) where m = length of word
Space: O(ALPHABET_SIZE * total_characters)
if ch not in node.children:
node.children[ch] = TrieNode()
if ch not in node.children:
def starts_with ( self , prefix ):
if ch not in node.children:
def _delete ( node , word , depth ):
return len (node.children) == 0
if ch not in node.children:
should_delete = _delete(node.children[ch], word, depth + 1 )
return len (node.children) == 0 and not node.is_end
_delete( self .root, word, 0 )
def words_with_prefix ( self , prefix ):
"""Collect all words with given prefix. Time: O(m + k * L) where k = results."""
if ch not in node.children:
for ch, child in n.children.items():
graph TD
ROOT["root"] --> A["a"]
A --> P["p"]
A --> PP["pp"]
A --> D["d"]
P --> P2["p"]
P --> P3["p"]
P --> L["l"]
P2 --> L2["l"]
P2 --> E["e"]
P3 --> E2["e"]
L --> E3["e"]
D --> D2["d"]
D --> D3["d"]
D2 --> E4["e"]
D3 --> E5["e"]
style ROOT fill:#95a5a6,color:#fff
style P2 fill:#e74c3c,color:#fff
style E fill:#e74c3c,color:#fff
style L2 fill:#e74c3c,color:#fff
style E4 fill:#e74c3c,color:#fff
style E5 fill:#e74c3c,color:#fff Operation Time Space Insert O ( m ) O(m) O ( m ) O ( 1 ) O(1) O ( 1 ) additional nodesSearch O ( m ) O(m) O ( m ) O ( 1 ) O(1) O ( 1 ) Delete O ( m ) O(m) O ( m ) O ( 1 ) O(1) O ( 1 ) freed nodesStartsWith O ( m ) O(m) O ( m ) O ( 1 ) O(1) O ( 1 ) Prefix search O ( m + k L ) O(m + kL) O ( m + k L ) O ( k L ) O(kL) O ( k L ) for results
Where m m m is the word length, k k k is the number of results, and L L L is the average result length.
Prefixes), this is $\sum |word_i| + 1$. The space can be reduced using a radix tree (compressed Trie) which merges chains of single-child nodes.A compressed trie merges chains of nodes with only one child into single edges labelled with Substrings. This reduces the number of nodes and eliminates unnecessary internal nodes.
def __init__ ( self , label = "" ):
Compressed trie (radix tree / Patricia trie).
Insert: O(m^2) worst case, O(m) amortised
Space: O(n) nodes where n = number of words (in the worst case)
if ch not in node.children:
node.children[ch] = RadixNode(word[i : ])
node.children[ch].is_end = True
node.children[ch].word = word
child = node.children[ch]
while j < len (label) and i + j < len (word) and label[j] == word[i + j]:
split = RadixNode(suffix)
split.children = child.children
split.is_end = child.is_end
child.children[remaining[ 0 ]] = RadixNode(remaining)
child.children[remaining[ 0 ]].is_end = True
child.children[remaining[ 0 ]].word = word
if ch not in node.children:
child = node.children[ch]
if word[i : i + len (label)] != label:
A suffix trie of a string S S S of length n n n contains all suffixes of S S S . It has O ( n 2 ) O(n^2) O ( n 2 ) nodes (in The worst case), which is too large for practical use.
Suffix trie — contains all suffixes of a string.
Construction: O(n^2) time and space
Pattern search: O(m) where m = pattern length
def __init__ ( self , text ):
for i in range ( len (text)):
for j in range (i, len (text)):
if ch not in node.children:
node.children[ch] = TrieNode()
def search ( self , pattern ):
"""Check if pattern is a substring. O(m)."""
if ch not in node.children:
A suffix tree is a compressed suffix trie. It has at most 2 n − 1 2n - 1 2 n − 1 nodes for a string of length n n n (including n n n leaves, one per suffix).
Property Value Number of nodes O ( n ) O(n) O ( n ) Construction O ( n ) O(n) O ( n ) (Ukkonen’s algorithm)Space O ( n ) O(n) O ( n ) Substring search O ( m ) O(m) O ( m ) where m m m = pattern lengthLongest repeated substring Find deepest internal node Longest common substring Generalised suffix tree
Ukkonen’s algorithm builds the suffix tree in O ( n ) O(n) O ( n ) time by processing the string left to right, One character at a time. It maintains an implicit suffix tree during construction and uses suffix Links to efficiently extend all suffixes.
The key ideas:
Implicit suffix tree : during construction, suffixes may end in the middle of an edgeSuffix links : each internal node has a link to the node representing its longest proper suffixRule 1 / Rule 2 extension : when adding character S [ i ] S[i] S [ i ] Extend all suffixes. Rule 1 applies when the extension is trivial (character already exists on the current edge); Rule 2 applies when a new leaf must be created Suffix tree using Ukkonen's algorithm.
def __init__ ( self , text ):
self .root = SuffixTreeNode()
self .root.suffix_link = self .root
self .active_node = self .root
while self .remaining > 0 :
if self .active_length == 0 :
if self .text[ self .active_edge] not in self .active_node.children:
leaf.suffix_index = pos - self .remaining + 1
self .active_node.children[ self .text[ self .active_edge]] = leaf
last_new_node.suffix_link = self .active_node
next_node = self .active_node.children[ self .text[ self .active_edge]]
edge_len = self ._edge_length(next_node)
if self .active_length >= edge_len:
self .active_edge += edge_len
self .active_length -= edge_len
self .active_node = next_node
if self .text[next_node.start + self .active_length] == self .text[pos]:
last_new_node.suffix_link = self .active_node
split.start = next_node.start
split.end = next_node.start + self .active_length - 1
self .active_node.children[ self .text[ self .active_edge]] = split
leaf.suffix_index = pos - self .remaining + 1
split.children[ self .text[pos]] = leaf
next_node.start += self .active_length
split.children[ self .text[next_node.start]] = next_node
last_new_node.suffix_link = split
if self .active_node == self .root and self .active_length > 0 :
self .active_edge = pos - self .remaining + 1
elif self .active_node != self .root:
self .active_node = self .active_node.suffix_link
def _edge_length ( self , node ):
return node.end - node.start + 1
def search ( self , pattern ):
"""Check if pattern exists as a substring. O(m)."""
if pattern[i] not in node.children:
child = node.children[pattern[i]]
while j <= min (child.end, child.start + len (pattern) - i - 1 ):
if self .text[j] != pattern[i]:
A suffix array SA of a string S S S of length n n n is a permutation of { 0 , 1 , … , n − 1 } \{0, 1, \ldots, n-1\} { 0 , 1 , … , n − 1 } such That S [ S A [ 0 ] : ] < S [ S A [ 1 ] : ] < ⋯ < S [ S A [ n − 1 ] : ] S[SA[0]:] \lt S[SA[1]:] \lt \cdots \lt S[SA[n-1]:] S [ S A [ 0 ] : ] < S [ S A [ 1 ] : ] < ⋯ < S [ S A [ n − 1 ] : ] .
def build_suffix_array ( s ):
Build suffix array using the prefix doubling algorithm.
rank = [ ord (c) for c in s]
return (rank[i], rank[i + k] if i + k < n else - 1 )
tmp[sa[i]] = tmp[sa[i - 1 ]]
if sort_key(sa[i]) != sort_key(sa[i - 1 ]):
if rank[sa[n - 1 ]] == n - 1 :
The Longest Common Prefix (LCP) array stores the length of the longest common prefix between Consecutive suffixes in the suffix array. LCP[i] = lcp(S[SA[i]:], S[SA[i-1]:]).
def build_lcp_array ( s , sa ):
Build LCP array using Kasai's algorithm.
while i + h < n and j + h < n and s[i + h] == s[j + h]:
def suffix_array_search ( s , sa , pattern ):
Binary search for pattern in suffix array.
Time: O(m log n) where m = pattern length
Returns: (first_occurrence, last_occurrence) or (-1, -1)
while j < m and idx + j < n:
if pattern[j] < s[idx + j]:
if pattern[j] > s[idx + j]:
return 0 if j == m else - 1
while left > 0 and compare(sa[left - 1 ]) == 0 :
while right < n - 1 and compare(sa[right + 1 ]) == 0 :
Memory (an array of integers vs a tree of objects) and are easier to implement. The LCP array Enables efficient computation of longest common substrings and other string problems.Aho-Corasick finds all occurrences of a set of patterns in a text simultaneously. It builds an Automaton from a trie augmented with failure links.
The failure link of a node points to the longest proper suffix of the current path that is also a Prefix of some pattern. This is analogous to the KMP failure function but for multiple patterns.
from collections import deque
self .pattern_indices = []
Multi-pattern string matching using Aho-Corasick.
Build: O(total_pattern_length)
Search: O(text_length + total_matches)
Space: O(total_pattern_length * ALPHABET_SIZE)
self .root = AhoCorasickNode()
def add_pattern ( self , pattern , pattern_idx ):
if ch not in node.children:
node.children[ch] = AhoCorasickNode()
node.pattern_indices.append(pattern_idx)
"""Build failure links using BFS. O(total_pattern_length)."""
for ch, child in self .root.children.items():
self .root.fail = self .root
for ch, child in curr.children.items():
while fail and ch not in fail.children:
child.fail = fail.children[ch] if fail and ch in fail.children else self .root
child.output = child.pattern_indices + child.fail.output
Find all pattern occurrences in text.
Time: O(text_length + total_matches)
Returns list of (end_index, pattern_indices)
for i, ch in enumerate (text):
while node and ch not in node.children:
results.append((i, list (node.output)))
KMP is a single-pattern matching algorithm that achieves O ( n + m ) O(n + m) O ( n + m ) time by preprocessing the Pattern to compute a failure function.
The failure function pi[i] is the length of the longest proper prefix of pattern[0:i+1] that is Also a suffix of pattern[0:i+1].
def kmp_failure ( pattern ):
Compute KMP failure (prefix) function.
Time: O(m) where m = len(pattern)
while j > 0 and pattern[i] != pattern[j]:
if pattern[i] == pattern[j]:
def kmp_search ( text , pattern ):
Time: O(n + m) where n = len(text), m = len(pattern)
Space: O(m) for the failure function
Returns list of starting indices of matches.
return list ( range ( len (text) + 1 ))
n, m = len (text), len (pattern)
pi = kmp_failure(pattern)
while j > 0 and text[i] != pattern[j]:
if text[i] == pattern[j]:
matches.append(i - m + 1 )
The key invariant: after processing text[i]The variable j equals the length of the longest Prefix of pattern that is a suffix of text[0:i+1]. When j == mWe have found a complete match Ending at position i. The failure function ensures that we never backtrack in the text — each Character of the text is examined at most once, giving O ( n ) O(n) O ( n ) time for the search phase plus O ( m ) O(m) O ( m ) For preprocessing.
Rabin-Karp uses hashing to find pattern matches. It computes a rolling hash of the text and compares It with the hash of the pattern.
h ( s [ i . . j ] ) = ( ∑ k = i j s [ k ] ⋅ p j − k ) m o d q h(s[i..j]) = \left(\sum_{k=i}^{j} s[k] \cdot p^{j-k}\right) \bmod q h ( s [ i .. j ]) = ( ∑ k = i j s [ k ] ⋅ p j − k ) mod q
When sliding the window by one position:
h ( s [ i + 1.. j + 1 ] ) = ( h ( s [ i . . j ] ) − s [ i ] ⋅ p m − 1 ) ⋅ p + s [ j + 1 ] m o d q h(s[i+1..j+1]) = (h(s[i..j]) - s[i] \cdot p^{m-1}) \cdot p + s[j+1] \bmod q h ( s [ i + 1.. j + 1 ]) = ( h ( s [ i .. j ]) − s [ i ] ⋅ p m − 1 ) ⋅ p + s [ j + 1 ] mod q
def rabin_karp_search ( text , pattern , base = 256 , mod = 10 ** 9 + 7 ):
Rabin-Karp string matching.
Time: O(n + m) average, O(nm) worst case
n, m = len (text), len (pattern)
return list ( range (n + 1 ))
base_pow_m = pow (base, m - 1 , mod)
pattern_hash = (pattern_hash * base + ord (pattern[i])) % mod
window_hash = (window_hash * base + ord (text[i])) % mod
for i in range (n - m + 1 ):
if window_hash == pattern_hash:
if text[i : i + m] == pattern:
window_hash = (window_hash - ord (text[i]) * base_pow_m) % mod
window_hash = (window_hash * base + ord (text[i + m])) % mod
def rabin_karp_double_hash ( text , pattern ):
Rabin-Karp with double hashing to reduce false positives.
n, m = len (text), len (pattern)
def compute_hashes ( s , mod ):
h = (h * BASE + ord (c)) % mod
p1, p2 = compute_hashes(pattern, MOD1 ), compute_hashes(pattern, MOD2 )
t1, t2 = compute_hashes(text[ : m], MOD1 ), compute_hashes(text[ : m], MOD2 )
base_pow_m1 = pow ( BASE , m - 1 , MOD1 )
base_pow_m2 = pow ( BASE , m - 1 , MOD2 )
for i in range (n - m + 1 ):
if t1 == p1 and t2 == p2:
if text[i : i + m] == pattern:
t1 = (t1 - ord (text[i]) * base_pow_m1) % MOD1
t1 = (t1 * BASE + ord (text[i + m])) % MOD1
t2 = (t2 - ord (text[i]) * base_pow_m2) % MOD2
t2 = (t2 * BASE + ord (text[i + m])) % MOD2
Boyer-Moore is often the fastest string matching algorithm in practice because it skips sections of The text that cannot possibly match.
When a mismatch occurs at position j j j of the pattern with character c c c in the text, shift the Pattern so that the rightmost occurrence of c c c in pattern[0:j] (if any) aligns with the text Character.
When a mismatch occurs after a partial match of length k k k Shift the pattern so that the next Occurrence of the suffix (or a prefix of it) aligns with the matched portion of the text.
def boyer_moore_search ( text , pattern ):
Boyer-Moore string matching with bad character rule.
Time: O(nm) worst case, O(n/m) best case (sublinear!)
Space: O(ALPHABET_SIZE + m)
n, m = len (text), len (pattern)
return list ( range (n + 1 ))
while j >= 0 and pattern[j] == text[i + j]:
shift = bad_char.get(text[i + j], - 1 )
average (it examines fewer than $n$ characters of the text). For guaranteed $O(n)$ worst case, Use the Boyer-Moore-Horspool variant or KMP.h ( s ) = ( ∑ i = 0 n − 1 s [ i ] ⋅ p i ) m o d m h(s) = \left(\sum_{i=0}^{n-1} s[i] \cdot p^i\right) \bmod m h ( s ) = ( ∑ i = 0 n − 1 s [ i ] ⋅ p i ) mod m
The hash of a substring can be computed from prefix hashes:
h ( s [ l . . r ] ) = ( h ( r + 1 ) − h ( l ) ⋅ p r − l + 1 ) m o d m h(s[l..r]) = (h(r+1) - h(l) \cdot p^{r-l+1}) \bmod m h ( s [ l .. r ]) = ( h ( r + 1 ) − h ( l ) ⋅ p r − l + 1 ) mod m
Polynomial rolling hash for substring hashing.
Query substring hash: O(1)
def __init__ ( self , s , base = 131 , mod = 2 ** 64 ):
self .prefix = [ 0 ] * ( self .n + 1 )
self .power = [ 1 ] * ( self .n + 1 )
self .prefix[i + 1 ] = ( self .prefix[i] * base + ord (s[i])) % mod
self .power[i + 1 ] = ( self .power[i] * base) % mod
"""Hash of s[l:r] (0-indexed, exclusive r). O(1)."""
return ( self .prefix[r] - self .prefix[l] * self .power[r - l]) % self .mod
h = (h * self .base + ord (c)) % self .mod
def longest_common_substring ( s1 , s2 ):
Find longest common substring using suffix array.
Time: O(n log n) where n = len(s1) + len(s2)
combined = s1 + ' # ' + s2 + ' $ '
sa = build_suffix_array(combined)
lcp = build_lcp_array(combined, sa)
for i in range ( 1 , len (sa)):
in_diff = (s1_pos < len (s1)) != (s1_prev < len (s1))
if in_diff and lcp[i] > max_len:
pos = min (s1_pos, s1_prev)
return combined[pos : pos + max_len] if max_len > 0 else ""
Manacher’s algorithm finds the longest palindromic substring in O ( n ) O(n) O ( n ) time.
Find longest palindromic substring.
Returns (longest_palindrome, start_index, length)
t = ' # ' + ' # ' .join(s) + ' # '
p[i] = min (right - i, p[mirror])
while a < n and b >= 0 and t[a] == t[b]:
center_idx = p.index(max_len)
start = (center_idx - max_len) // 2
return (s[start : start + max_len], start, max_len)
Algorithm Preprocessing Search Time Worst Case Sublinear? Multi-pattern? Naive O ( 1 ) O(1) O ( 1 ) O ( n m ) O(nm) O ( nm ) O ( n m ) O(nm) O ( nm ) No No KMP O ( m ) O(m) O ( m ) O ( n ) O(n) O ( n ) O ( n ) O(n) O ( n ) No No Rabin-Karp O ( m ) O(m) O ( m ) O ( n + m ) O(n+m) O ( n + m ) avgO ( n m ) O(nm) O ( nm ) No Yes (simple) Boyer-Moore O ( m + σ ) O(m+\sigma) O ( m + σ ) O ( n m ) O(nm) O ( nm ) worst, sublinear avgO ( n m ) O(nm) O ( nm ) Yes No Aho-Corasick O ( k m ) O(km) O ( k m ) O ( n + z ) O(n + z) O ( n + z ) O ( n + z ) O(n + z) O ( n + z ) No Yes Suffix Array O ( n log n ) O(n \log n) O ( n log n ) O ( m log n ) O(m \log n) O ( m log n ) O ( m log n ) O(m \log n) O ( m log n ) No Yes
Where n n n = text length, m m m = pattern length, k k k = number of patterns, z z z = number of matches, σ \sigma σ = alphabet size.
Autocomplete : trie with frequency ranking, prefix search in O ( m ) O(m) O ( m ) Spell check : trie for dictionary lookup, edit distance for suggestionsDNA sequencing : suffix arrays/tries for genome alignmentIP routing : longest prefix match using a trieIntrusion detection : Aho-Corasick for multi-pattern matching on network trafficCode search : suffix arrays for fast substring search in large codebasesCompression : Lempel-Ziv uses suffix structures for repeated substring detectionA basic trie for English words with 26 children per node uses about 26 × 8 = 208 26 \times 8 = 208 26 × 8 = 208 bytes per Node (pointer size). For 1 million words averaging 10 characters, this is about 10 million nodes Consuming ~2 GB. Use a radix tree (compressed trie) or a sorted array of words with binary search For memory-constrained applications.
When building a suffix array, remember that rank[i] gives the position of suffix starting at i In the sorted order. The LCP array is indexed by the suffix array: LCP[j] gives the LCP between Suffixes at SA[j] and SA[j-1]Not between suffixes starting at j and j-1.
A single hash function has collision probability 1 / m 1/m 1/ m per comparison. For large texts, this can Lead to many false positives, each requiring an O ( m ) O(m) O ( m ) string comparison. Use double hashing (two Independent moduli) to reduce the collision probability to approximately 1 / ( m 1 ⋅ m 2 ) 1/(m_1 \cdot m_2) 1/ ( m 1 ⋅ m 2 ) Or use A 64-bit hash (which effectively eliminates collisions in practice).
The failure function for the pattern "aaaa" is [0, 1, 2, 3]Which means KMP never “skips ahead” For this pattern — it degrades to O ( n m ) O(nm) O ( nm ) in terms of character comparisons. This is correct but Slow. For patterns with many repeated characters, the Z-algorithm may be more intuitive.
Manacher’s algorithm works on a transformed string where each character is separated by #. The Mapping from the transformed index i to the original index is (i - 1) // 2. Forgetting this Mapping produces incorrect results.
If your trie keys are Unicode strings, the children dictionary can become very large (over 1 million Possible code points). Consider normalising the input (NFKC/NFKD), lowercasing, or using an array Only for the ASCII subset with a dictionary fallback for other characters.
If multiple patterns share suffixes, the output list at a node includes outputs from its failure Link. When counting matches, ensure you count each pattern index separately and do not double-count Patterns that appear in both the node’s direct output and its failure chain output.
Ukkonen’s algorithm is notoriously difficult to implement correctly. Common bugs include: incorrect Suffix link updates, edge cases for the first few characters, and handling the “active point” Transitions when active_length exceeds an edge length. Consider using a suffix array + LCP array Instead unless you specifically need the suffix tree structure.
This topic covers the mathematical techniques and concepts related to tries and string algorithms, including key theorems, methods, and problem-solving approaches.
Key concepts include:
complex number arithmetic Argand diagrams modulus and argument De Moivre’s theorem roots of complex numbers Regular practice with a variety of question types is essential to build fluency and confidence in applying these mathematical techniques.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Binary Search Trees — Tries and BSTs both provide O(log n) or O(L) lookup; tries are optimised for prefix-based string operations.Advanced Data Structures — Suffix trees and Aho-Corasick extend trie concepts to more complex string matching problems.Graph Algorithms — Pattern matching in strings can be modelled as graph traversal problems.Dynamic Programming — String DP problems like edit distance and longest common subsequence relate to trie-based solutions.A trie is a tree where each edge represents a character, so looking up a word of length L takes O(L) time regardless of how many words are in the dictionary. This prefix-based structure makes tries ideal for autocomplete, spell checking, and IP routing — anywhere you need to match by prefix rather than exact value. The trade-off is memory: a naive trie with one pointer per character per node wastes space. Radix trees (compressed tries) solve this by collapsing chains of single-child nodes into single edges labeled with multi-character strings, reducing both memory and traversal steps.
Suffix structures (suffix tries, suffix trees, suffix arrays) take a different approach: instead of storing a dictionary of known words, they index every suffix of a single text. A suffix tree for a string of length n has O(n) nodes and enables O(m) substring search, longest common substring, and many other queries. Ukkonen’s algorithm builds it in O(n) time online (processing characters left to right). Suffix arrays are a space-efficient alternative: just an array of sorted suffix indices, plus an LCP array, achieving the same queries with much less memory and better cache performance.
The string matching algorithms form a hierarchy of generality and efficiency. Naive O(nm) search is simple but slow. KMP achieves O(n) by preprocessing the pattern to avoid redundant comparisons — when a mismatch occurs, it uses knowledge of the pattern’s structure to skip ahead. Boyer-Moore is often faster in practice because it skips from right to left, examining fewer characters on average. Aho-Corasick generalizes KMP to multiple patterns simultaneously, building an automaton from a trie with failure links. Rabin-Karp uses rolling hashes to compare substrings in O(1), trading worst-case guarantees for simplicity and multi-pattern support.