Loading content...
Knowing the edit distance between two strings is valuable—but it's often not enough. In real applications, we need to know how to transform one string into another: what specific operations to perform, in what order, at what positions.
This is the difference between saying "these strings differ by 5 edits" and actually producing "delete position 0, substitute position 2, insert 'x' at position 4..." The DP table contains all the information we need; we just have to extract it through a process called backtracking or reconstruction.
This page covers the construction of the DP table in detail, space optimization techniques, and most importantly, how to trace back through the table to recover the optimal edit sequence—a skill essential for any practical application of edit distance.
By the end of this page, you will understand the table filling process at a deep level, be able to implement O(n) space optimization, visualize the table as an alignment between strings, and reconstruct the complete sequence of edit operations from a filled table.
Before diving into construction, let's understand the structure of our DP table in precise detail.
Table Dimensions:
For strings s of length m and t of length n, we create a table of size (m+1) × (n+1). The extra row and column account for the empty string prefixes.
Cell Semantics:
s[0:i] or s₀s₁...sᵢ₋₁t[0:j] or t₀t₁...tⱼ₋₁dp[i][j] stores the edit distance between these two prefixesdp[m][n]—the distance between the complete stringsCoordinate System Convention:
There are two common conventions for labeling the table. We'll use the 0-indexed convention where:
t[0] t[1] t[2] ... t[n-1]
j=1 j=2 j=3 j=n
┌─────┬─────┬─────┬─────┬───┬─────┐
│ ε │ │ │ │ │ │ j=0 (empty prefix of t)
i=0 │ │ │ │ │ │ │
├─────┼─────┼─────┼─────┼───┼─────┤
s[0]│ │ │ │ │ │ │ i=1
i=1 │ │ │ │ │ │ │
├─────┼─────┼─────┼─────┼───┼─────┤
s[1]│ │ │ │ │ │ │ i=2
i=2 │ │ │ │ │ │ │
├─────┼─────┼─────┼─────┼───┼─────┤
... │ │ │ │ │ │ │
├─────┼─────┼─────┼─────┼───┼─────┤
s[m-1] │ │ │ │ │ * │ i=m
i=m │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴───┴─────┘
↑
answer
Important: When accessing string characters, use 0-based indexing: s[i-1] for row i, t[j-1] for column j. This is a common source of off-by-one errors!
The most common bug in edit distance implementations is confusing table indices with string indices. Remember: dp[i][j] compares s[0:i] with t[0:j], so when checking characters for a match, you compare s[i-1] with t[j-1], NOT s[i] with t[j]. Double-check your indexing!
The order in which we fill the table matters—we must compute each cell only after all its dependencies are available.
Dependency Structure:
Cell dp[i][j] depends on:
dp[i-1][j-1] (diagonal, for match/substitute)dp[i-1][j] (above, for delete)dp[i][j-1] (left, for insert)This means when we compute dp[i][j], these three cells must already be filled.
Valid Filling Orders:
1. Row by row, left to right:
for i = 0 to m:
for j = 0 to n:
compute dp[i][j]
Most common; cache-friendly for row-major storage.
2. Column by column, top to bottom:
for j = 0 to n:
for i = 0 to m:
compute dp[i][j]
Equivalent correctness; different cache behavior.
3. Diagonal waves:
for d = 0 to m+n:
for cells on anti-diagonal d:
compute dp[i][j]
Useful for parallelization.
Invalid Filling Orders:
Right to left or bottom to top: Would try to use cells that haven't been computed yet.
Random order: Might access uninitialized cells.
Visual of dependencies:
j-1 j
┌─────┬─────┐
i-1 │DIAG │ UP │
│ ↘ │ ↓ │
├─────┼─────┤
i │LEFT │ ? │
│ → │ │
└─────┴─────┘
All three arrows point TO the current cell FROM cells that must be computed first.
In most programming languages, 2D arrays are stored in row-major order, meaning consecutive elements in a row are adjacent in memory. Row-by-row, left-to-right filling takes advantage of CPU cache by accessing memory sequentially. This can make a significant performance difference for large strings.
The standard DP solution uses O(m×n) space. But looking at the dependencies, we can do much better.
Key Insight: Each cell only depends on the current row and the previous row. We never look back more than one row. This means we only need to keep two rows in memory at a time!
Two-Row Optimization:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
def edit_distance_optimized(s: str, t: str) -> int: """ Space-optimized edit distance using only O(n) space. We keep only two rows: previous and current. Time: O(m * n), Space: O(min(m, n)) """ # Ensure s is the longer string for better space efficiency if len(s) < len(t): s, t = t, s # Swap so we iterate over shorter dimension m, n = len(s), len(t) # Only need two rows of size (n+1) prev = list(range(n + 1)) # Previous row (dp[i-1]) curr = [0] * (n + 1) # Current row (dp[i]) for i in range(1, m + 1): curr[0] = i # Base case: delete all s[0:i] for j in range(1, n + 1): if s[i - 1] == t[j - 1]: curr[j] = prev[j - 1] # Match: diagonal else: curr[j] = 1 + min( curr[j - 1], # Insert (left in current row) prev[j], # Delete (above in previous row) prev[j - 1] # Replace (diagonal in previous row) ) # Swap rows: current becomes previous for next iteration prev, curr = curr, prev # After loop, answer is in prev (due to final swap) return prev[n] # Even more optimized: single row with a saved variabledef edit_distance_single_row(s: str, t: str) -> int: """ Edit distance using O(n) space with a single row. We save the diagonal value before overwriting. Time: O(m * n), Space: O(min(m, n)) """ if len(s) < len(t): s, t = t, s m, n = len(s), len(t) dp = list(range(n + 1)) # Single row for i in range(1, m + 1): diagonal = dp[0] # Save dp[i-1][0] before overwriting dp[0] = i # dp[i][0] = i deletions for j in range(1, n + 1): temp = dp[j] # Save dp[i-1][j] before overwriting if s[i - 1] == t[j - 1]: dp[j] = diagonal else: dp[j] = 1 + min( dp[j - 1], # Insert (left, already updated to dp[i][j-1]) dp[j], # Delete (above, still dp[i-1][j]) diagonal # Replace (diagonal was dp[i-1][j-1]) ) diagonal = temp # Update diagonal for next j return dp[n] # Example usageprint(edit_distance_optimized("kitten", "sitting")) # Output: 3print(edit_distance_single_row("saturday", "sunday")) # Output: 3The space-optimized versions only compute the distance—they don't store the full table. This means we CANNOT do reconstruction (backtracking) to find the actual edit sequence. If you need the operations, not just the distance, you must keep the full O(m×n) table or use a more sophisticated technique like Hirschberg's algorithm (which achieves O(n) space with reconstruction, but is more complex).
With the full table computed, we can backtrack from dp[m][n] to dp[0][0] to recover the sequence of operations. At each cell, we determine which neighboring cell we came from, which tells us what operation was performed.
The Backtracking Process:
dp[m][n](i, j), determine which predecessor gave the minimum value:
s[i-1] == t[j-1] and dp[i][j] == dp[i-1][j-1]: Match, move to (i-1, j-1)dp[i][j] == dp[i-1][j-1] + 1: Substitute, move to (i-1, j-1)dp[i][j] == dp[i-1][j] + 1: Delete, move to (i-1, j)dp[i][j] == dp[i][j-1] + 1: Insert, move to (i, j-1)123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
from dataclasses import dataclassfrom typing import Literal @dataclassclass EditOperation: """Represents a single edit operation.""" type: Literal["match", "insert", "delete", "replace"] position_s: int # Position in source string position_t: int # Position in target string char_s: str = "" # Character from source (for delete/replace) char_t: str = "" # Character in target (for insert/replace) def __str__(self): if self.type == "match": return f"Match '{self.char_s}' at position {self.position_s}" elif self.type == "insert": return f"Insert '{self.char_t}' at position {self.position_s}" elif self.type == "delete": return f"Delete '{self.char_s}' at position {self.position_s}" else: # replace return f"Replace '{self.char_s}' with '{self.char_t}' at position {self.position_s}" def edit_distance_with_reconstruction(s: str, t: str) -> tuple[int, list[EditOperation]]: """ Compute edit distance AND return the sequence of operations. Returns: (distance, list of EditOperation objects) """ m, n = len(s), len(t) # Build the full DP table dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): if s[i - 1] == t[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = 1 + min( dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1] ) # Backtrack to find operations operations = [] i, j = m, n while i > 0 or j > 0: if i > 0 and j > 0 and s[i - 1] == t[j - 1]: # Characters match - no operation needed operations.append(EditOperation( type="match", position_s=i - 1, position_t=j - 1, char_s=s[i - 1], char_t=t[j - 1] )) i -= 1 j -= 1 elif i > 0 and j > 0 and dp[i][j] == dp[i - 1][j - 1] + 1: # Substitution operations.append(EditOperation( type="replace", position_s=i - 1, position_t=j - 1, char_s=s[i - 1], char_t=t[j - 1] )) i -= 1 j -= 1 elif i > 0 and dp[i][j] == dp[i - 1][j] + 1: # Deletion operations.append(EditOperation( type="delete", position_s=i - 1, position_t=j, char_s=s[i - 1] )) i -= 1 else: # Insertion (j > 0 and dp[i][j] == dp[i][j - 1] + 1) operations.append(EditOperation( type="insert", position_s=i, position_t=j - 1, char_t=t[j - 1] )) j -= 1 # Reverse to get operations in forward order operations.reverse() return dp[m][n], operations # Example usage and verificationdef demonstrate_reconstruction(): s = "kitten" t = "sitting" distance, ops = edit_distance_with_reconstruction(s, t) print(f"Edit distance from '{s}' to '{t}': {distance}") print("\nOperations:") for i, op in enumerate(ops): print(f" {i + 1}. {op}") # Verify by applying operations print("\nVerification (simulating transformation):") result = list(s) offset = 0 # Track position shifts from inserts/deletes for op in ops: if op.type == "match": print(f" '{''.join(result)}' - Match at {op.position_s}") elif op.type == "replace": pos = op.position_s + offset result[pos] = op.char_t print(f" '{''.join(result)}' - Replaced '{op.char_s}' with '{op.char_t}'") elif op.type == "delete": pos = op.position_s + offset deleted = result.pop(pos) offset -= 1 print(f" '{''.join(result)}' - Deleted '{deleted}'") elif op.type == "insert": pos = op.position_s + offset result.insert(pos, op.char_t) offset += 1 print(f" '{''.join(result)}' - Inserted '{op.char_t}'") print(f"\nFinal result: '{''.join(result)}'") print(f"Target was: '{t}'") print(f"Match: {''.join(result) == t}") demonstrate_reconstruction()When multiple predecessors give the same minimum value, there are multiple optimal edit sequences. The code above picks one arbitrarily based on the order of conditionals. For some applications, you might want to enumerate all optimal sequences, prefer certain operations, or break ties based on additional criteria.
An elegant way to visualize edit distance is as a string alignment. We display both strings with gaps inserted where insertions and deletions occur, similar to how bioinformatics displays sequence alignments.
Alignment Notation:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
def get_alignment(s: str, t: str) -> tuple[str, str, str]: """ Generate alignment strings showing the edit operations. Returns: (aligned_s, operation_line, aligned_t) where operation_line shows: - '|' for match - '!' for substitution - ' ' for insert/delete """ m, n = len(s), len(t) # Build DP table dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): if s[i - 1] == t[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = 1 + min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1]) # Backtrack and build alignment aligned_s = [] aligned_t = [] ops = [] i, j = m, n while i > 0 or j > 0: if i > 0 and j > 0 and s[i-1] == t[j-1] and dp[i][j] == dp[i-1][j-1]: aligned_s.append(s[i-1]) aligned_t.append(t[j-1]) ops.append('|') # Match i -= 1 j -= 1 elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1: aligned_s.append(s[i-1]) aligned_t.append(t[j-1]) ops.append('!') # Substitution i -= 1 j -= 1 elif i > 0 and dp[i][j] == dp[i-1][j] + 1: aligned_s.append(s[i-1]) aligned_t.append('-') ops.append(' ') # Deletion (gap in t) i -= 1 else: aligned_s.append('-') aligned_t.append(t[j-1]) ops.append(' ') # Insertion (gap in s) j -= 1 # Reverse (we built backwards) return ( ''.join(reversed(aligned_s)), ''.join(reversed(ops)), ''.join(reversed(aligned_t)) ) def print_alignment(s: str, t: str): """Pretty-print the alignment between two strings.""" aligned_s, ops, aligned_t = get_alignment(s, t) print(f"Aligning '{s}' to '{t}':") print(f" {aligned_s} (source)") print(f" {ops}") print(f" {aligned_t} (target)") print() # Examplesprint_alignment("kitten", "sitting")print_alignment("INTENTION", "EXECUTION")print_alignment("saturday", "sunday")This alignment view is exactly how DNA and protein alignments are displayed. In bioinformatics, the strings are genetic sequences, and the 'edits' represent mutations that occurred during evolution. The alignment helps scientists identify conserved regions (many matches), mutation hotspots (many substitutions), and insertions/deletions (called 'indels'). The Needleman-Wunsch algorithm, which computes this type of global alignment, is essentially edit distance with biologically-inspired scoring.
Here's a powerful conceptual insight: the edit distance DP table can be viewed as a shortest-path problem on a directed graph.
The Graph Model:
Edit distance = length of shortest path from (0,0) to (m,n)
This view explains why dynamic programming works: we're systematically exploring all paths and keeping only the shortest.
Implications of the Graph View:
Dijkstra's algorithm could solve this—but it's overkill since all edges have weights ≤ 1. The DP approach is essentially BFS on the unweighted version or a specialized shortest path for this DAG structure.
Multiple shortest paths explain why there can be multiple optimal edit sequences—they're different shortest paths in the graph.
Parallelization becomes clearer: cells on the same anti-diagonal have no dependencies on each other and can be computed in parallel.
Space optimization corresponds to keeping only the "frontier" of the BFS rather than the entire graph.
(0,0)─→(0,1)─→(0,2)─→(0,3)
│ ↘ │ ↘ │ ↘ │
↓ ↓ ↓ ↓
(1,0)─→(1,1)─→(1,2)─→(1,3)
│ ↘ │ ↘ │ ↘ │
↓ ↓ ↓ ↓
(2,0)─→(2,1)─→(2,2)─→(2,3)
Each arrow has weight 0 or 1 based on character matches.
Many DP problems can be reframed as shortest (or longest) path problems on DAGs. This perspective connects DP to graph algorithms and can inspire solutions: if you can model a problem as finding optimal paths in a DAG, DP is usually the right approach. It also suggests that techniques from graph algorithms (like A* with admissible heuristics) might accelerate DP in some cases.
Edit distance bugs can be subtle. Here's a systematic approach to debugging.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
def edit_distance_debug(s: str, t: str, verbose: bool = True) -> int: """ Debug version that prints the DP table and traces decisions. """ m, n = len(s), len(t) dp = [[0] * (n + 1) for _ in range(m + 1)] # Base cases for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j # Fill with detailed trace for i in range(1, m + 1): for j in range(1, n + 1): if s[i - 1] == t[j - 1]: dp[i][j] = dp[i - 1][j - 1] if verbose: print(f"dp[{i}][{j}]: '{s[i-1]}' == '{t[j-1]}', " f"match → {dp[i][j]}") else: ins = dp[i][j - 1] + 1 delete = dp[i - 1][j] + 1 repl = dp[i - 1][j - 1] + 1 dp[i][j] = min(ins, delete, repl) if verbose: print(f"dp[{i}][{j}]: '{s[i-1]}' != '{t[j-1]}', " f"min(ins={ins}, del={delete}, rep={repl}) → {dp[i][j]}") if verbose: print("\nFinal DP table:") print_dp_table(dp, s, t) return dp[m][n] def print_dp_table(dp: list, s: str, t: str): """Pretty-print the DP table.""" m, n = len(s), len(t) # Header row print(" ", end="") print(" ε ", end="") for j in range(n): print(f" {t[j]} ", end="") print() for i in range(m + 1): if i == 0: print(" ε ", end="") else: print(f" {s[i-1]} ", end="") for j in range(n + 1): print(f" {dp[i][j]:2} ", end="") print() # Test with known examplesprint("Test 1: 'cat' → 'cut' (expected: 1)")print("=" * 50)result = edit_distance_debug("cat", "cut")print(f"Result: {result}\n") print("Test 2: 'kitten' → 'sitting' (expected: 3)")print("=" * 50)result = edit_distance_debug("kitten", "sitting", verbose=False)print_dp_table([[0, 1, 2, 3, 4, 5, 6, 7], [1, 1, 2, 3, 4, 5, 6, 6], [2, 2, 1, 2, 3, 4, 5, 6], [3, 3, 2, 1, 2, 3, 4, 5], [4, 4, 3, 2, 1, 2, 3, 4], [5, 5, 4, 3, 2, 2, 2, 3], [6, 6, 5, 4, 3, 3, 3, 3]], "kitten", "sitting")print(f"Result: {result}")Always test with strings where you know the answer: 'cat'→'cat' (0), 'cat'→'dog' (3), 'kitten'→'sitting' (3), ''→'abc' (3), 'abc'→'' (3). These edge and corner cases quickly reveal bugs.
We've covered the essential skills for working with the edit distance DP table—not just computing it, but understanding its structure and extracting the information we need.
What's Next:
Now that we can compute edit distance and reconstruct the edit sequence, we'll explore real-world applications. The next page examines how edit distance powers spell checkers, autocomplete systems, DNA analysis, and more—seeing the theory we've built in practical action.
You can now construct the DP table efficiently, optimize space usage, backtrack to find the actual edit sequence, visualize edits as alignments, and debug your implementations. These are practical skills that translate directly to solving real-world string comparison problems.