Edit Distance: Levenshtein Distance Algorithm

Edit distance quantifies how different two strings are by counting the minimum operations needed to transform one into the other. The Levenshtein distance, named after Soviet mathematician Vladimir...

Key Insights

  • Edit distance measures the minimum number of single-character operations (insert, delete, substitute) needed to transform one string into another, forming the foundation for spell checkers, DNA analysis, and fuzzy search systems.
  • Dynamic programming reduces the exponential brute-force complexity to O(n×m) by building a matrix that stores solutions to overlapping subproblems, making the algorithm practical for real-world use.
  • Space optimization techniques can reduce memory usage from O(n×m) to O(min(n,m)) by recognizing that each row only depends on the previous row, critical when processing long strings.

Introduction to Edit Distance

Edit distance quantifies how different two strings are by counting the minimum operations needed to transform one into the other. The Levenshtein distance, named after Soviet mathematician Vladimir Levenshtein who introduced it in 1965, allows three operations: insertion, deletion, and substitution—each with a cost of 1.

This metric powers systems you use daily. Spell checkers suggest corrections by finding dictionary words with low edit distance from your typo. DNA sequence alignment uses edit distance to measure genetic similarity. Plagiarism detection tools identify paraphrased content. Fuzzy search implementations match user queries against databases despite typos.

Understanding this algorithm gives you a foundational tool for any problem involving string similarity.

Understanding Levenshtein Distance

Let’s transform “kitten” into “sitting” step by step:

  1. kittensitten (substitute ‘k’ with ’s’)
  2. sittensittin (substitute ’e’ with ‘i’)
  3. sittinsitting (insert ‘g’ at end)

The edit distance is 3. But how do we prove this is minimal? We need a systematic approach.

def demonstrate_transformation(source: str, target: str, operations: list[tuple]) -> None:
    """Show step-by-step string transformation with operations."""
    current = source
    print(f"Start: '{current}'")
    
    for i, (op_type, position, char) in enumerate(operations, 1):
        if op_type == "substitute":
            current = current[:position] + char + current[position + 1:]
            print(f"Step {i}: Substitute '{source[position]}' with '{char}' at position {position} → '{current}'")
        elif op_type == "insert":
            current = current[:position] + char + current[position:]
            print(f"Step {i}: Insert '{char}' at position {position} → '{current}'")
        elif op_type == "delete":
            current = current[:position] + current[position + 1:]
            print(f"Step {i}: Delete '{current[position]}' at position {position} → '{current}'")
    
    print(f"Final: '{current}' (matches target: {current == target})")

# Transform "kitten" to "sitting"
operations = [
    ("substitute", 0, "s"),  # k → s
    ("substitute", 4, "i"),  # e → i
    ("insert", 6, "g"),      # add g
]
demonstrate_transformation("kitten", "sitting", operations)

This outputs each transformation, confirming our manual calculation. But finding the optimal sequence requires a smarter approach.

The Dynamic Programming Approach

A brute-force approach would try every possible sequence of operations, leading to exponential time complexity. For strings of length n and m, we’d explore O(3^(n+m)) possibilities—completely impractical.

Dynamic programming solves this by recognizing overlapping subproblems. The key insight: the edit distance between two strings depends on the edit distances of their prefixes.

We build a matrix dp where dp[i][j] represents the edit distance between the first i characters of the source and the first j characters of the target.

The recurrence relation:

  • If characters match: dp[i][j] = dp[i-1][j-1]
  • If they differ: dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])

The three terms in the minimum represent deletion, insertion, and substitution respectively.

def build_dp_matrix(source: str, target: str) -> list[list[int]]:
    """Build and visualize the edit distance DP matrix."""
    m, n = len(source), len(target)
    
    # Initialize matrix with base cases
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Base case: transforming empty string to prefix of target
    for j in range(n + 1):
        dp[0][j] = j
    
    # Base case: transforming prefix of source to empty string
    for i in range(m + 1):
        dp[i][0] = i
    
    # Fill the matrix
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if source[i - 1] == target[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]  # Characters match, no operation needed
            else:
                dp[i][j] = 1 + min(
                    dp[i - 1][j],      # Delete from source
                    dp[i][j - 1],      # Insert into source
                    dp[i - 1][j - 1]   # Substitute
                )
    
    return dp

def print_matrix(dp: list[list[int]], source: str, target: str) -> None:
    """Pretty print the DP matrix with labels."""
    print("    ", "  ".join(["ε"] + list(target)))
    for i, row in enumerate(dp):
        label = "ε" if i == 0 else source[i - 1]
        print(f" {label}  " + "  ".join(f"{val}" for val in row))

# Example
source, target = "cat", "car"
dp = build_dp_matrix(source, target)
print_matrix(dp, source, target)
print(f"\nEdit distance: {dp[-1][-1]}")

Running this produces:

     ε  c  a  r
 ε   0  1  2  3
 c   1  0  1  2
 a   2  1  0  1
 t   3  2  1  1

Edit distance: 1

The matrix clearly shows how we build up solutions from smaller subproblems.

Implementation

Here’s the complete, production-ready implementation:

def levenshtein_distance(source: str, target: str) -> int:
    """
    Calculate the Levenshtein distance between two strings.
    
    Time complexity: O(n * m)
    Space complexity: O(n * m)
    
    Args:
        source: The string to transform from
        target: The string to transform to
        
    Returns:
        The minimum number of single-character edits needed
    """
    m, n = len(source), len(target)
    
    # Handle edge cases
    if m == 0:
        return n
    if n == 0:
        return m
    
    # Create the DP table
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Initialize base cases
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    
    # Fill the table
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            cost = 0 if source[i - 1] == target[j - 1] else 1
            dp[i][j] = min(
                dp[i - 1][j] + 1,      # Deletion
                dp[i][j - 1] + 1,      # Insertion
                dp[i - 1][j - 1] + cost # Substitution (or match)
            )
    
    return dp[m][n]

For JavaScript environments:

function levenshteinDistance(source, target) {
    const m = source.length;
    const n = target.length;
    
    if (m === 0) return n;
    if (n === 0) return m;
    
    const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
    
    for (let i = 0; i <= m; i++) dp[i][0] = i;
    for (let j = 0; j <= n; j++) dp[0][j] = j;
    
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            const cost = source[i - 1] === target[j - 1] ? 0 : 1;
            dp[i][j] = Math.min(
                dp[i - 1][j] + 1,
                dp[i][j - 1] + 1,
                dp[i - 1][j - 1] + cost
            );
        }
    }
    
    return dp[m][n];
}

Space Optimization

The standard implementation uses O(n×m) space, which becomes problematic for long strings. Observe that each cell only depends on three cells: the one above, to the left, and diagonally above-left. This means we only need to keep track of two rows at a time.

def levenshtein_distance_optimized(source: str, target: str) -> int:
    """
    Space-optimized Levenshtein distance using two rows.
    
    Time complexity: O(n * m)
    Space complexity: O(min(n, m))
    """
    # Ensure source is the longer string for optimal space usage
    if len(source) < len(target):
        source, target = target, source
    
    m, n = len(source), len(target)
    
    if n == 0:
        return m
    
    # Only keep two rows
    previous_row = list(range(n + 1))
    current_row = [0] * (n + 1)
    
    for i in range(1, m + 1):
        current_row[0] = i
        
        for j in range(1, n + 1):
            cost = 0 if source[i - 1] == target[j - 1] else 1
            current_row[j] = min(
                previous_row[j] + 1,      # Deletion
                current_row[j - 1] + 1,   # Insertion
                previous_row[j - 1] + cost # Substitution
            )
        
        # Swap rows
        previous_row, current_row = current_row, previous_row
    
    return previous_row[n]

# Compare memory usage
import sys

source = "a" * 1000
target = "b" * 1000

# Standard approach memory
standard_matrix = [[0] * 1001 for _ in range(1001)]
print(f"Standard approach: {sys.getsizeof(standard_matrix) + sum(sys.getsizeof(row) for row in standard_matrix):,} bytes")

# Optimized approach memory
optimized_rows = [list(range(1001)), [0] * 1001]
print(f"Optimized approach: {sum(sys.getsizeof(row) for row in optimized_rows):,} bytes")

The space savings are dramatic—from megabytes to kilobytes for long strings.

Practical Applications & Variations

Here’s a fuzzy search implementation that finds approximate matches:

def fuzzy_search(query: str, candidates: list[str], max_distance: int = 2) -> list[tuple[str, int]]:
    """
    Find candidates within max_distance edits of the query.
    
    Returns list of (candidate, distance) tuples, sorted by distance.
    """
    matches = []
    
    for candidate in candidates:
        distance = levenshtein_distance_optimized(query.lower(), candidate.lower())
        if distance <= max_distance:
            matches.append((candidate, distance))
    
    return sorted(matches, key=lambda x: (x[1], x[0]))

# Example: Spell checker
dictionary = ["python", "programming", "algorithm", "function", "variable", 
              "pythonic", "program", "algorithms"]

typo = "progamming"
suggestions = fuzzy_search(typo, dictionary)
print(f"Did you mean: {[s[0] for s in suggestions]}")
# Output: Did you mean: ['program', 'programming']

Damerau-Levenshtein extends the algorithm to include transpositions (swapping adjacent characters) as a single operation. This better models common typos like “teh” → “the”.

Weighted edit distance assigns different costs to operations. For keyboard-based applications, substituting adjacent keys might cost less than distant ones.

Complexity Analysis & When to Use Alternatives

The Levenshtein algorithm runs in O(n×m) time and O(min(n,m)) space with optimization. For most applications, this is acceptable. However, consider alternatives in specific scenarios:

Hamming distance works when strings have equal length and you only need to count differing positions. It runs in O(n) time.

Jaro-Winkler similarity excels at comparing short strings like names, giving higher scores to strings matching from the beginning.

BK-trees provide efficient fuzzy dictionary search. Rather than comparing against every word, they prune the search space using the triangle inequality property of edit distance.

For very long strings (like DNA sequences), algorithms like Myers’ bit-vector algorithm achieve O(n×m/w) time where w is the word size, providing significant speedups.

Choose Levenshtein when you need exact edit distance for moderate-length strings. For specialized use cases—name matching, dictionary lookup at scale, or biological sequences—investigate domain-specific alternatives.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.