Longest Common Subsequence
Table of Contents + β
Longest Common Subsequence is the parent of many string problems. The interviewer uses it to see if you can compare two strings with a 2D grid. Once you understand this one grid, edit distance and many others become easy.
π― The Problem
You get two strings and you return the length of their longest common subsequence.
- You compare two strings.
- A subsequence keeps the original order but the characters need not sit next to each other.
- You want the longest run of characters that appears in both strings, in the same order.
- You return a length, not the actual characters.
For "abcde" and "ace", the letters a, c, and e all show up in "abcde" in that same order. So the longest common subsequence is "ace", and its length is 3.
Input: text1 = "abcde", text2 = "ace"Output: 3
Explanation: The longest common subsequence is "ace", which has length 3.Here is the matching idea. We line up both strings and pick out the shared letters in order.
π’ Approach 1: Plain Recursion (Brute Force)
Compare the two strings from the front and branch on every mismatch.
The idea:
- Look at the first character of each string.
- If they match, keep it and move both strings forward by one.
- If they differ, try two things and take the bigger result.
How it works:
- Skip the first character of string one, or skip the first of string two.
- When either string runs out, nothing is left to match, so the length is zero.
Why it is weak:
- A mismatch branches into two calls.
- The same pair of positions gets solved again and again.
- Time grows about O(2^(m+n)). Too slow.
Here is the plain recursion code:
def longest_common_subsequence(text1, text2): def dfs(i, j): if i == len(text1) or j == len(text2): return 0 if text1[i] == text2[j]: return 1 + dfs(i + 1, j + 1) return max(dfs(i + 1, j), dfs(i, j + 1))
return dfs(0, 0)β‘ Approach 2: Memoization (Better)
Store each position pair so it is solved only once.
The idea:
- The answer for βstring one from index i, string two from index jβ never changes.
- So save it the first time you compute it.
How it works:
- Keep a 2D table keyed by the two indexes.
- The first time you solve a pair, write it down.
- Next time the pair comes up, read the saved value.
Why it is fast:
- Each position pair runs once.
- Time drops to O(m Γ n), where m and n are the string lengths.
Here is the memoized recursion:
from functools import lru_cache
def longest_common_subsequence(text1, text2): @lru_cache(None) def dfs(i, j): if i == len(text1) or j == len(text2): return 0 if text1[i] == text2[j]: return 1 + dfs(i + 1, j + 1) return max(dfs(i + 1, j), dfs(i, j + 1))
return dfs(0, 0)β‘ Approach 3: Bottom-Up Tabulation (Better)
Build a grid from the smallest cases up, with no recursion.
The idea:
- Make a 2D table
dpwith an extra row and column of zeros. - The cell
dp[i][j]holds the LCS length of the firstiand firstjcharacters.
How it works:
- On a match, the cell is one plus the diagonal cell up and to the left.
- On a mismatch, the cell is the larger of the cell above and the cell to the left.
- Fill the grid in order so every cell already has its three neighbors ready.
Why it is fast:
- Each cell is filled once.
- Time is O(m Γ n). The bottom-right cell is the answer.
Here is the bottom-up table code:
def longest_common_subsequence(text1, text2): dp = [[0] * (len(text2) + 1) for _ in range(len(text1) + 1)]
for i in range(len(text1) - 1, -1, -1): for j in range(len(text2) - 1, -1, -1): if text1[i] == text2[j]: dp[i][j] = 1 + dp[i + 1][j + 1] else: dp[i][j] = max(dp[i + 1][j], dp[i][j + 1])
return dp[0][0]β‘ Approach 4: Space-Optimized Two Rows (Best)
Keep only the two rows the grid rule actually needs.
The idea:
- Each cell reads only the row above and the current row.
- So the whole grid is never needed at once.
How it works:
- Keep a previous row and a current row.
- Fill the current row using the previous row.
- When the row is done, the current row becomes the previous row.
Why it is best:
- Time stays O(m Γ n), just as fast.
- Memory drops from a full grid to two rows, so it is O(n).
Here is the 2D grid filling for "abcde" and "ace". Rows are the first string, columns are the second. Each cell is an LCS length.
Steps to Solve
- Let
mandnbe the lengths of the two strings. - Make a 2D table
dpof size(m + 1)by(n + 1), filled with zeros. The extra row and column stand for empty strings. - Loop
ifrom1tomandjfrom1ton. - If the characters at
i - 1andj - 1match, setdp[i][j]todp[i - 1][j - 1] + 1. - If they do not match, set
dp[i][j]to the larger ofdp[i - 1][j]anddp[i][j - 1]. - After the loops,
dp[m][n]holds the length of the longest common subsequence.
This Python version fills a 2D list with a zero border, then reads the bottom-right cell.
def lcs(a, b): m, n = len(a), len(b) dp = [[0] * (n + 1) for _ in range(m + 1)] # zero border
for i in range(1, m + 1): for j in range(1, n + 1): if a[i - 1] == b[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 # match: diagonal + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # carry best return dp[m][n]
print(lcs("abcde", "ace"))The output of the above code will be:
3Let us walk through the Python version line by line. The why behind each line explains the whole grid.
m, n = len(a), len(b) gets both string lengths. The grid will be sized from these.
dp = [[0] * (n + 1) for _ in range(m + 1)] builds the grid with an extra row and column of zeros. That border stands for an empty string. The LCS with an empty string is always zero, which seeds the whole table.
for i in range(1, m + 1): and for j in range(1, n + 1): walk every pair of prefixes. We start at one, not zero, because row zero and column zero are the empty-string border.
if a[i - 1] == b[j - 1]: checks the current characters. The i - 1 and j - 1 line up the grid index with the real string index, because the grid is shifted by one for the border.
dp[i][j] = dp[i - 1][j - 1] + 1 is the match rule. When the characters match, we extend the answer from the diagonal cell. That diagonal cell is the best length before either of these characters, so adding one is correct.
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) is the mismatch rule. When the characters differ, we drop one character from one string and take the better of the two choices. The cell above means dropping a character from one string, and the cell to the left means dropping from the other.
return dp[m][n] reads the final answer. The bottom-right cell uses both whole strings, so it holds the full LCS length.
β±οΈ Time and Space Complexity
Plain recursion is exponential, since it re-solves the same position pairs. Memoization and 2D tabulation both visit each of the m times n cells once, so they run in O(m times n) time. The space-optimized version keeps only two rows, so it uses O(n) memory while staying just as fast.
| Approach | Time Complexity | Space Complexity |
|---|---|---|
| Plain recursion | O(2^(m+n)) | O(m + n) |
| Memoization | O(m Γ n) | O(m Γ n) |
| Tabulation (2D grid) | O(m Γ n) | O(m Γ n) |
| Space-optimized (two rows) | O(m Γ n) | O(n) |
Tip
The match rule moves on the diagonal, and the mismatch rule looks up and left. If you can say those two rules out loud, you can write the grid for LCS, edit distance, and many cousins.
π§© Key Takeaways
- β A subsequence keeps order but does not need characters next to each other.
- β The grid cell dp[i][j] holds the LCS of the first i and first j characters.
- β On a match, take the diagonal cell plus one.
- β On a mismatch, take the larger of the cell above and the cell to the left.
- β Two rolling rows are enough, which cuts memory from a full grid to one line.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
What is a subsequence in this problem?
Why: A subsequence keeps the original order but the characters do not have to sit next to each other.
- 2
What does dp[i][j] store in the LCS grid?
Why: Each cell holds the LCS length using the first i characters of one string and first j of the other.
- 3
When the two current characters match, what is the rule?
Why: A match extends the LCS from the diagonal cell, so the value is the diagonal plus one.
- 4
When the characters do not match, what is the rule?
Why: On a mismatch you drop one character from one string and keep the better of the two neighbor cells.