← Back to DevBytes

Solving Longest Common Subsequence in Python: Step-by-Step Guide

Introduction to Longest Common Subsequence

The Longest Common Subsequence (LCS) problem is one of the most classic problems in computer science and dynamic programming. Given two strings (or sequences), the goal is to find the longest subsequence that appears in both of them in the same order — but not necessarily consecutively. This distinction between a subsequence and a substring is crucial: a substring requires contiguous characters, while a subsequence only requires the order to be preserved.

For example, given the strings "ABCBDAB" and "BDCAB", the longest common subsequences are "BCAB", "BDAB", and "BCBA", each with a length of 4. Understanding how to compute this efficiently is a foundational skill for any developer working with algorithms, text processing, or bioinformatics.

Why LCS Matters

The LCS problem is not just an academic exercise — it has real-world applications across many domains:

Because of these applications, knowing how to implement LCS efficiently is a valuable skill that extends far beyond coding interviews.

Understanding the Problem

Before diving into code, let's clearly define the problem. A subsequence of a string is a sequence of characters that appear in the same order as the original string, but not necessarily adjacent. For instance, "ACE" is a subsequence of "ABCDE" because A, C, and E appear in that order, even though they are not consecutive.

The LCS problem asks: given two strings X of length m and Y of length n, what is the longest subsequence common to both?

There are multiple ways to approach this problem, ranging from naive recursion to optimized dynamic programming. Let's explore each approach step by step.

Approach 1: Naive Recursive Solution

The simplest way to think about LCS is recursively. We compare the last characters of both strings:

Here is the implementation:

def lcs_recursive(X, Y, m, n):
    # Base case: if either string is empty, LCS is 0
    if m == 0 or n == 0:
        return 0
    
    # If last characters match, include in LCS
    if X[m - 1] == Y[n - 1]:
        return 1 + lcs_recursive(X, Y, m - 1, n - 1)
    
    # Otherwise, take the max of two possibilities
    return max(
        lcs_recursive(X, Y, m - 1, n),
        lcs_recursive(X, Y, m, n - 1)
    )

# Example usage
X = "ABCBDAB"
Y = "BDCAB"
print("Length of LCS:", lcs_recursive(X, Y, len(X), len(Y)))
# Output: Length of LCS: 4

While this solution is intuitive, it has a major drawback: its time complexity is O(2^(m+n)) in the worst case. This is because each recursive call branches into two more calls, leading to an exponential explosion. For strings longer than about 20 characters, this becomes impractical.

Approach 2: Memoization (Top-Down Dynamic Programming)

The recursive solution recomputes the same subproblems repeatedly. We can fix this by caching results — a technique called memoization. By storing the result of each subproblem in a dictionary or 2D array, we avoid redundant calculations.

def lcs_memoization(X, Y, m, n, memo):
    # Base case
    if m == 0 or n == 0:
        return 0
    
    # Check if already computed
    key = (m, n)
    if key in memo:
        return memo[key]
    
    # If last characters match
    if X[m - 1] == Y[n - 1]:
        memo[key] = 1 + lcs_memoization(X, Y, m - 1, n - 1, memo)
    else:
        memo[key] = max(
            lcs_memoization(X, Y, m - 1, n, memo),
            lcs_memoization(X, Y, m, n - 1, memo)
        )
    
    return memo[key]

# Example usage
X = "ABCBDAB"
Y = "BDCAB"
memo = {}
print("Length of LCS:", lcs_memoization(X, Y, len(X), len(Y), memo))
# Output: Length of LCS: 4

With memoization, each unique subproblem (m, n) is solved only once. Since there are O(m * n) possible subproblems, the time complexity drops to O(m * n), and the space complexity is also O(m * n) for the memo table plus the recursion stack.

Approach 3: Tabulation (Bottom-Up Dynamic Programming)

The bottom-up approach eliminates recursion entirely by building a 2D table iteratively. We create a table dp of size (m+1) x (n+1), where dp[i][j] represents the length of the LCS of the first i characters of X and the first j characters of Y.

The recurrence relation is:

def lcs_tabulation(X, Y):
    m = len(X)
    n = len(Y)
    
    # Create a (m+1) x (n+1) table initialized to 0
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Build the table bottom-up
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    
    return dp[m][n]

# Example usage
X = "ABCBDAB"
Y = "BDCAB"
print("Length of LCS:", lcs_tabulation(X, Y))
# Output: Length of LCS: 4

This approach has the same O(m * n) time and space complexity as memoization, but it avoids the overhead of recursive function calls and is generally faster in practice due to better cache locality.

Reconstructing the Actual LCS String

So far, we have only computed the length of the LCS. In many applications, you need the actual subsequence itself. To reconstruct it, we trace back through the dp table starting from dp[m][n]:

def lcs_print(X, Y):
    m = len(X)
    n = len(Y)
    
    # Build the DP table
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    
    # Trace back to find the LCS string
    i, j = m, n
    lcs_chars = []
    
    while i > 0 and j > 0:
        if X[i - 1] == Y[j - 1]:
            lcs_chars.append(X[i - 1])
            i -= 1
            j -= 1
        elif dp[i - 1][j] > dp[i][j - 1]:
            i -= 1
        else:
            j -= 1
    
    # The characters were collected in reverse order
    lcs_chars.reverse()
    return ''.join(lcs_chars)

# Example usage
X = "ABCBDAB"
Y = "BDCAB"
print("LCS:", lcs_print(X, Y))
# Output: LCS: BCAB

The traceback works by checking whether the current characters match. If they do, that character is part of the LCS, and we move diagonally. If not, we move in the direction of the larger adjacent value. This reconstruction takes O(m + n) time since we traverse at most m + n cells.

Approach 4: Space-Optimized Solution

If you only need the length of the LCS (not the actual string), you can reduce the space complexity from O(m * n) to O(min(m, n)). Notice that each row of the DP table only depends on the previous row, so we can keep just two rows in memory at any time.

def lcs_space_optimized(X, Y):
    # Ensure Y is the shorter string for minimal space
    if len(X) < len(Y):
        X, Y = Y, X
    
    m = len(X)
    n = len(Y)
    
    # Only keep two rows
    previous = [0] * (n + 1)
    current = [0] * (n + 1)
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                current[j] = previous[j - 1] + 1
            else:
                current[j] = max(previous[j], current[j - 1])
        
        # Swap rows
        previous, current = current, previous
        # Reset current row for next iteration
        current = [0] * (n + 1)
    
    return previous[n]

# Example usage
X = "ABCBDAB"
Y = "BDCAB"
print("Length of LCS:", lcs_space_optimized(X, Y))
# Output: Length of LCS: 4

This optimization is particularly useful when dealing with very long strings where memory is a constraint. The time complexity remains O(m * n), but the space drops significantly.

Best Practices

When implementing LCS in production code, keep the following best practices in mind:

Putting It All Together: A Complete Utility Class

Here is a complete, reusable Python class that encapsulates all the LCS functionality we have discussed:

class LongestCommonSubsequence:
    """A utility class for computing the Longest Common Subsequence."""
    
    @staticmethod
    def length(text1, text2):
        """Return the length of the LCS of two strings."""
        if not text1 or not text2:
            return 0
        
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
        
        return dp[m][n]
    
    @staticmethod
    def sequence(text1, text2):
        """Return the actual LCS string of two strings."""
        if not text1 or not text2:
            return ""
        
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
        
        # Traceback
        i, j = m, n
        result = []
        while i > 0 and j > 0:
            if text1[i - 1] == text2[j - 1]:
                result.append(text1[i - 1])
                i -= 1
                j -= 1
            elif dp[i - 1][j] > dp[i][j - 1]:
                i -= 1
            else:
                j -= 1
        
        result.reverse()
        return ''.join(result)
    
    @staticmethod
    def all_sequences(text1, text2):
        """Return a set of all possible LCS strings."""
        if not text1 or not text2:
            return {""}
        
        m, n = len(text1), len(text2)
        dp = [[0] * (n + 1) for _ in range(m + 1)]
        
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    dp[i][j] = dp[i - 1][j - 1] + 1
                else:
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
        
        def backtrack(i, j):
            if i == 0 or j == 0:
                return {""}
            if text1[i - 1] == text2[j - 1]:
                return {s + text1[i - 1] for s in backtrack(i - 1, j - 1)}
            result = set()
            if dp[i - 1][j] >= dp[i][j - 1]:
                result.update(backtrack(i - 1, j))
            if dp[i][j - 1] >= dp[i - 1][j]:
                result.update(backtrack(i, j - 1))
            return result
        
        return backtrack(m, n)


# Example usage
if __name__ == "__main__":
    text1 = "ABCBDAB"
    text2 = "BDCAB"
    
    lcs = LongestCommonSubsequence
    print("LCS Length:", lcs.length(text1, text2))
    print("One LCS:", lcs.sequence(text1, text2))
    print("All LCS:", lcs.all_sequences(text1, text2))
    
    # Edge cases
    print("Empty string:", lcs.length("", "ABC"))
    print("Identical strings:", lcs.sequence("HELLO", "HELLO"))
    print("No common subsequence:", lcs.length("ABC", "XYZ"))

This class provides three methods: length() for just the length, sequence() for one valid LCS string, and all_sequences() for finding all possible LCS strings. The all_sequences() method uses a recursive backtrack over the DP table and can be computationally expensive if there are many valid LCS results, so use it judiciously.

Conclusion

The Longest Common Subsequence problem is a cornerstone of dynamic programming that every developer should understand. We started with a naive recursive approach and progressively refined it through memoization, tabulation, and space optimization, arriving at efficient solutions that run in O(m * n) time. We also explored how to reconstruct the actual LCS string and even find all possible LCS results. Whether you are preparing for coding interviews, building a diff tool, or working on sequence alignment in bioinformatics, the techniques covered in this guide give you a solid foundation. Remember to choose the approach that best fits your specific constraints — prioritize readability for maintainable code, and optimize only when profiling shows a real need. With these tools in hand, you are well-equipped to tackle LCS and the many related problems that build upon it.

— Ad —

Google AdSense will appear here after approval

← Back to all articles