Fast Levenshtein Distance Using a Trie
- title
- Fast Levenshtein Distance Using a Trie
- type
- summary
- summary
- How to find dictionary words within N edits of a query 300× faster by sharing Levenshtein table rows across trie prefixes
- tags
- algorithms, search, data-structures
- created
- 2026-04-16
- updated
- 2026-04-16
Steve Hanov's classic 2009 post on efficient fuzzy dictionary search. The problem: given a misspelled word, find all dictionary words within N edits. The naive approach is too slow for large dictionaries. The trie-based approach is 300× faster.
The naive approach
Compare the query against every word in the dictionary using the standard Levenshtein dynamic programming algorithm. For each word pair, fill an N×M table where each cell is the minimum of:
- Cell above + 1 (deletion)
- Cell to the left + 1 (insertion)
- Cell diagonally above-left + 0 or 1 (match or substitution)
Complexity: O(words × max_length²). For a 98,568-word dictionary: 4.5 seconds.
The insight
When comparing "food" against "care" and then "cars", the naive approach rebuilds the entire table. But "care" and "cars" share a prefix. The Levenshtein table rows for "c", "ca", "car" are identical — only the final row differs.
A trie stores words with shared prefixes collapsed into single paths. If you traverse the trie depth-first, you process words in exactly the order that maximizes row reuse.
The algorithm
Recursive DFS through the trie. Each call receives:
- The current trie node
- The letter at this node
- The target word
- The previous row (from the parent node)
- Results accumulator
- Maximum allowed edit distance
Each node computes one row:
def searchRecursive(node, letter, word, previousRow, results, maxCost):
currentRow = [previousRow[0] + 1]
for column in range(1, len(word) + 1):
insertCost = currentRow[column - 1] + 1
deleteCost = previousRow[column] + 1
replaceCost = previousRow[column - 1]
if word[column - 1] != letter:
replaceCost += 1
currentRow.append(min(insertCost, deleteCost, replaceCost))
# If this node completes a word and cost is acceptable
if node.word and currentRow[-1] <= maxCost:
results.append((node.word, currentRow[-1]))
# Early termination: if min(row) > maxCost, no word below can match
if min(currentRow) <= maxCost:
for letter, child in node.children.items():
searchRecursive(child, letter, word, currentRow, results, maxCost)
The early termination is key. If the minimum value in the current row exceeds maxCost, every word in this subtree is too far away. Prune the entire branch.
Performance
Same 98,568-word dictionary: 0.014 seconds. Over 300× faster.
New complexity bound: O(max_length × trie_nodes). The trie has fewer nodes than the dictionary has total characters because prefixes are shared.
Real-world application
The author used this for RhymeBrain. After importing Google's N-grams dataset (2.6 million words), queries still took only 19-50ms on a 1GHz netbook. No caching, no precomputation — just the trie and the algorithm.
Alternatives
Peter Norvig's spelling corrector — Generate all possible 1-edit mutations of the query and check dictionary membership. Fast for edit distance 1, but "breaks down quickly" for larger distances because mutations grow exponentially.
Levenshtein automata — Construct a regex/NFA matching all strings within N edits, then intersect with the dictionary. More complex to implement ("big honking gobs of code") but can be faster for very large dictionaries.
Memory
Tries are memory-hungry. The article references follow-up work on DAWG (Directed Acyclic Word Graph) structures that share suffixes as well as prefixes, dramatically reducing node count.
See also inverted-index for a different approach to search indexing, full-text-search-indexeddb for another fuzzy search implementation.