Medium#11438 min readDynamic Programming2D DPStringsLeetCode

Longest Common Subsequence

Match-or-skip 2D DP with 1D row collapse

The classic 2D string DP. Understand match vs. skip transitions.

Published Apr 17, 2026

The full write-up: intuition, approaches, traces and solutions.

Problem

Given two strings text1 and text2, return the length of their longest common subsequence (LCS). A subsequence keeps order but can drop characters. If there is none, return 0.

Input
Output
3
Explanation
The LCS is 'ace'.
Input
Output
3
Explanation
Equal strings share their entire length.
Input
Output
0
Explanation
No characters in common.

Intuition

Consider the last characters. There are exactly two cases:

  • They match: text1[i - 1] == text2[j - 1]. Then they both belong to an LCS, so dp[i][j] = 1 + dp[i - 1][j - 1].
  • They differ: at least one of them is not in the LCS. So either drop the last of text1 or drop the last of text2, whichever gives the longer LCS: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]).

Base: dp[0][j] = dp[i][0] = 0 (empty strings share zero characters).

This "match-or-skip" template is the canonical 2D string DP. It's simple to derive, and once you recognize the shape you can write it in your sleep:

if s1[i-1] == s2[j-1]:
    dp[i][j] = 1 + dp[i-1][j-1]
else:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])

Like #62's grid DP, transitions only touch the row above and the cell to the left in the current row. Collapse to a 1D buffer: iterate j left-to-right, but cache the old dp[j - 1] from the row above before you overwrite it — that's the "diagonal" input the match branch needs.

Approach

2D Table

TimeO(m · n)
SpaceO(m · n)

Direct translation. Easy to print and debug, heaviest on memory. Use .as_bytes() in Rust to avoid panicking on Unicode boundaries; LC inputs are ASCII.

impl Solution {
    pub fn longest_common_subsequence(text1: String, text2: String) -> i32 {
        let a = text1.as_bytes();
        let b = text2.as_bytes();
        let m = a.len();
        let n = b.len();
        let mut dp = vec![vec![0i32; n + 1]; m + 1];
        for i in 1..=m {
            for j in 1..=n {
                dp[i][j] = if a[i - 1] == b[j - 1] {
                    dp[i - 1][j - 1] + 1
                } else {
                    dp[i - 1][j].max(dp[i][j - 1])
                };
            }
        }
        dp[m][n]
    }
}
Rust

1D Rolling Buffer

TimeO(m · n)
SpaceO(min(m, n))
Recommended

Iterate one string along the outer loop, the other along the inner. Before overwriting dp[j], save it in prev_diag — that's the value that will become dp[i - 1][j - 1] on the next iteration. A small amount of bookkeeping for a substantial memory win.

impl Solution {
    pub fn longest_common_subsequence(text1: String, text2: String) -> i32 {
        // Iterate over the shorter string on the inner axis.
        let (a, b) = if text1.len() < text2.len() {
            (text2.as_bytes(), text1.as_bytes())
        } else {
            (text1.as_bytes(), text2.as_bytes())
        };
        let n = b.len();
        let mut dp = vec![0i32; n + 1];
        for &ai in a {
            let mut prev_diag = 0;
            for j in 1..=n {
                let saved = dp[j];
                if ai == b[j - 1] {
                    dp[j] = prev_diag + 1;
                } else {
                    dp[j] = dp[j].max(dp[j - 1]);
                }
                prev_diag = saved;
            }
        }
        dp[n]
    }
}
Rust

Trace

2D fill on text1 = "abcde", text2 = "ace":

i (text1[i-1])j=1 'a'j=2 'c'j=3 'e'
1 'a'111
2 'b'111
3 'c'122
4 'd'122
5 'e'123

Return dp[5][3] = 3. ✓ (The LCS is "ace".)

Edge cases

  • Either string empty — return 0. The base row/column of zeros handles this without a special case.
  • Equal strings — return the length. Every diagonal match adds one.
  • No common character — return 0. The match branch never fires; the max-of-neighbors propagates zero forward.
  • Huge inputs (LC caps at 1000 × 1000) — the 2D form uses ~4 MB; the 1D form uses ~4 KB. Prefer the 1D form when memory is tight.
💡
Tip

LCS (max, +1) and Edit Distance (min, +1) share this skeleton. If you see "compare two strings character-by-character with a match/mismatch decision," reach for the 2D table first, then collapse.

Takeaway

The match-or-skip 2D string DP is a template you'll use for the rest of your career. Every variant — LCS (#1143), Edit Distance (#72), Delete Operations (#583), Shortest Common Supersequence — is the same recurrence with a different aggregator.

The row-collapse trick you learned in #62 Unique Paths applies unchanged: transitions touch one cell above and one to the left, so a single row buffer (plus the diagonal cache) is enough. Pair that with a shorter-axis-inside iteration and you get O(m · n) time and O(min(m, n)) memory — the production form.

More in 2D DP