← Back to DevBytes

Edit Distance (Levenshtein): Multiple Solutions and Complexity Analysis

Introduction to Edit Distance

Edit distance, also known as Levenshtein distance, is a way to quantify how dissimilar two strings are by counting the minimum number of operations required to transform one string into the other. The allowed operations typically include insertion, deletion, and substitution of a single character. This metric is foundational in string processing, natural language processing, bioinformatics, and many other fields where approximate string matching is essential.

Understanding and implementing edit distance helps developers build features like spell checkers, fuzzy search, diff tools, and data deduplication systems. By analyzing multiple solution approaches, you gain insight into algorithmic trade-offs between time and space complexity, and learn to select the right technique for production environments.

Understanding the Problem

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Given two strings word1 (length m) and word2 (length n), the Levenshtein distance is the smallest number of single-character edits required to change word1 into word2. An edit is one of:

For example, transforming "kitten" into "sitting" requires:

The distance is 3. The problem can be solved in several ways, each with different performance characteristics.

Approach 1: Recursive Brute Force

The most straightforward mental model uses recursion. Consider the characters from the end of each string:

This approach explores all possible edit sequences, leading to an exponential time complexity of O(3max(m,n)). It is only suitable for tiny strings, but serves as a conceptual foundation.


def edit_distance_recursive(word1: str, word2: str) -> int:
    # Base cases
    if not word1:
        return len(word2)
    if not word2:
        return len(word1)

    # If last characters match
    if word1[-1] == word2[-1]:
        return edit_distance_recursive(word1[:-1], word2[:-1])

    # Otherwise, explore three options
    delete_cost = edit_distance_recursive(word1[:-1], word2)
    insert_cost = edit_distance_recursive(word1, word2[:-1])
    replace_cost = edit_distance_recursive(word1[:-1], word2[:-1])

    return 1 + min(delete_cost, insert_cost, replace_cost)

# Example usage
print(edit_distance_recursive("kitten", "sitting"))  # Output: 3

Approach 2: Dynamic Programming – Memoization (Top-Down)

The recursive solution suffers from overlapping subproblems. By storing results for each pair of suffixes (i, j) where i and j are positions in the strings, we reduce redundant calculations. This is top-down dynamic programming using memoization.

The state is defined as dp(i, j): edit distance between word1[i:] and word2[j:]. The recursion remains the same, but we cache results in a dictionary or using @lru_cache.

Time complexity drops to O(m * n) because each subproblem is solved once. Space complexity is O(m * n) for the cache (plus recursion stack).


from functools import lru_cache

def edit_distance_memo(word1: str, word2: str) -> int:
    @lru_cache(None)
    def dp(i, j):
        # i = position in word1, j = position in word2
        if i == len(word1):
            return len(word2) - j
        if j == len(word2):
            return len(word1) - i

        if word1[i] == word2[j]:
            return dp(i + 1, j + 1)

        delete_cost = dp(i + 1, j)      # delete word1[i]
        insert_cost = dp(i, j + 1)      # insert word2[j]
        replace_cost = dp(i + 1, j + 1) # replace word1[i] with word2[j]

        return 1 + min(delete_cost, insert_cost, replace_cost)

    return dp(0, 0)

print(edit_distance_memo("kitten", "sitting"))  # 3

Approach 3: Dynamic Programming – Bottom-Up (Tabulation)

The classic bottom-up DP builds a table dp[i][j] iteratively, where i and j represent lengths of prefixes (not suffixes). This avoids recursion overhead and is often preferred in production for predictable performance.

Define dp[i][j] as the edit distance between word1[:i] and word2[:j]. Base cases: dp[0][j] = j (insert all characters), dp[i][0] = i (delete all). For each cell:

The answer is in dp[m][n]. Time complexity O(m*n). Space complexity O(m*n), but can be reduced to O(min(m, n)) using only two rows (or one row and a variable).

Full Table Implementation


def edit_distance_tabulation(word1: str, word2: str) -> int:
    m, n = len(word1), len(word2)
    # dp[i][j] for prefixes of lengths i, j
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # Base cases
    for i in range(m + 1):
        dp[i][0] = i  # delete all from word1
    for j in range(n + 1):
        dp[0][j] = j  # insert all into word1

    for i in range(1, m + 1):
        for j in range(1, n + 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],      # delete
                    dp[i][j - 1],      # insert
                    dp[i - 1][j - 1]   # replace
                )

    return dp[m][n]

print(edit_distance_tabulation("kitten", "sitting"))  # 3

Space-Optimized Implementation

Since each row dp[i] depends only on row dp[i-1], we can maintain two rows (previous and current). Further optimization uses a single row and a variable to track the previous diagonal element. Below is the two-row version, space O(min(m, n)).


def edit_distance_space_optimized(word1: str, word2: str) -> int:
    # Ensure word1 is the shorter one for minimal memory
    if len(word1) > len(word2):
        word1, word2 = word2, word1
    m, n = len(word1), len(word2)

    prev_row = list(range(n + 1))  # dp for i=0
    curr_row = [0] * (n + 1)

    for i in range(1, m + 1):
        curr_row[0] = i  # delete all from word1
        for j in range(1, n + 1):
            if word1[i - 1] == word2[j - 1]:
                # cost from diagonal without +1
                curr_row[j] = prev_row[j - 1]
            else:
                curr_row[j] = 1 + min(
                    prev_row[j],      # delete
                    curr_row[j - 1],  # insert
                    prev_row[j - 1]   # replace
                )
        # Swap rows for next iteration
        prev_row, curr_row = curr_row, [0] * (n + 1)

    return prev_row[n]

print(edit_distance_space_optimized("kitten", "sitting"))  # 3

Complexity Analysis Summary

Choosing the right implementation depends on the context:

For most applications, the space-optimized bottom-up version provides the best balance. When additional operations like transposition (Damerau-Levenshtein) are needed, adaptations follow similar patterns with slightly larger state.

Practical Applications and Best Practices

Edit distance powers a wide range of real-world features:

Best Practices

Conclusion

Edit distance is a classic problem that demonstrates how dynamic programming transforms an exponential brute-force task into an efficient quadratic-time solution. By understanding the recursive structure, you can derive memoized and tabular versions, and then apply space optimizations to handle large inputs. Whether you're building a search engine, a genomic aligner, or a simple autocorrect feature, mastering these techniques ensures you pick the right trade-off between readability, speed, and memory. Start with the space-optimized bottom-up approach as your default, and reach for specialized libraries when performance becomes critical or you need advanced distance metrics.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles