Loading content...
How different are two strings? Not in a philosophical sense, but in a precise, computable way that captures the effort required to transform one into the other?
The answer is Edit Distance, also known as Levenshtein Distance—the minimum number of single-character edits (insertions, deletions, or substitutions) needed to change one string into another. This deceptively simple metric underlies:
Edit distance is LeetCode problem #72, a classic DP problem that every serious engineer should master. But beyond the basic problem lie many fascinating variations with different edit operations, costs, and constraints.
By the end of this page, you will implement the classic edit distance algorithm, understand its variations (weighted edits, different allowed operations), explore space optimizations and path reconstruction, and see how edit distance connects to other string algorithms like LCS.
Problem Statement (LeetCode 72):
Given two strings word1 and word2, return the minimum number of operations required to convert word1 to word2. You have three operations:
Example:
word1 = "horse"
word2 = "ros"
Transformation:
horse → rorse (replace 'h' with 'r')
rorse → rose (delete 'r')
rose → ros (delete 'e')
Edit distance = 3
Historical Note:
This problem is named after Vladimir Levenshtein, who defined the metric in 1965. It's sometimes called "Levenshtein distance" in his honor. The algorithm we'll study was developed independently by Wagner and Fischer in 1974.
DP State Definition:
Let dp[i][j] = minimum edit distance to convert word1[0..i-1] to word2[0..j-1].
Using 1-indexed DP for clean base cases:
dp[i][0] = i (delete all i characters from word1)dp[0][j] = j (insert all j characters into empty string)The Recurrence:
At position (i, j), we consider the last character of each prefix:
Case 1: word1[i-1] == word2[j-1]
Characters match! No operation needed for this position.
dp[i][j] = dp[i-1][j-1]
Case 2: word1[i-1] != word2[j-1]
Characters differ. We must perform one of three operations:
Replace word1[i-1] with word2[j-1]: Now both end with the same character.
Delete word1[i-1]: Remove it and match word1[0..i-2] with word2[0..j-1].
Insert word2[j-1] after word1[i-1]: Now word1 ends with word2[j-1].
dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
What's confusing is that "insert into word1" is equivalent to "delete from word2" when thinking about the transformation. The key is to think of dp[i][j] as "what's the cost to align word1[0..i-1] with word2[0..j-1]?" Insert extends word1; delete shortens word1; replace changes a character.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
def min_distance(word1: str, word2: str) -> int: """ Compute the minimum edit distance (Levenshtein distance). Time Complexity: O(n * m) where n = len(word1), m = len(word2) Space Complexity: O(n * m) for the DP table Args: word1: Source string word2: Target string Returns: Minimum number of operations to transform word1 into word2 """ n, m = len(word1), len(word2) # Create DP table # dp[i][j] = min edits to convert word1[0..i-1] to word2[0..j-1] dp = [[0] * (m + 1) for _ in range(n + 1)] # Base cases: transforming to/from empty string for i in range(n + 1): dp[i][0] = i # Delete all characters for j in range(m + 1): dp[0][j] = j # Insert all characters # Fill DP table for i in range(1, n + 1): for j in range(1, m + 1): if word1[i - 1] == word2[j - 1]: # Characters match - no operation needed dp[i][j] = dp[i - 1][j - 1] else: # Take minimum of three operations dp[i][j] = 1 + min( dp[i - 1][j - 1], # Replace dp[i - 1][j], # Delete from word1 dp[i][j - 1] # Insert into word1 ) return dp[n][m] def min_distance_optimized(word1: str, word2: str) -> int: """ Space-optimized version using two rows. Time Complexity: O(n * m) Space Complexity: O(min(n, m)) - we use the shorter string for columns """ # Ensure word2 is the shorter one for space efficiency if len(word1) < len(word2): word1, word2 = word2, word1 n, m = len(word1), len(word2) # Use two rows: previous and current prev = list(range(m + 1)) # dp[i-1][*] curr = [0] * (m + 1) # dp[i][*] for i in range(1, n + 1): curr[0] = i # Base case: delete all from word1[0..i-1] for j in range(1, m + 1): if word1[i - 1] == word2[j - 1]: curr[j] = prev[j - 1] else: curr[j] = 1 + min( prev[j - 1], # Replace prev[j], # Delete curr[j - 1] # Insert ) # Swap rows prev, curr = curr, prev return prev[m] # Note: after swap, result is in prev def min_distance_single_row(word1: str, word2: str) -> int: """ Further optimized using single row. We need to save dp[i-1][j-1] before it's overwritten. Time: O(n * m) Space: O(m) """ n, m = len(word1), len(word2) dp = list(range(m + 1)) for i in range(1, n + 1): prev_diag = dp[0] # dp[i-1][0] dp[0] = i # dp[i][0] = i (base case) for j in range(1, m + 1): temp = dp[j] # Save dp[i-1][j] before overwriting if word1[i - 1] == word2[j - 1]: dp[j] = prev_diag else: dp[j] = 1 + min( prev_diag, # Replace (dp[i-1][j-1]) dp[j], # Delete (dp[i-1][j]) dp[j - 1] # Insert (dp[i][j-1]) ) prev_diag = temp return dp[m] # Demonstration with tracedef min_distance_with_trace(word1: str, word2: str) -> tuple[int, list[list[int]]]: """Return both the distance and the full DP table.""" n, m = len(word1), len(word2) dp = [[0] * (m + 1) for _ in range(n + 1)] for i in range(n + 1): dp[i][0] = i for j in range(m + 1): dp[0][j] = j for i in range(1, n + 1): for j in range(1, m + 1): if word1[i - 1] == word2[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) return dp[n][m], dp if __name__ == "__main__": examples = [ ("horse", "ros"), ("intention", "execution"), ("", "abc"), ("abc", "abc"), ("abc", "def"), ] for w1, w2 in examples: dist = min_distance(w1, w2) dist_opt = min_distance_optimized(w1, w2) print(f"edit_distance('{w1}', '{w2}') = {dist} (optimized: {dist_opt})")DP Table Visualization for "horse" → "ros":
"" r o s
"" 0 1 2 3
h 1 1 2 3
o 2 2 1 2
r 3 2 2 2
s 4 3 3 2
e 5 4 4 3
Reading the table:
Knowing the distance is useful, but often we need the actual sequence of edits. We can reconstruct the path by backtracking through the DP table.
Backtracking Strategy:
Starting from dp[n][m], we trace back to dp[0][0] by asking: "Which previous cell led to this value?"
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
from enum import Enumfrom dataclasses import dataclass class EditOp(Enum): """Types of edit operations.""" MATCH = "match" # Characters already match REPLACE = "replace" # Replace character DELETE = "delete" # Delete from word1 INSERT = "insert" # Insert into word1 @dataclassclass Edit: """Represents a single edit operation.""" operation: EditOp word1_idx: int # Index in word1 (or -1 for insert) word2_idx: int # Index in word2 (or -1 for delete) char_from: str = "" # Original character char_to: str = "" # New character def min_distance_with_path( word1: str, word2: str) -> tuple[int, list[Edit]]: """ Compute edit distance and return the sequence of operations. Returns: Tuple of (distance, list of Edit operations) """ n, m = len(word1), len(word2) # Build DP table dp = [[0] * (m + 1) for _ in range(n + 1)] for i in range(n + 1): dp[i][0] = i for j in range(m + 1): dp[0][j] = j for i in range(1, n + 1): for j in range(1, m + 1): if word1[i - 1] == word2[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) # Backtrack to find the operations operations = [] i, j = n, m while i > 0 or j > 0: if i > 0 and j > 0 and word1[i - 1] == word2[j - 1]: # Characters match - no operation operations.append(Edit( operation=EditOp.MATCH, word1_idx=i - 1, word2_idx=j - 1, char_from=word1[i - 1], char_to=word2[j - 1] )) i -= 1 j -= 1 elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1: # Replace operations.append(Edit( operation=EditOp.REPLACE, word1_idx=i - 1, word2_idx=j - 1, char_from=word1[i - 1], char_to=word2[j - 1] )) i -= 1 j -= 1 elif i > 0 and dp[i][j] == dp[i-1][j] + 1: # Delete from word1 operations.append(Edit( operation=EditOp.DELETE, word1_idx=i - 1, word2_idx=-1, char_from=word1[i - 1] )) i -= 1 elif j > 0 and dp[i][j] == dp[i][j-1] + 1: # Insert into word1 operations.append(Edit( operation=EditOp.INSERT, word1_idx=-1, word2_idx=j - 1, char_to=word2[j - 1] )) j -= 1 # Reverse to get operations in forward order operations.reverse() return dp[n][m], operations def apply_edits(word1: str, operations: list[Edit]) -> str: """ Apply edit operations to transform word1, showing each step. """ result = list(word1) offset = 0 # Track insertions/deletions affecting indices print(f"Starting: '{word1}'") for op in operations: if op.operation == EditOp.MATCH: continue # No change needed if op.operation == EditOp.REPLACE: idx = op.word1_idx + offset result[idx] = op.char_to print(f" Replace '{op.char_from}' at {idx} with '{op.char_to}' -> " f"'{''.join(result)}'") elif op.operation == EditOp.DELETE: idx = op.word1_idx + offset del result[idx] offset -= 1 print(f" Delete '{op.char_from}' at {idx} -> '{''.join(result)}'") elif op.operation == EditOp.INSERT: # Insert at position corresponding to word2_idx idx = op.word2_idx + offset result.insert(idx, op.char_to) offset += 1 print(f" Insert '{op.char_to}' at {idx} -> '{''.join(result)}'") return ''.join(result) # Demoif __name__ == "__main__": word1, word2 = "horse", "ros" distance, operations = min_distance_with_path(word1, word2) print(f"\nTransforming '{word1}' to '{word2}':") print(f"Distance: {distance}") print(f"\nOperations:") for op in operations: if op.operation != EditOp.MATCH: print(f" {op.operation.value}: {op.char_from or ''} -> {op.char_to or ''}") print(f"\nStep by step:") result = apply_edits(word1, operations) print(f"\nFinal: '{result}' (expected: '{word2}')")When multiple cells tie for the minimum, there are multiple optimal edit sequences. Our backtracking algorithm picks one deterministically (preferring replace, then delete, then insert), but other valid sequences may exist.
The classic Levenshtein distance is just one member of a family of string metrics. Understanding the variations deepens your understanding and prepares you for different problem constraints.
1. Weighted Edit Distance
Different operations have different costs. Common in NLP where insertions might be "cheaper" than substitutions.
cost(insert) = 1
cost(delete) = 1
cost(replace) = 2 # Penalize substitutions more heavily
2. Damerau-Levenshtein Distance
Adds transposition as a fourth operation (swap two adjacent characters). This models common typing errors better.
"ab" → "ba" (1 transposition, vs 2 operations in Levenshtein)
3. Longest Common Subsequence (LCS) Distance
Only insertions and deletions are allowed (no substitutions). The edit distance equals n + m - 2*LCS(s, t).
4. Hamming Distance
Only substitutions are allowed. Strings must have equal length. Counts positions where characters differ.
5. One Edit Distance (LeetCode 161)
Determine if two strings are exactly one edit apart. A simpler problem with O(n) solution.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
def weighted_edit_distance( word1: str, word2: str, insert_cost: int = 1, delete_cost: int = 1, replace_cost: int = 1) -> int: """ Edit distance with customizable operation costs. This allows modeling different types of errors. For example, in spell checking, substituting 'e' for 'a' might be cheaper than substituting 'e' for 'z' (nearby on keyboard). """ n, m = len(word1), len(word2) dp = [[0] * (m + 1) for _ in range(n + 1)] for i in range(n + 1): dp[i][0] = i * delete_cost for j in range(m + 1): dp[0][j] = j * insert_cost for i in range(1, n + 1): for j in range(1, m + 1): if word1[i - 1] == word2[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = min( dp[i - 1][j - 1] + replace_cost, dp[i - 1][j] + delete_cost, dp[i][j - 1] + insert_cost ) return dp[n][m] def damerau_levenshtein_distance(word1: str, word2: str) -> int: """ Damerau-Levenshtein: adds transposition of adjacent characters. Transposition: "ab" → "ba" in one operation This requires a 2D table where we also look at dp[i-2][j-2] when word1[i-1] == word2[j-2] and word1[i-2] == word2[j-1]. """ n, m = len(word1), len(word2) # Use defaultdict for cleaner boundary handling from collections import defaultdict dp = defaultdict(lambda: float('inf')) for i in range(-1, n + 1): dp[i, -1] = i + 1 for j in range(-1, m + 1): dp[-1, j] = j + 1 for i in range(n): for j in range(m): cost = 0 if word1[i] == word2[j] else 1 dp[i, j] = min( dp[i - 1, j] + 1, # Delete dp[i, j - 1] + 1, # Insert dp[i - 1, j - 1] + cost, # Replace/Match ) # Transposition if i > 0 and j > 0: if word1[i] == word2[j - 1] and word1[i - 1] == word2[j]: dp[i, j] = min(dp[i, j], dp[i - 2, j - 2] + 1) return dp[n - 1, m - 1] def is_one_edit_distance(s: str, t: str) -> bool: """ LeetCode 161: One Edit Distance Check if strings are exactly one edit apart. Time: O(n) Space: O(1) """ n, m = len(s), len(t) # Ensure s is shorter or equal if n > m: return is_one_edit_distance(t, s) # Length difference > 1 means more than one edit if m - n > 1: return False for i in range(n): if s[i] != t[i]: if n == m: # Replace: rest must match return s[i + 1:] == t[i + 1:] else: # Insert into s (or delete from t): s[i:] must match t[i+1:] return s[i:] == t[i + 1:] # All characters match; need exactly one insert at end return m == n + 1 def hamming_distance(s: str, t: str) -> int: """ Hamming distance: count positions where characters differ. Strings must have equal length. Only substitutions are counted. """ if len(s) != len(t): raise ValueError("Strings must have equal length for Hamming distance") return sum(c1 != c2 for c1, c2 in zip(s, t)) def lcs_based_distance(word1: str, word2: str) -> int: """ LCS-based distance: only insertions and deletions. Distance = |word1| + |word2| - 2 * LCS_length This is the minimum edits when substitution is not allowed. """ n, m = len(word1), len(word2) # Compute LCS length dp = [0] * (m + 1) for i in range(1, n + 1): prev = 0 for j in range(1, m + 1): temp = dp[j] if word1[i - 1] == word2[j - 1]: dp[j] = prev + 1 else: dp[j] = max(dp[j], dp[j - 1]) prev = temp lcs_length = dp[m] return n + m - 2 * lcs_length # Test all variationsif __name__ == "__main__": word1, word2 = "horse", "ros" print(f"Comparing '{word1}' and '{word2}':") print(f" Levenshtein: {weighted_edit_distance(word1, word2)}") print(f" Weighted (rep=2): {weighted_edit_distance(word1, word2, replace_cost=2)}") print(f" Damerau-Levenshtein: {damerau_levenshtein_distance(word1, word2)}") print(f" LCS-based: {lcs_based_distance(word1, word2)}") # Transposition example print(f"\nTransposition example 'ab' vs 'ba':") print(f" Levenshtein: {weighted_edit_distance('ab', 'ba')}") print(f" Damerau-Levenshtein: {damerau_levenshtein_distance('ab', 'ba')}") # One edit distance print(f"\nOne Edit Distance tests:") print(f" 'ab', 'acb': {is_one_edit_distance('ab', 'acb')}") # True (insert) print(f" 'ab', 'a': {is_one_edit_distance('ab', 'a')}") # True (delete) print(f" 'ab', 'ac': {is_one_edit_distance('ab', 'ac')}") # True (replace) print(f" 'ab', 'ab': {is_one_edit_distance('ab', 'ab')}") # False (same)| Variant | Operations | Use Case | Complexity |
|---|---|---|---|
| Levenshtein | Insert, Delete, Replace | General spelling, DNA | O(nm) |
| Damerau-Levenshtein |
| Typo correction | O(nm) |
| LCS-based | Insert, Delete only | Diff tools, version control | O(nm) |
| Hamming | Replace only (equal length) | Error correction codes | O(n) |
| Weighted | Custom costs per op | Domain-specific NLP | O(nm) |
Edit distance is one of the most practically applied algorithms in computer science.
Spell Checking and Correction:
When you type "teh" and your editor suggests "the", edit distance is at work. The spell checker:
Optimizations (like BK-trees) make this feasible for large dictionaries.
DNA Sequence Alignment:
In bioinformatics, comparing DNA sequences is fundamentally an edit distance problem. Researchers need to:
Variants like Needleman-Wunsch (global alignment) and Smith-Waterman (local alignment) extend edit distance with gap penalties.
Plagiarism Detection:
Plagiarism detectors compare submitted text against known sources. Since plagiarized text is often slightly modified (word substitutions, reordering), edit-distance-based similarity captures these subtle changes.
Diff Tools (Git, Unix diff):
When you run git diff, the output shows the minimum changes between file versions. This is computed using a variant of edit distance optimized for line-by-line comparison (the Myers diff algorithm).
Fuzzy Search:
Database systems and search engines support fuzzy matching—finding records that approximately match a query. This often uses edit distance with a threshold (e.g., "find all names within edit distance 2 of 'Smith'").
Mastering edit distance unlocks a family of related problems:
72. Edit Distance (Medium/Hard): The classic problem we've covered in depth.
161. One Edit Distance (Medium): Check if two strings are exactly one edit apart. Can be solved in O(n) without DP.
583. Delete Operation for Two Strings (Medium):
Minimum deletions to make two strings equal. This is n + m - 2*LCS.
712. Minimum ASCII Delete Sum for Two Strings (Medium): Instead of counting operations, minimize the sum of deleted character ASCII values. Weighted variant.
1035. Uncrossed Lines (Medium): Connect matching elements between two arrays without crossing lines. This is LCS in disguise.
1143. Longest Common Subsequence (Medium): The dual problem to edit distance. LCS + edit distance are intimately connected.
44. Wildcard Matching (Hard): Similar DP structure but with special character handling (* and ?).
10. Regular Expression Matching (Hard): Extends pattern matching with * meaning "zero or more of previous".
Edit distance (with only insert/delete) = n + m - 2*LCS. This means LCS algorithms can compute edit distance and vice versa. They're two views of the same underlying problem: how similar are these sequences?
Edit distance is a foundational algorithm that connects theoretical computer science to practical applications in search, biology, and natural language processing.
You've now completed Module 10: Common String Algorithm Patterns. You've mastered anagram detection, string compression, minimum window substring, distinct subsequences, and edit distance—a comprehensive toolkit for string manipulation problems. These patterns appear constantly in interviews and production systems, and your deep understanding will serve you well throughout your career.