Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Longest Common Subsequence
The DP at the heart of "git diff", DNA alignment and spell-correction: the length of the longest subsequence common to two strings (characters in order, gaps allowed). A classic 2-D table fills in O(m·n). Solve it in Python or TypeScript, with hidden tests.
The problem
Given two strings a and b, return the length of their longest common subsequence — the longest sequence of characters appearing in both, left-to-right, not necessarily contiguous. This is the core of diff tools (the LCS of two files is the set of unchanged lines).
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement lcs(a, b): fill a table where dp[i][j] is the LCS length of the first i chars of a and first j of b. On a matching char, extend the diagonal; otherwise take the best of dropping one char from either string.
Examples and constraints 5 examples
a = 'abcde', b = 'ace'3a = 'aggtab', b = 'gxtxayb'4a = 'abc', b = 'def'0a = 'abc', b = 'cba'1a = '', b = 'anything'0- 0 ≤ a.length, b.length; either may be empty (then the answer is 0).
- Subsequence ≠ substring: characters keep their order but may have gaps.
- Aim for O(m·n) time with a 2-D DP table (or a rolling 1-D row).
Hints 4 ideas
- Define
dp[i][j]= LCS ofa[:i]andb[:j]. The empty row and column are all 0. - If
a[i-1] == b[j-1], the characters can join the subsequence:dp[i][j] = dp[i-1][j-1] + 1. - Otherwise, skip one character:
dp[i][j] = max(dp[i-1][j], dp[i][j-1]). - The answer is
dp[m][n]. You can compress the table to two rows if you like.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious approach is the recursion straight from the definition: compare the first characters; if they match, count 1 and recurse on both remainders, otherwise return the better of skipping a character of a or of b. It is correct, because an LCS either uses that matching pair or leaves out at least one of the two characters. Without memoization it costs O(2^(m+n)) time when nothing matches, since every call branches twice, plus O(m + n) stack. It is too slow because it solves the same pair of suffixes again and again: two 12-character strings with nothing in common already take 5,408,311 calls, and at 20 characters each it is about 276 billion.
The approach
The insight is that only the last characters of two prefixes matter. If a[i-1] == b[j-1], matching them is never worse, so the answer is one more than the LCS of both shorter prefixes. If they differ, at least one of them is not in the LCS, so the answer is the better of dropping one from a or one from b. There are only (m + 1)(n + 1) prefix pairs, so compute each once and look it up.
The reference allocates dp as (m + 1) × (n + 1) zeros, where dp[i][j] is the LCS of a[:i] and b[:j]; row 0 and column 0 are the empty prefixes. It fills rows top to bottom and columns left to right, which is the invariant that makes it work: when cell (i, j) is computed, the cells above, to the left and diagonally up-left are already final. A match sets dp[i][j] = dp[i - 1][j - 1] + 1; a mismatch sets max(dp[i - 1][j], dp[i][j - 1]). The answer is dp[m][n].
Walk through an example
The first example, a = "abcde" and b = "ace", one table row per character of a; the bottom-right cell is the answer.
| step | a[i-1] | j = 1 (a) | j = 2 (c) | j = 3 (e) | why |
|---|---|---|---|---|---|
| i = 0 | — | 0 | 0 | 0 | empty prefix of a |
| i = 1 | a | 1 | 1 | 1 | a matches at j = 1: dp[0][0] + 1; the 1 carries right |
| i = 2 | b | 1 | 1 | 1 | no match: each cell takes the max of above and left |
| i = 3 | c | 1 | 2 | 2 | c matches at j = 2: dp[2][1] + 1 = 2 |
| i = 4 | d | 1 | 2 | 2 | no match: copied from above |
| i = 5 | e | 1 | 2 | 3 | e matches at j = 3: dp[4][2] + 1 = 3 = dp[5][3], the answer |
Complexity
DP: O(m·n) time, one constant-time cell per pair of prefixes, and O(m·n) space for dp; keeping only two rows cuts that to O(min(m, n)) when only the length is needed. Brute force: the plain recursion is O(2^(m+n)) time when nothing matches, because every call branches twice, with O(m + n) stack depth.
Edge cases to test
- Either string empty:
lcs("", "anything")is 0, read straight from the zero row ofdp. - Same letters in a different order:
lcs("abc", "cba")is 1, not 3. Counting shared characters ignores order and over-counts. - Repeated characters:
lcs("aaaa", "aa")is 2, because each position can be matched only once. - Greedy matching fails on
a = "aba",b = "bac": matching the firstaas early as possible leaves nothing usable after it and gives 1, while the answer is 2 ("ba"). - Case matters:
lcs("ABC", "abc")is 0, since characters compare exactly.
Common mistakes
- Confusing subsequence (not contiguous) with substring (contiguous) — different problems.
- Getting the mismatch recurrence wrong (it is the max of the two “drop one” options).
- Indexing the strings with the table indices: row i of
dpstands for the first i characters, so it comparesa[i - 1], nota[i]. - Matching greedily, taking each character of
aat its earliest position inb:"aba"against"bac"gives 1 instead of 2.
Follow-ups an interviewer asks next
- Return the subsequence itself, not just its length.
- Keep the full table and walk back from
dp[m][n]: whena[i-1] == b[j-1], that character is in the answer, so record it and step diagonally; otherwise step toward the larger ofdp[i-1][j]anddp[i][j-1]. Reverse the collected characters at the end. The walk is O(m + n) after the O(m·n) fill, and returns"ace"for the first example. - Both strings have 100,000 characters.
- The full table would have about 10^10 cells, far too much memory. For the length alone, keep only the previous and current rows, O(min(m, n)) space, because each cell reads only the row above and the cell to its left. To recover the subsequence in linear space too, Hirschberg’s algorithm splits
ain half, finds where the optimal path crosses the middle row with two linear-space passes, and recurses, still in O(m·n) time. - Turn it into a line-based diff of two files.
- Treat each line as one symbol, usually by hashing lines to integers so comparisons are cheap. Lines in the LCS are unchanged; lines of the old file outside it are deletions and lines of the new file outside it are insertions. Diff tools also avoid the full table: Myers’ algorithm runs in O((m + n) · D) time, where D is the size of the edit, which is fast for files that differ a little.
- How does it relate to edit distance?
- If the only edits allowed are inserting and deleting characters, the minimum number of edits is
m + n - 2 · lcs(a, b): delete everything inaoutside the LCS and insert everything inboutside it. For"abcde"and"ace"that is 5 + 3 − 6 = 2. Allowing substitutions too gives Levenshtein distance, which fills the same table shape with a different recurrence.
Where this shows up
LCS is the core of diff tools — git diff and file comparison find the longest common subsequence, and everything not in it is an insertion or deletion. The same DP underlies version control, DNA sequence alignment, and the whole edit-distance family.
Reference solution
Reference solution — Python
def lcs(a, b):
m, n = len(a), len(b)
dp = [[0] * (n + 1) for _ in range(m + 1)] # dp[i][j] = LCS(a[:i], b[:j])
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: extend the diagonal
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # drop one char
return dp[m][n]
print(lcs("abcde", "ace")) # -> 3
print(lcs("aggtab", "gxtxayb")) # -> 4Reference solution — TypeScript
function lcs(a: string, b: string): number {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; // match
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); // drop one
}
}
return dp[m][n];
}
console.log(lcs("abcde", "ace")); // -> 3
console.log(lcs("aggtab", "gxtxayb")); // -> 4Questions about Longest Common Subsequence
What is the difference between longest common subsequence and longest common substring?
A subsequence keeps the order of characters but may skip characters in between; a substring must be contiguous. For abcde and ace, the longest common subsequence is ace, length 3, while the longest common substring is a single character, length 1. The tables look alike, but the substring version resets a cell to 0 on a mismatch instead of taking the max of its neighbors, and its answer is the largest cell anywhere in the table rather than the bottom-right corner.
Why does the LCS table have one extra row and column?
Row 0 and column 0 stand for the empty prefix of each string, and the LCS of anything with an empty string is 0. Having those zeros in place lets every real cell use the same recurrence, reading the cell diagonally up-left, the cell above and the cell to the left, without checking whether it sits on the boundary. Row i then describes the first i characters of a, which is why the code compares a[i - 1] and b[j - 1]. The answer is in the last cell, dp[m][n].
How does git diff use the longest common subsequence?
A diff is the other side of an LCS. Treat each line of a file as one symbol, and the lines in a longest common subsequence of the old and new versions are the unchanged lines; every other line is a deletion from the old file or an insertion into the new one. Git does not fill an m by n table, though. Its default algorithm is based on Myers diff, which finds a shortest edit script in time proportional to the file sizes times the number of differences, and Git adds speed heuristics, so a diff is not guaranteed minimal unless you pass the minimal option.
Can the longest common subsequence be computed faster than O(m·n)?
Not by much for arbitrary strings. The best known general algorithms shave only logarithmic factors off the quadratic table, and results in fine-grained complexity show that a truly subquadratic algorithm, one running in time n to the power 2 minus epsilon for two strings of length n, would refute the Strong Exponential Time Hypothesis. Special cases are faster: Hunt-Szymanski is quick when few character pairs match, and Myers diff is quick when the two strings are nearly identical. For the length alone, two rows of the table bring memory down to linear.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Number of IslandsThe canonical connected-components question. Flood-fill each unvisited patch of land with BFS or DFS, count how many floods you started — grid traversal, visited bookkeeping, and the classic diagonal trap. Solve it in Python or TypeScript.Read →
- Merge IntervalsCalendar apps and memory allocators run on this: sort by start, sweep once, grow or close the current block. Touching endpoints merge, nested intervals vanish — the classic that punishes off-by-one thinking. Solve it in Python or TypeScript.Read →
- Top-K Frequent ElementsCount, then rank — the two-step pattern behind trending topics and hot-key detection. A hash map counts; a (-count, value) sort ranks with a clean tie-break; buckets get you to O(n) if you want the flex. Solve it in Python or TypeScript.Read →