112.Longest Common Subsequence
Problem
Given two strings text1 and text2, return the length of their longest common subsequence (LCS). If there is none, return 0.
A subsequence is formed by deleting zero or more characters from a string without changing the order of the remaining ones ("ace" is a subsequence of "abcde", "aec" is not). A common subsequence appears in both strings.
Examples
Input: text1 = "abcde", text2 = "ace"
Output: 3
Explanation: The LCS is "ace".Input: text1 = "abc", text2 = "abc"
Output: 3
Explanation: The whole string is common.Input: text1 = "abc", text2 = "def"
Output: 0
Explanation: No characters in common.Constraints
1 <= text1.length, text2.length <= 1000- Both strings consist of lowercase English letters only.
💡 Key Insight
Compare the first characters of the two remaining suffixes. If they match, that character can always be part of an LCS, so take it and advance both: 1 + lcs(i + 1, j + 1). If they differ, at least one of them is not used — try skipping either: max(lcs(i + 1, j), lcs(i, j + 1)). The state is just the pair (i, j), giving an (m+1) x (n+1) table that can be compressed to one row.
Recursion (Brute Force)
TimeO(2^(m+n))SpaceO(m + n)Intuition
Let lcs(i, j) be the LCS length of text1[i..] and text2[j..]. If either suffix is empty, the answer is 0. Otherwise apply the match / skip rule from the key insight.
Algorithm
lcs(i, j):- If
i == morj == n, return0. - If
text1[i] == text2[j], return1 + lcs(i + 1, j + 1). - Else return
max(lcs(i + 1, j), lcs(i, j + 1)).
- If
- Return
lcs(0, 0).
Visualization
flowchart TD
A["lcs(0,0)<br/>'abc' vs 'ac'<br/>a == a"] -->|"match +1"| B["lcs(1,1)<br/>'bc' vs 'c'<br/>b != c"]
B -->|"skip b"| C["lcs(2,1)<br/>'c' vs 'c'<br/>match"]
B -->|"skip c"| D["lcs(1,2)<br/>'bc' vs ''<br/>0"]
C -->|"match +1"| E["lcs(3,2) = 0"]
C --> F["lcs(2,1) = 1"]
F --> G["lcs(1,1) = max(1, 0) = 1"]
G --> H["lcs(0,0) = 1 + 1 = 2"]Code
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
return lcs(text1, text2, 0, 0);
}
private int lcs(String a, String b, int i, int j) {
if (i == a.length() || j == b.length()) {
return 0; // one suffix is empty
}
if (a.charAt(i) == b.charAt(j)) {
return 1 + lcs(a, b, i + 1, j + 1); // use the matching char
}
// drop a char from either string and keep the better result
return Math.max(lcs(a, b, i + 1, j), lcs(a, b, i, j + 1));
}
}Complexity
- Time: O(2^(m+n)) — the mismatch case branches twice and depth is up to
m + n. - Space: O(m + n) — recursion depth.
Top-Down Memoization
TimeO(m · n)SpaceO(m · n)Intuition
Only m · n distinct (i, j) pairs exist, but the plain recursion recomputes them over and over. Store each result in memo[i][j].
Algorithm
memo[m][n]filled with-1.- Same recursion; return
memo[i][j]if it is not-1; otherwise compute, store, return.
Dry Run
text1 = "abcde", text2 = "ace"
lcs(0,0): 'a'=='a' → 1 + lcs(1,1)
lcs(1,1): 'b'!='c' → max(lcs(2,1), lcs(1,2))
lcs(2,1): 'c'=='c' → 1 + lcs(3,2)
lcs(3,2): 'd'!='e' → max(lcs(4,2), lcs(3,3)=0)
lcs(4,2): 'e'=='e' → 1 + lcs(5,3)=1
...memo hits when lcs(1,2), lcs(2,2), ... are revisited
result = 3Code
import java.util.Arrays;
class Solution {
private int[][] memo;
private String a, b;
public int longestCommonSubsequence(String text1, String text2) {
a = text1;
b = text2;
memo = new int[a.length()][b.length()];
for (int[] row : memo) {
Arrays.fill(row, -1);
}
return lcs(0, 0);
}
private int lcs(int i, int j) {
if (i == a.length() || j == b.length()) {
return 0;
}
if (memo[i][j] != -1) {
return memo[i][j];
}
int res;
if (a.charAt(i) == b.charAt(j)) {
res = 1 + lcs(i + 1, j + 1);
} else {
res = Math.max(lcs(i + 1, j), lcs(i, j + 1));
}
memo[i][j] = res;
return res;
}
}Complexity
- Time: O(m · n) — each state is solved once in O(1).
- Space: O(m · n) — memo table plus O(m + n) stack.
Bottom-Up 2-D Tabulation
TimeO(m · n)SpaceO(m · n)Intuition
Let dp[i][j] = LCS of the prefixes text1[0..i) and text2[0..j). Row 0 and column 0 are empty prefixes, so they are 0.
Recurrence:
dp[i][j] = dp[i-1][j-1] + 1iftext1[i-1] == text2[j-1]dp[i][j] = max(dp[i-1][j], dp[i][j-1])otherwise
Algorithm
- Allocate
dp[m+1][n+1](all zeros). - Fill row by row with the recurrence.
- Return
dp[m][n].
Dry Run
text1 = "abcde" (rows), text2 = "ace" (columns):
| "" | a | c | e | |
|---|---|---|---|---|
| "" | 0 | 0 | 0 | 0 |
| a | 0 | 1 | 1 | 1 |
| b | 0 | 1 | 1 | 1 |
| c | 0 | 1 | 2 | 2 |
| d | 0 | 1 | 2 | 2 |
| e | 0 | 1 | 2 | 3 |
Bold cells are diagonal "+1" matches; the answer is dp[5][3] = 3.
Code
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
int[][] dp = new int[m + 1][n + 1]; // row/col 0 = empty prefix = 0
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1; // extend diagonal
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
}Complexity
- Time: O(m · n) — every cell is filled once.
- Space: O(m · n) — the table.
Space-Optimized 1-D DPOptimal
TimeO(m · n)SpaceO(min(m, n))Intuition
Row i reads only row i - 1 (up and up-left) and the current row (left). Keep one array dp of size n + 1; as we sweep j left-to-right, dp[j] still holds "up" before we overwrite it, dp[j-1] is already "left", and we save the old dp[j-1] in a variable prevDiag to serve as "up-left". Iterate over the shorter string for columns to use O(min(m, n)) memory.
Algorithm
- Ensure
text2is the shorter string (swap if needed);dp = new int[n + 1]. - For each
iin1..m:prevDiag = 0; for eachjin1..n:temp = dp[j](this is "up", and will be the next diagonal).- If chars match:
dp[j] = prevDiag + 1; elsedp[j] = max(dp[j], dp[j-1]). prevDiag = temp.
- Return
dp[n].
Dry Run
flowchart TD
A["dp = [0,0,0,0]<br/>cols: '' a c e"] -->|"row 'a'"| B["dp = [0,1,1,1]"]
B -->|"row 'b'"| C["dp = [0,1,1,1]"]
C -->|"row 'c': c==c uses prevDiag=1"| D["dp = [0,1,2,2]"]
D -->|"row 'd'"| E["dp = [0,1,2,2]"]
E -->|"row 'e': e==e uses prevDiag=2"| F["dp = [0,1,2,3]"]
F --> G["answer dp[3] = 3"]Code
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
if (text2.length() > text1.length()) {
String tmp = text1;
text1 = text2;
text2 = tmp; // keep the shorter string as columns
}
int m = text1.length(), n = text2.length();
int[] dp = new int[n + 1];
for (int i = 1; i <= m; i++) {
int prevDiag = 0; // dp[i-1][0]
for (int j = 1; j <= n; j++) {
int up = dp[j]; // dp[i-1][j], becomes next diagonal
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[j] = prevDiag + 1;
} else {
dp[j] = Math.max(up, dp[j - 1]); // up vs left
}
prevDiag = up;
}
}
return dp[n];
}
}Complexity
- Time: O(m · n) — same number of cell updates.
- Space: O(min(m, n)) — a single row over the shorter string.
Complexity Comparison
| Approach | Time | Space |
|---|---|---|
| Recursion (Brute Force) | O(2^(m+n)) | O(m + n) |
| Top-Down Memoization | O(m · n) | O(m · n) |
| Bottom-Up 2-D Tabulation | O(m · n) | O(m · n) |
| Space-Optimized 1-D DP | O(m · n) | O(min(m, n)) |
Common Pitfalls
- Confusing subsequence with substring — on a mismatch LCS takes
max(up, left); it does not reset to0(that is longest common substring). - Off-by-one between
dpindices and string indices:dp[i][j]comparestext1[i-1]andtext2[j-1]. - In the 1-D version, forgetting to save the old
dp[j]before overwriting it — then the diagonal value is lost. - Resetting
prevDiagto0at the start of every row is required (dp[i-1][0]is always0). - On a match, there is no need to also consider skipping — taking the match is never worse.