Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsintermediate 75m read

L36 · DP IV — Subsequences (LIS / LCS)

Longest Increasing Subsequence and Longest Common Subsequence are the two parent problems behind a huge family. Know both cold and most subsequence DP becomes recognition.

🧩DSAPhase 2 · Dynamic programming· Session 036 of 130 75 min

🎯 Own the two parent subsequence problems — LIS and LCS — so completely that the dozen problems built on them become recognition instead of invention.

Series: LeetCode — From Basics to Interview-Ready · Session 36 / 65 · Phase 2 · Dynamic programming

Prerequisites

You need L33–L35: what a DP state is, how a recurrence is derived, and the memo→table transition. If "define the state in one English sentence" does not yet feel automatic, re-do L33 first. Everything here is that skill applied to two specific state shapes.

Watch first (skim before the session)

Why this session exists

Two problems generate an entire family. Longest Increasing Subsequence (LIS) asks: inside one array, what is the longest chain that keeps going up? Longest Common Subsequence (LCS) asks: across two strings, what is the longest sequence you can read off both, in order, without rearranging?

Once you can write both from memory, a surprising number of "new" problems collapse into one of them plus a twist: Longest Palindromic Subsequence is just LCS of a string with its own reverse. Edit Distance (L40) is LCS with a cost twist. Russian Doll Envelopes is LIS after a sort. Maximum Length of Pair Chain is LIS in disguise. The leverage here is enormous — two templates unlock a dozen mediums.

Subsequence vs substring
🌍 Real world
A subsequence is what you get by deleting some letters from a word without reordering the rest — like reading only the capital letters in a ransom note. A substring is a contiguous run. 'ace' is a subsequence of 'abcde' but not a substring. Interviewers love this distinction because candidates conflate the two under pressure.
💻 Code world
s = "abcde" # subsequence: pick indices in increasing order, skip freely # a _ c _ e -> "ace" (valid subsequence) # substring: must be contiguous # "ace" is NOT a substring of "abcde"

Blank-file warm-up

Five minutes, empty file, no notes. Write both cores from memory:

LIS:  dp[i] = 1 + max(dp[j] for j < i if a[j] < a[i]), else 1
LCS:  dp[i][j] = dp[i-1][j-1] + 1        if a[i-1] == b[j-1]
                 max(dp[i-1][j], dp[i][j-1])   otherwise

If you cannot reproduce these cold, you do not know the session yet. Review, then start.

LIS from first principles

From first principles
Start with the question
Why does dp[i] = 1 + max over earlier smaller elements give the LIS length?
  1. 1
    Define dp[i] = length of the longest increasing subsequence that ENDS exactly at index i.
    forced by · Anchoring the subsequence at its last element removes ambiguity: every increasing subsequence ends somewhere, so the global answer is max(dp).
  2. 2
    Any increasing subsequence ending at i has some previous element at index j < i with a[j] < a[i], OR i is the very first element of its chain.
    forced by · That previous element must itself be the end of a valid increasing subsequence — of length dp[j].
  3. 3
    So dp[i] = 1 + max(dp[j]) over all j < i with a[j] < a[i]; if no such j, dp[i] = 1.
    forced by · We extend the best compatible chain by one (the +1 for element i itself).
  4. 4
    The global answer is max(dp[i]) over all i, not dp[n-1].
    forced by · The longest chain need not end at the last element.
⇒ Therefore
LIS is take/skip DP where 'compatible' means strictly larger, and the answer is the best chain-ending value anywhere in the array.
def length_of_lis(nums: list[int]) -> int:
    n = len(nums)
    dp = [1] * n                       # every element is a chain of length 1 by itself
    for i in range(n):
        for j in range(i):
            if nums[j] < nums[i]:      # strictly increasing
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp) if dp else 0        # answer can end anywhere
 
assert length_of_lis([10, 9, 2, 5, 3, 7, 101, 18]) == 4   # [2,3,7,101] or [2,3,7,18]
assert length_of_lis([0, 1, 0, 3, 2, 3]) == 4
assert length_of_lis([7, 7, 7, 7]) == 1

That is O(n²). Interviewers who ask LIS almost always escalate to: can you do better than n²? The answer is O(n log n) with patience sorting.

from bisect import bisect_left
 
def length_of_lis_fast(nums: list[int]) -> int:
    tails: list[int] = []              # tails[k] = smallest possible tail of an increasing subseq of length k+1
    for x in nums:
        i = bisect_left(tails, x)      # first tail >= x
        if i == len(tails):
            tails.append(x)            # x extends the longest chain
        else:
            tails[i] = x               # x gives a smaller tail for a length-(i+1) chain
    return len(tails)                  # length of tails == LIS length
 
assert length_of_lis_fast([10, 9, 2, 5, 3, 7, 101, 18]) == 4

LCS from first principles

From first principles
Start with the question
Why does the LCS recurrence branch on whether the last characters match?
  1. 1
    Define dp[i][j] = LCS length of a[:i] (first i chars of a) and b[:j] (first j chars of b).
    forced by · Prefix-by-prefix is the natural decomposition for two-string DP: shrink one prefix at a time.
  2. 2
    If a[i-1] == b[j-1], those two characters can BOTH be the last character of a common subsequence, so dp[i][j] = dp[i-1][j-1] + 1.
    forced by · Matching a shared last character is never worse than not matching it — a classic exchange argument.
  3. 3
    If they differ, at least one of them is not in the LCS, so dp[i][j] = max(dp[i-1][j], dp[i][j-1]).
    forced by · We try dropping the last char of a, or the last char of b, and keep the better result.
  4. 4
    Base case dp[0][j] = dp[i][0] = 0.
    forced by · An empty prefix shares nothing with anything.
⇒ Therefore
LCS is a 2D grid DP: match the diagonal +1, else take the better of the two neighbours. This exact shape reappears in Edit Distance, Distinct Subsequences, and Shortest Common Supersequence.
def lcs(a: str, b: str) -> int:
    m, n = len(a), len(b)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    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        # matched last chars
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    return dp[m][n]
 
assert lcs("abcde", "ace") == 3        # "ace"
assert lcs("abc", "abc") == 3
assert lcs("abc", "def") == 0

The table has O(mn) cells, each filled in O(1), so O(mn) time and space. Space drops to O(n) with two rolling rows — a common follow-up.

Mental model

Mental modelTwo parents, many children
A family tree with exactly two roots. LIS sits over one array; LCS sits over two strings. Below them hang the children: LPS = LCS(s, reverse(s)); Edit Distance = LCS with insert/delete/replace costs; Russian Doll Envelopes = sort then LIS; Max Length of Pair Chain = LIS on intervals. When a subsequence problem appears, first ask: is this LIS-shaped (one sequence, monotone condition) or LCS-shaped (two sequences, alignment)?
  • One sequence + a 'keeps going up/down' condition ⇒ LIS family.
  • Two sequences aligned in order ⇒ LCS family.
  • A palindrome sub-question ⇒ LCS of the string with its reverse.
  • If the O(n^2) LIS DP is accepted, offer the O(n log n) patience version as the follow-up before they ask.
🔔 Fires when you see
The word 'subsequence' plus either a monotonicity rule (LIS) or a second string (LCS).

Memory hook

"LIS ends here; LCS matches the corner." LIS DP is anchored at the ending index (answer is max(dp), not dp[-1]). LCS DP branches on the corner diagonal cell (dp[i-1][j-1]) when the last characters match. Say the sentence out loud twice; it encodes the one detail people forget about each — where LIS's answer lives, and which neighbour LCS reaches on a match.

Common misconception
✗ What most people think
The LIS answer is dp[n-1] (the value at the last index), just like most 1D DP.
✓ What is actually true
It is max(dp) over the whole array. The longest increasing chain can end at any element, not necessarily the last one.
Why the myth is so sticky
In [10, 9, 2, 5, 3, 7, 101, 18] the best chain ends at 101 (index 6), not at 18 (index 7). Reading dp[-1] gives 4 here by luck, but on [5, 6, 1] it gives 1 instead of 2.
Prove it to yourself
Ask yourself: could a shorter suffix drag the answer down if I read dp[-1]? If yes, you must take the max.

What interviewers actually ask

Real problems that live in this family, with what the interviewer is probing.

  • LC300 · Longest Increasing SubsequenceMicrosoft, Amazon, Google. They want the O(n²) DP first (proves you can define the state), then push for O(n log n). The probe: can you explain why patience sorting's tails length equals the LIS length? Follow-up: reconstruct the actual subsequence, or count how many distinct LIS there are (LC673).
  • LC1143 · Longest Common SubsequenceAmazon, Adobe. The canonical 2D-string DP. Probe: the match-vs-mismatch branch and the base row/column. Follow-up: reduce space to O(min(m,n)) with rolling rows, or return the subsequence itself.
  • LC516 · Longest Palindromic SubsequenceAmazon. The "aha" is that it equals LCS(s, reverse(s)). Probe: do you see the reduction, or do you reinvent an interval DP? Both are accepted; the reduction is the elegant answer.
  • LC1035 · Uncrossed LinesAmazon. Word-dressed LCS on two integer arrays. Probe: can you strip the story and recognise the parent problem?
  • LC354 · Russian Doll EnvelopesGoogle, Amazon (Hard). Sort by width ascending, height descending, then LIS on heights. Probe: why the descending tie-break on height matters (it forbids stacking two envelopes of equal width). This one separates people who memorised LIS from people who understand it.
  • LC673 · Number of Longest Increasing SubsequencesAmazon. LIS with a parallel count array. Probe: careful bookkeeping when dp[j] + 1 == dp[i] (add counts) vs > dp[i] (reset).

The universal escalation ladder here: n² DP → n log n (LIS) or O(min(m,n)) space (LCS) → reconstruct the actual sequence → count how many optimal sequences exist.

A worked reduction: Longest Palindromic Subsequence

Brute force would try every subsequence — exponential. The insight: a palindrome reads the same forwards and backwards, so the longest palindromic subsequence of s is exactly the longest subsequence common to s and reverse(s).

def longest_palindrome_subseq(s: str) -> int:
    return lcs(s, s[::-1])            # reuse the LCS we already wrote
 
assert longest_palindrome_subseq("bbbab") == 4   # "bbbb"
assert longest_palindrome_subseq("cbbd") == 2    # "bb"
  • Complexity: O(n²) time and space (LCS of two length-n strings).
  • Follow-up they escalate to: do it with an interval DP directly (dp[i][j] over substring s[i..j]) and reduce space. Both are fine; leading with the reduction shows range.

Tradeoff

The tradeoff
For LIS, which implementation do you reach for in an interview?
O(n^2) DP
+ you gain Trivial to derive on the spot, easy to reconstruct the actual subsequence with parent pointers, hard to get wrong.
− you pay Too slow if n is up to 1e5.
pick when when n is small (≤ a few thousand) or the interviewer also wants the subsequence itself.
O(n log n) patience sorting
+ you gain Fast enough for n up to 1e5+, and it is the 'expected' answer for LC300.
− you pay Reconstruction needs extra bookkeeping; the tails array is easy to misexplain.
pick when when constraints show large n, or after you have given the n^2 version and they ask for better.
What a senior engineer actually does
State the O(n^2) DP to prove understanding, then offer O(n log n). Never lead with patience sorting cold — if you fumble the tails explanation you look like you memorised it.

In practice

Compilers and version-control diffs lean on LCS: the classic Unix diff computes a longest common subsequence of the two files' lines to find the minimal set of edits. That is why git diff output on a shuffled file can look surprisingly jumpy — the algorithm is optimising for the longest shared line-subsequence, not for what a human would call "the obvious change." Understanding LCS is understanding why diffs sometimes land where they do.

You are ready to move on when you can
  • Write the O(n^2) LIS DP from memory and explain why the answer is max(dp), not dp[-1].
  • Explain why patience sorting's tails-length equals the LIS length, and admit tails is not the subsequence.
  • Write the LCS grid DP and state the match vs mismatch branches from first principles.
  • Reduce Longest Palindromic Subsequence to LCS(s, reverse(s)) unprompted.
  • Name the descending-height tie-break in Russian Doll Envelopes and say why it is needed.
Check yourself · click to reveal
★ = stretch question

Practice queue

Solve in this order; log each as cold / warm / hint / failed for your spaced queue.

  1. LC300 Longest Increasing Subsequence — the parent; do both n² and n log n.
  2. LC1143 Longest Common Subsequence — the other parent; then add rolling-row space reduction.
  3. LC516 Longest Palindromic Subsequence — reduce to LCS.
  4. LC1035 Uncrossed Lines — LCS in disguise.
  5. LC673 Number of Longest Increasing Subsequences — LIS with counts.
  6. LC354 Russian Doll Envelopes — sort + LIS, the boss of this session.
Key points