Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°37

Longest Common Subsequence

Medium Dynamic ProgrammingStrings

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).

SOLVE IT YOURSELF

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.

YOUR TASK

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.

CPython · WebAssembly
Examples and constraints 5 examples
EXAMPLE 1
Input a = 'abcde', b = 'ace'
Output 3
"ace" appears in both, in order
EXAMPLE 2
Input a = 'aggtab', b = 'gxtxayb'
Output 4
"gtab" is common to both
EXAMPLE 3
Input a = 'abc', b = 'def'
Output 0
nothing in common
EXAMPLE 4
Input a = 'abc', b = 'cba'
Output 1
order matters: all three letters are shared, but only one can line up
EXAMPLE 5
Input a = '', b = 'anything'
Output 0
edge case: the LCS with an empty string is 0
CONSTRAINTS
  • 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
HINTS — 4 IDEAS
  1. Define dp[i][j] = LCS of a[:i] and b[:j]. The empty row and column are all 0.
  2. If a[i-1] == b[j-1], the characters can join the subsequence: dp[i][j] = dp[i-1][j-1] + 1.
  3. Otherwise, skip one character: dp[i][j] = max(dp[i-1][j], dp[i][j-1]).
  4. 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.

stepa[i-1]j = 1 (a)j = 2 (c)j = 3 (e)why
i = 0—000empty prefix of a
i = 1a111a matches at j = 1: dp[0][0] + 1; the 1 carries right
i = 2b111no match: each cell takes the max of above and left
i = 3c122c matches at j = 2: dp[2][1] + 1 = 2
i = 4d122no match: copied from above
i = 5e123e 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 of dp.
  • 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 first a as 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 dp stands for the first i characters, so it compares a[i - 1], not a[i].
  • Matching greedily, taking each character of a at its earliest position in b: "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]: when a[i-1] == b[j-1], that character is in the answer, so record it and step diagonally; otherwise step toward the larger of dp[i-1][j] and dp[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 a in 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 in a outside the LCS and insert everything in b outside 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"))  # -> 4
Reference 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")); // -> 4

Questions 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.

Finished this one? 0 / 76 Challenges done
NextExpression Calculator Next on the “Graphs, strings & parsers” ladder · rung 6 of 8

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges