Topics › Dynamic Programming › DP on Strings
Print Longest Common Subsequence - (DP - 26)
Java source ↗ Edit this article ↗
Jump to code:
Problem
Given two strings s1 and s2, return a longest common subsequence itself, not just its length. A common subsequence is a string obtainable from both s1 and s2 by deleting characters without reordering the rest. When several longest ones exist, any of them is accepted. If the strings share no character, return the empty string.
Constraints: 1 ≤ n, m ≤ 1000 in the usual version; the Java methods also accept empty strings.
Example 1
s1 = "abcde", s2 = "ace"
answer = "ace"
Example 2
s1 = "abaaa", s2 = "baabaca"
answer = "baaa" // "abaa" is equally long and also correct
The idea in one line
Compute the usual LCS length table, then start at the bottom-right cell and ask, at every step, which choice produced this value. Each time the answer is “the two characters matched”, that character belongs to the subsequence. The rest of this article builds up to that, then shows how to do the same in linear memory.
Recall the length recurrence on prefix lengths (f(i, j) = LCS length of s1[0..i) and s2[0..j)):
f(0, j) = f(i, 0) = 0
f(i, j) = 1 + f(i - 1, j - 1) if s1[i - 1] == s2[j - 1]
f(i, j) = max(f(i - 1, j), f(i, j - 1)) otherwise
When the last characters match, some LCS pairs them together; when they differ, at least one of them is unused and can be dropped. Both facts are proved in Longest common subsequence.
Approach 1: Brute force, recursion returning strings
Intuition. Make the recursion return the subsequence instead of its length. On a match, solve the smaller prefixes and append the matched character. On a mismatch, solve both “drop one character” subproblems and keep the longer string. On a tie it keeps the “drop from s2” branch; this rule is chosen so that the result is identical to Approach 2’s walk.
Algorithm.
- If either prefix is empty, return
"". - If the last characters match, return
build(i - 1, j - 1) + s1[i - 1]. - Otherwise compute
up = build(i - 1, j)andleft = build(i, j - 1)and return the longer, preferringlefton ties.
static String bruteForce(String s1, String s2) {
return build(s1.length(), s2.length(), s1, s2);
}
/** One LCS of the prefixes s1[0..i) and s2[0..j). Ties go to the "drop from s2" branch. */
static String build(int i, int j, String s1, String s2) {
if (i == 0 || j == 0) return "";
if (s1.charAt(i - 1) == s2.charAt(j - 1)) return build(i - 1, j - 1, s1, s2) + s1.charAt(i - 1);
String up = build(i - 1, j, s1, s2); // drop s1's last character
String left = build(i, j - 1, s1, s2); // drop s2's last character
return up.length() > left.length() ? up : left;
}
Complexity. Time exponential, O(2^(n + m)) calls in the worst case, and each call may also build a string of length up to min(n, m). Space O(n + m) stack plus the strings in flight. Memoizing strings would make it polynomial, but every cell would then store a string, O(n · m · min(n, m)) characters in total, which is why the standard method stores only lengths.
Approach 2: Tabulation, then walk back
Intuition. The table dp[i][j] of lengths already encodes every decision. Start at (n, m) and retrace:
- If
s1[i - 1] == s2[j - 1], the value came from the diagonal. That character is part of the LCS; write it and move to(i - 1, j - 1). - Otherwise the value came from whichever neighbour is larger,
dp[i - 1][j](move up) ordp[i][j - 1](move left). On a tie either works; the code moves left.
Each step moves up, left, or diagonally, so the walk ends after at most n + m steps at row 0 or column 0. The characters are found from the back, so they are written into a char[] of length dp[n][m] from its last slot to its first, which avoids reversing at the end.
Taking the diagonal on a match, even when a neighbour has the same value, is always safe: the recurrence says a match gives dp[i][j] = 1 + dp[i - 1][j - 1], so following the diagonal keeps us on an optimal path.
Algorithm.
- Fill
dp[n + 1][m + 1]exactly as in the LCS length problem. - Set
i = n,j = m,k = dp[n][m] - 1. - While
i > 0andj > 0: on a match writes1[i - 1]atout[k--]and step diagonally; otherwise step toward the larger of up and left. - Return
new String(out).
static String tabulation(String s1, String s2) {
int n = s1.length(), m = s2.length();
int[][] dp = new int[n + 1][m + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)) dp[i][j] = 1 + dp[i - 1][j - 1];
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
char[] out = new char[dp[n][m]];
int k = out.length - 1, i = n, j = m;
while (i > 0 && j > 0) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)) { // this character is part of the LCS
out[k--] = s1.charAt(i - 1);
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--; // the answer came from the cell above
} else {
j--; // the answer came from the cell on the left
}
}
return new String(out);
}
Dry run on Example 1, s1 = "abcde" (rows), s2 = "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 |
| at (i, j) | characters | move | out |
|---|---|---|---|
| (5, 3) | e = e | diagonal | __e |
| (4, 2) | d ≠ c, up 2 > left 1 | up | __e |
| (3, 2) | c = c | diagonal | _ce |
| (2, 1) | b ≠ a, up 1 > left 0 | up | _ce |
| (1, 1) | a = a | diagonal | ace |
The walk stops at (0, 0) and returns "ace".
Complexity. Time O(n · m) to fill the table plus O(n + m) for the walk. Space O(n · m) for the table. This is the expected interview answer.
Approach 3: Linear space (Hirschberg)
Intuition. The length alone needs only two rows, but the walk back needs the whole table: for two strings of length 10^4 that is 10^8 ints, about 400 MB. Hirschberg’s algorithm recovers the subsequence with only O(n + m) extra memory by dividing and conquering.
Cut s1 in half at mid. Any LCS of the whole problem uses some characters of the top half of s1 matched inside a prefix s2[0..k), and the rest from the bottom half matched inside the suffix s2[k..m), for some split point k. So:
- One forward two-row pass gives
top[k] = LCS(s1[0..mid), s2[0..k))for everykat once (the last row of the table). - One backward two-row pass, running both strings from their right ends, gives
bottom[k] = LCS(s1[mid..n), last k characters of s2). - The best split is the
kmaximisingtop[k] + bottom[m - k]. Recurse on (top half,s2[0..k)) and (bottom half,s2[k..m)) and concatenate the two answers.
The recursion stops when a piece of s1 has one character: its LCS with a piece of s2 is that character if the piece contains it, otherwise empty.
Algorithm.
split(a range, b range): return if either range is empty; handle a single character ofadirectly.- Otherwise compute
cutwithbestCut(one forward and one backward row), then recurse on the left parts and then on the right parts, appending to one sharedStringBuilder.
static String linearSpace(String s1, String s2) {
StringBuilder out = new StringBuilder();
split(s1, 0, s1.length(), s2, 0, s2.length(), out);
return out.toString();
}
/** Appends one LCS of a[aLo..aHi) and b[bLo..bHi) to out. */
static void split(String a, int aLo, int aHi, String b, int bLo, int bHi, StringBuilder out) {
if (aLo >= aHi || bLo >= bHi) return;
if (aHi - aLo == 1) { // one character of a: it is the LCS if b contains it
for (int k = bLo; k < bHi; k++) {
if (b.charAt(k) == a.charAt(aLo)) {
out.append(a.charAt(aLo));
return;
}
}
return;
}
int mid = (aLo + aHi) / 2;
int cut = bestCut(a, aLo, mid, aHi, b, bLo, bHi);
split(a, aLo, mid, b, bLo, cut, out); // LCS = (LCS of the top half) + (LCS of the bottom half)
split(a, mid, aHi, b, cut, bHi, out);
}
/** Where an optimal alignment crosses from a[aLo..mid) into a[mid..aHi): the split point in b. */
static int bestCut(String a, int aLo, int mid, int aHi, String b, int bLo, int bHi) {
int[] top = forwardRow(a, aLo, mid, b, bLo, bHi); // top[k] = LCS(a[aLo..mid), first k chars of b)
int[] bottom = backwardRow(a, mid, aHi, b, bLo, bHi); // bottom[k] = LCS(a[mid..aHi), last k chars of b)
int w = bHi - bLo, best = 0;
for (int k = 1; k <= w; k++) {
if (top[k] + bottom[w - k] > top[best] + bottom[w - best]) best = k;
}
return bLo + best;
}
/** Last row of the LCS table of a[aLo..aHi) against prefixes of b[bLo..bHi), using two rows. */
static int[] forwardRow(String a, int aLo, int aHi, String b, int bLo, int bHi) {
int w = bHi - bLo;
int[] prev = new int[w + 1], cur = new int[w + 1];
for (int i = aLo; i < aHi; i++) {
for (int k = 1; k <= w; k++) {
if (a.charAt(i) == b.charAt(bLo + k - 1)) cur[k] = 1 + prev[k - 1];
else cur[k] = Math.max(prev[k], cur[k - 1]);
}
int[] tmp = prev; prev = cur; cur = tmp;
}
return prev;
}
/** The same scan run from the right ends: row[k] = LCS(a[aLo..aHi), last k chars of b[bLo..bHi)). */
static int[] backwardRow(String a, int aLo, int aHi, String b, int bLo, int bHi) {
int w = bHi - bLo;
int[] prev = new int[w + 1], cur = new int[w + 1];
for (int i = aHi - 1; i >= aLo; i--) {
for (int k = 1; k <= w; k++) {
if (a.charAt(i) == b.charAt(bHi - k)) cur[k] = 1 + prev[k - 1];
else cur[k] = Math.max(prev[k], cur[k - 1]);
}
int[] tmp = prev; prev = cur; cur = tmp;
}
return prev;
}
Complexity. Time O(n · m): the top level scans n × m cells, the next level scans n/2 rows against pieces of s2 whose widths add up to m, and so on, for a total of at most 2 · n · m. Space O(n + m): the two rows are freed when bestCut returns, the recursion is O(log n) deep, and the output has at most min(n, m) characters.
It may return a different LCS from Approach 2 when several exist, since it breaks ties by split point. The tests therefore check that every result is a common subsequence of the right length, and compare exact strings only where the LCS is unique.
Edge cases and pitfalls
- No common character. The table’s last cell is 0, the
char[]is empty, and the method returns""without entering the loop’s write branch. - Several valid answers.
("abcd", "dcba")has four answers of length 1. Do not hard-code one string in tests unless the answer is unique. - Tie-breaking. The walk must step to a neighbour holding the same value as the current cell when characters differ. Moving toward the smaller neighbour loses characters.
- Reversal. The walk produces characters back to front. Either fill a
char[]from the end, as here, or append and reverse once at the end; prepending to aStringin a loop is quadratic. - Stopping condition. Stop when either
iorjreaches 0. Unlike the supersequence problem, leftover characters of the other string are not part of the answer. - Testing. 300 seeded random pairs are checked for all three methods, plus a 400 × 300 pair for the two polynomial methods only.
Related problems
- Longest common subsequence computes the length that this walk relies on.
- Shortest common supersequence uses the same walk but also writes the unmatched characters.
- Longest common substring is the gap-free variant.
- Print Longest Increasing Subsequence recovers a subsequence by storing parent pointers instead of walking a 2D table.