Subsequence DP
State is "best subsequence ending at index i"; transition scans all earlier j that can precede i.
Overview
Subsequence DP handles problems about choosing elements in order but not necessarily contiguously, where the validity of adding element i depends only on the previous chosen element j. The state is dp[i] = best subsequence that ends exactly at i; the transition is dp[i] = best over j < i with compatible(j, i) of dp[j] + gain(i); the answer is the best dp[i] overall. Longest Increasing Subsequence is the canonical example (compatible = a[j] < a[i]).
The generic form is O(n²), suitable for n ≤ 5000. When compatible is an ordering (<, ≤) and gain is uniform, the patience-sorting / binary-search technique gives O(n log n) for Longest Increasing Subsequence; a Fenwick Tree or Segment Tree indexed by value gives O(n log n) for weighted variants (max-sum increasing subsequence).
Members: LIS and its count, longest divisible subset, longest chain of pairs, Russian doll envelopes (sort by width, LIS on height), longest arithmetic subsequence (dp[i][diff]), maximum sum increasing subsequence, and number of LIS.
Intuition
A mental model before the formal terms.
Each element asks "which earlier element would I most like to follow?" and inherits that element's chain plus one. For [10, 9, 2, 5, 3, 7, 101, 18], 7 can follow 2, 5 or 3 (all smaller); the best of those chains has length 2, so dp[7] = 3. The answer is the longest chain anyone managed to build, not the last one.
Contrast with 1D (Linear) DP prefix DPs: here dp[i] is deliberately about subsequences that must include i, because "the previous chosen element is j" is the only history that matters and pinning i makes that history explicit.
How it works
- State:
dp[i]= optimal subsequence value ending at indexi(and including it). Sometimes a second dimension carries the "previous difference" or "previous element index" for two-element constraints. - Transition:
dp[i] = gain(i) + max(0 or base, dp[j] for j < i if compatible(j, i)). For counting, sum instead of max, and track ties for "number of longest". - Base case:
dp[i] = gain(i)(a subsequence of just elementi). - Order: increasing
i. Answer:max over i of dp[i]. Optimization: replace the inner scan with a data structure keyed by value (binary search on "tails", Fenwick tree for prefix max) whencompatibleis a threshold ona[j].
Why it works
Removing the last element of an optimal subsequence ending at i leaves an optimal subsequence ending at some compatible j — otherwise swapping in a better one would improve the original. The transition enumerates all j, so it is exact.
Compatibility depends only on (j, i), not on earlier elements, so dp[j] is a sufficient summary of the chain before j.
Recognition
How to tell a problem wants this.
- The word subsequence together with a pairwise condition on consecutive chosen elements (increasing, divisible, differing by
d, non-overlapping intervals). n ≤ 5000suggestsO(n²);n ≤ 10^5with a simple ordering suggestsO(n log n)patience sorting.- A greedy "take it if it extends the current chain" fails on inputs like
[1, 5, 2, 3, 4].
Interactive visualization
Play, step, change the input. ← → and space work too.
Showing the closely related Longest Increasing Subsequence visualization.
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
1dp[i] = 1 for all i // LIS ending at i2for i in 1 .. n-1:3 for j in 0 .. i-1:4 if a[j] < a[i] and dp[j] + 1 > dp[i]:5 dp[i] = dp[j] + 1; prev[i] = j6answer = max(dp); reconstruct via prevPseudocode
1# LIS, O(n^2): dp[i] = length of longest increasing subsequence ending at i2dp = [1] * n3for i in 0..n-1:4 for j in 0..i-1:5 if a[j] < a[i]: dp[i] = max(dp[i], dp[j] + 1)6return max(dp)Implementations
1from bisect import bisect_left2 3# Subsequence DP: the state is a position in each sequence, and the choice at4# every step is "does this element participate". Representative example:5# longest common subsequence, with reconstruction and the two-row variant.6 7 81 · dp[i][j] = LCS length of a[0..i) and b[0..j)9def lcs_table(a: str, b: str) -> list[list[int]]:10 n, m = len(a), len(b)11 dp = [[0] * (m + 1) for _ in range(n + 1)]12 for i in range(1, n + 1):13 ai = a[i - 1]14 row, prev_row = dp[i], dp[i - 1]15 for j in range(1, m + 1):162 · Characters match: extend the diagonal. Otherwise drop one side.17 row[j] = prev_row[j - 1] + 1 if ai == b[j - 1] else max(prev_row[j], row[j - 1])18 return dp19 20 213 · Reconstruction walks the table backwards, retracing the choices22def lcs(a: str, b: str) -> str:23 dp = lcs_table(a, b)24 out: list[str] = []25 i, j = len(a), len(b)26 while i > 0 and j > 0:27 if a[i - 1] == b[j - 1]:28 out.append(a[i - 1])29 i -= 130 j -= 131 elif dp[i - 1][j] >= dp[i][j - 1]:32 i -= 133 else:34 j -= 135 return "".join(reversed(out))36 37 384 · Only the previous row is read, so two rows suffice for the length39def lcs_length(a: str, b: str) -> int:40 shorter, longer = (a, b) if len(a) <= len(b) else (b, a)41 prev = [0] * (len(shorter) + 1)42 cur = [0] * (len(shorter) + 1)43 for lc in longer:44 for j in range(1, len(shorter) + 1):45 cur[j] = prev[j - 1] + 1 if lc == shorter[j - 1] else max(prev[j], cur[j - 1])46 prev, cur = cur, prev47 return prev[len(shorter)]48 49 505 · A different subsequence shape: longest increasing subsequence in O(n log n)51def lis(a: list[int]) -> int:52 tails: list[int] = [] # tails[k] = smallest tail of a length-(k+1) increasing subsequence53 for x in a:54 i = bisect_left(tails, x)55 if i == len(tails):56 tails.append(x)57 else:58 tails[i] = x59 return len(tails)[[0] * (m + 1) for _ in range(n + 1)]builds distinct rows;[[0] * (m+1)] * (n+1)would alias one row.row, prev_row = dp[i], dp[i - 1]hoists both rows into locals before the inner loop, removing two list indexings per iteration — a significant win in CPython.ai = a[i - 1]similarly hoists the character out of the inner loop.prev, cur = cur, prevswaps the two list *references* in O(1).bisect_left(tails, x)is exactly the LIS binary search, C-implemented — this is one place where Python has the primitive and JavaScript does not.
The nested loop is pure Python and is the slow part; difflib.SequenceMatcher is C-backed and solves a closely related problem much faster.
bisect_leftgives strictly-increasing LIS;bisect_rightgives non-decreasing — a one-word switch between two different problems.- Hoisting
dp[i]anddp[i-1]into locals is the standard CPython optimisation for a 2D DP and typically gives 2-3x. "".join(reversed(out))is the idiomatic reverse-and-join;reversed()returns an iterator thatjoinconsumes without an intermediate list.difflib.SequenceMatchercomputes matching blocks (a related but not identical notion) in C and is worth knowing before hand-rolling LCS.
- Building the table with
[[0] * m] * nand aliasing every row. - Using
bisect_rightwhen strictly increasing was meant, silently answering a different question. - Indexing
dp[i][j]directly in the inner loop rather than hoisting the rows, which is several times slower for no reason.
- Binary search for LIS is a standard-library call in C++ (
std::lower_bound) and Python (bisect_left), and must be written out in JS/TS — the same split as everywhere else. - Swapping the two DP rows is O(1) in all four, but the spelling differs:
std::swapon vectors swaps pointers, while JS/TS destructuring and Python tuple assignment rebind references. - Row aliasing when building a 2D table is a live trap in Python (
[[0]*m]*n) and JS/TS (fillwith an array), and impossible in C++. - Hoisting the row into a local is a real optimisation in Python (attribute and index lookups are expensive) and largely irrelevant in C++, where the compiler does it.
Complexity
The O(n log n) tails array gives the length only; reconstructing the sequence needs parent pointers.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Choosing an ordered subset where only consecutive chosen elements constrain each other.
- Longest/heaviest chain problems on arrays, pairs, or intervals after sorting.
- Counting the number of optimal subsequences.
- Two sequences interact — that is Longest Common Subsequence / 2D (Two-Sequence) DP.
- The constraint involves the whole chosen set, not just the previous element (e.g. sum ≤ budget) — use Knapsack DP.
- Contiguous subarrays — use Kadane's Algorithm or sliding window; "ending at i" DP still works but the transition is O(1).
Alternatives
Common mistakes
- Returning
dp[n-1]instead ofmax(dp)— the longest chain rarely ends at the last element. - Initializing
dp[i] = 0instead of1(each element alone is a subsequence of length 1). - In the
O(n log n)version, believingtailsis the actual LIS — it is not; it is a set of minimal tails. - Using
bisect_right(non-strict) when strictly increasing is required, or vice versa. - Forgetting to sort first in pair/envelope problems, and to sort the secondary key descending to prevent same-width chains.
Interview patterns
- LIS, Number of LIS, Longest Arithmetic Subsequence, Longest Divisible Subset.
- Russian Doll Envelopes, Maximum Length of Pair Chain (sort + LIS).
- Maximum Sum Increasing Subsequence (weighted; Fenwick tree for speed).
- Longest String Chain (sort by length;
dpover words via hash map).
- Minimum Size Subarray SumIntermediate
- Coin ChangeIntermediate
- Search in Rotated Sorted ArrayIntermediate