Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 33

Longest Common Subsequence: A Dynamic Programming Approach

Recommended resources

Hello! Welcome to the final lesson in our module on Dynamic Programming.

In our last session, we explored our first 2D dynamic programming problem, the 0/1 Knapsack. We learned to define a state dp[i][c] based on two variables (items and capacity) and derived a recurrence relation based on the choice to either include or exclude an item.

Today, we'll solidify your understanding of 2D DP by tackling another classic and crucial problem: the Longest Common Subsequence (LCS). This problem has wide applications, from comparing files (like the diff utility in Linux) to bioinformatics (aligning DNA sequences). By the end of this lesson, you will be able to define the state and recurrence for the LCS problem, implement a bottom-up DP solution to find its length, and backtrack through your solution to reconstruct the subsequence itself.

1. What is a Longest Common Subsequence?

First, it's important to distinguish a subsequence from a substring.

  • A substring must be a contiguous block of characters within a string. For "abcde", "bcd" is a substring, but "ace" is not.
  • A subsequence is formed by deleting zero or more characters from a string while maintaining the relative order of the remaining characters. For "abcde", "ace" is a valid subsequence.

The Longest Common Subsequence (LCS) problem asks for the longest possible subsequence that is present in two given strings.

To see this in action, the following video provides a clear definition and example.

Longest Common Subsequence - Dynamic Programming - Leetcode 1143

This video from NeetCode clearly defines the Longest Common Subsequence problem and illustrates the concept with a simple example.

Watch from the beginning to 01:58. Focus on understanding the difference between a subsequence and a substring, which is key to this problem.


2. Devising a Strategy: Subproblems and Recurrence

Like other DP problems, LCS has an optimal substructure. The solution to the main problem can be built from solutions to smaller, overlapping subproblems. Let's think about this recursively.

Consider two strings, s1 and s2. We want to find LCS(s1, s2). Let's compare their last characters.

  1. Case 1: The last characters match.
    If s1 ends with 'a' and s2 also ends with 'a', then this matching 'a' must be part of their LCS. Why? Including it gives us a common subsequence that is one character longer than what we could get from the strings without their last characters. So, the problem is reduced to finding the LCS of the prefixes (the strings without their last character) and appending 'a'.
    LCS(s1, s2) = LCS(s1_prefix, s2_prefix) + 'a'

  2. Case 2: The last characters do not match.
    If s1 ends with 'a' and s2 ends with 'b', they can't both contribute to the end of the LCS. The LCS must therefore be the LCS of either (s1_prefix, s2) or (s1, s2_prefix), whichever is longer.
    LCS(s1, s2) = max(length(LCS(s1_prefix, s2)), length(LCS(s1, s2_prefix)))

This recursive logic generates many overlapping subproblems, as shown in the recursive tree for finding the LCS of "abcde" and "ace".

Recursive Tree for Longest Common Subsequence (LCS)
This recursive tree illustrates how the same subproblems (e.g., finding the LCS of "ab" and "a") are computed multiple times, highlighting the inefficiency of a naive recursive approach and the need for dynamic programming.

3. The Dynamic Programming Solution

To avoid re-computing subproblems, we use tabulation. We'll build a 2D table, dp, where dp[i][j] stores the length of the LCS for the prefixes s1[0...i-1] and s2[0...j-1].

  • State: dp[i][j] = length of the LCS of s1's first i characters and s2's first j characters.
  • Goal: The final answer will be dp[m][n], where m and n are the lengths of s1 and s2.
  • Recurrence Relation: This directly follows our recursive logic:
    • If s1[i-1] == s2[j-1]: dp[i][j] = 1 + dp[i-1][j-1] (We found a match, add 1 to the result from the smaller subproblem).
    • If s1[i-1] != s2[j-1]: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) (No match, so we carry forward the best result from the previous subproblems).
  • Base Case: The dp table is initialized with zeros. dp[0][j] and dp[i][0] will be 0, as the LCS of any string and an empty string is 0.

The following video provides an excellent walkthrough of setting up and filling this DP table.

Longest Common Subsequence - Dynamic Programming - Leetcode 1143

Now, let's see how to translate this subproblem logic into a 2D Dynamic Programming solution. This video will walk you through setting up the DP table and the rules for filling it.

Watch from 01:58 to 13:58. Pay close attention to: How the 2D grid maps to the subproblems. The two main rules: what to do when characters match (go diagonally) and when they don't (take the max of the top and left cells). The role of the base cases (the extra row/column of zeros).


4. Implementation: Finding the Length

Here is a Python implementation that builds the dp table to find the length of the LCS. Notice how the code is a direct translation of the recurrence relation we just defined.

def lcs_length(s1, s2):
    m, n = len(s1), len(s2)
    
    # Create a DP table of size (m+1) x (n+1) and initialize with 0s.
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Iterate through the table to fill it bottom-up.
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            # Case 1: Characters match
            if s1[i - 1] == s2[j - 1]:
                dp[i][j] = 1 + dp[i - 1][j - 1]
            # Case 2: Characters do not match
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
                
    # The bottom-right cell contains the length of the LCS.
    # We return the whole table because we'll need it for backtracking.
    return dp

# Example
s1 = "AGGTAB"
s2 = "GXTXAYB"
dp_table = lcs_length(s1, s2)
lcs_len = dp_table[len(s1)][len(s2)]
print(f"The length of the LCS is: {lcs_len}") # Output: 4
Test your understanding!

Given s1 = "ace" and s2 = "abc". Manually fill the 4x4 DP table. What is the value in dp[3][3]?

Show answer

The table would look like this, with s1 on the side and s2 on top:

"" a b c
"" 0 0 0 0
a 0 1 1 1
c 0 1 1 2
e 0 1 1 2

dp[3][3] would be 2. The LCS is "ac".


5. Reconstructing the LCS String

The DP table gives us the length, but what if we want the actual subsequence? For this, we backtrack through the filled DP table, starting from the bottom-right corner.

The following article explains the traceback logic clearly.

Longest Common Subsequence (Problem & Solution)

To find the actual characters of the LCS, we need to backtrack through the table we just built. This article provides a clear explanation of the logic.

Please read the section titled 'Dynamic Programming Approach'. Focus on the part that starts with 'Now that the table is completed we can trace back through the table...'. Understand how the direction of movement (up-left, up, or left) corresponds to how we built the table.

Here's the Python code for the backtracking function. It takes the filled DP table and the original strings as input.

def backtrack_lcs(dp_table, s1, s2):
    lcs = ""
    i, j = len(s1), len(s2)
    
    while i > 0 and j > 0:
        # If characters match, this character is part of the LCS.
        if s1[i - 1] == s2[j - 1]:
            lcs = s1[i - 1] + lcs  # Prepend the character
            i -= 1
            j -= 1
        # If not, move to the cell that gave the max value.
        elif dp_table[i - 1][j] > dp_table[i][j - 1]:
            i -= 1  # Move up
        else:
            j -= 1  # Move left
            
    return lcs

# Continuing our example:
s1 = "AGGTAB"
s2 = "GXTXAYB"
# dp_table is already computed from the previous step

result_lcs = backtrack_lcs(dp_table, s1, s2)
print(f"The LCS is: '{result_lcs}'") # Output: 'GTAB'

Conclusion

Congratulations on completing the Dynamic Programming module! The LCS problem is a powerful demonstration of 2D DP, reinforcing the concepts of optimal substructure and overlapping subproblems that you first saw with the Knapsack problem.

Key Takeaways:

  • The LCS problem seeks the longest subsequence common to two strings, where elements maintain relative order but need not be contiguous.
  • The 2D DP state dp[i][j] effectively stores the solution for prefixes of the strings.
  • The recurrence relation is based on two simple cases: whether the characters at the current positions match or not.
  • Finding the length of the LCS has a time and space complexity of O(m * n), where m and n are the string lengths.
  • The actual subsequence can be reconstructed by backtracking through the filled DP table from the end.

Up Next:
The backtracking we did today to find the LCS string was a specific application of a broader, powerful algorithmic paradigm. In the next module, Backtracking, we will formalize this technique. You will learn the general "choose, explore, un-choose" template that can be used to solve an entirely new class of problems, such as generating all permutations or combinations of a set, and solving classic puzzles like N-Queens and Sudoku.