LC Patterns — 1D Dynamic Programming
The Master Insight (read before the patterns)
DP is recursion with memory: the answer to the whole problem can be written from answers to smaller instances of the same question, and those sub-answers repeat — so you compute each once. "1D" means the state is a single index or value: dp[i] = the answer for the prefix ending at i (or for amount i, or day i).
dp[i] mean, in one precise sentence? ("max money robbable from houses 0..i") — 90% of DP failure is a fuzzy state definition. 2. Transition: how does dp[i] follow from earlier entries? This encodes the choice at step i (take/skip, extend/restart, which coin last). 3. Base case(s): the smallest states answered directly — and what represents "impossible" (a sentinel). 4. Order: which direction guarantees the dependencies are ready (usually left→right; knapsack compression flips the inner loop — Pattern 6). 5. Answer: dp[n]? dp[n-1]? max over all i? (Kadane's answer is the max over ends, not the last entry — classic slip.)Top-down (memoized recursion) and bottom-up (tabulation) are the same computation; write whichever you derive faster — but know the mechanical conversion, interviewers ask for it. When dp[i] depends only on a few recent entries, compress O(n) space to O(1) (rolling variables) — mention it even if you don't do it.
§0Reference Skeletons & Universal Pitfalls
Bottom-up with rolling variables (the shape of Patterns 1–3)
int prev2 = base0, prev1 = base1;
for (int i = 2; i <= n; ++i) {
int cur = combine(prev1, prev2, cost(i));
prev2 = prev1; prev1 = cur;
}
return prev1;Top-down memoization (derive-first form)
int solve(int i, vector<int>& memo) {
if (i <= base_i) return base_val;
int &res = memo[i];
if (res != -1) return res;
return res = combine(solve(i-1, memo), solve(i-2, memo));
}Universal pitfalls
- Fuzzy state. If you can't define
dp[i]in one sentence, stop and fix that — the transition can't be right if the state is vague. Common precision trap: "best ending at i" (Kadane, LIS) vs "best up to i" (robber) are different states with different answers. - Indexing convention drift:
dp[i]= "first i characters" (size n+1,dp[0]= empty) vs "ending at index i" (size n). Pick per problem, write it as a comment, never mix. - Sentinel arithmetic:
INT_MAXfor "impossible" thendp[j] + costoverflows. Guard:if (dp[j] != INT_MAX) ...— or use a huge-but-safe constant like1e9. - Wrong inner-loop direction in compressed knapsack (Pattern 6) — backwards for 0/1, forwards for unbounded. The single most-tested DP mechanic.
- Loop order changes the meaning (Pattern 5): coins-outer counts combinations; amount-outer counts permutations. Not a style choice — a semantics choice.
- Answer location (framework Q5): forgetting the
maxover all positions when the state is "ending at i".
1Linear Recurrence (Climbing Stairs / Fibonacci-style)
What it is: The number of ways (or min cost) to reach step i depends only on the last one or two steps: dp[i] = dp[i-1] + dp[i-2] (count) or dp[i] = cost[i] + min(dp[i-1], dp[i-2]) (cost). The "hello world" of DP — use it to drill the 5-question framework until it's reflexive.
Intuition: Split all the ways to reach step i by the last move (you came from i−1, or from i−2). The cases don't overlap and cover everything, so counts add and costs min. Every counting DP is this move: split by the last decision.
Approach:
int climbStairs(int n) {
int p2 = 1, p1 = 1; // ways to reach step 0, step 1
for (int i = 2; i <= n; ++i) { int c = p1 + p2; p2 = p1; p1 = c; }
return p1;
}O(n) time, O(1) space via rolling variables.
Challenges & pitfalls
base-case fencepost (is step 0 one way or zero? — define and verify with n=2 by hand); jumping to the closed-form/matrix-power flex before nailing the basics (mention it, don't derive it); in min-cost variants, whether you pay on arrival or departure changes the bases — trace a 3-element example.
n = 5, taking 1 or 2 steps at a time.- Ways to reach each step = ways to the step below (then a 1-step) + ways to two below (then a 2-step):
- step 0: 1, step 1: 1, step 2:
1 + 1 = 2, step 3:2 + 1 = 3, step 4:3 + 2 = 5, step 5:5 + 3 = 8. - Answer 8. ✓ Each step's count came from exactly its two predecessors — Fibonacci.
2Take/Skip with Adjacency Constraint (House Robber family)
What it is: At each element you choose take it (earning its value but forfeiting the neighbor) or skip it. dp[i] = max(dp[i-1], dp[i-2] + val[i]) — the better of skipping i, or taking i on top of i−2's best.
Intuition: The constraint ("no two adjacent") only couples neighbors, so the whole future depends on just one bit of the past: did I take the previous element? That collapses exponential choice into two rolling values. Learn the two costume variants: circular arrays (first and last are adjacent → run the linear DP twice, once excluding the first house and once excluding the last, take the max) and value-domain transforms (740: taking value v deletes v±1 → total the points per value, and it is house robber on the value axis).
Approach:
int rob(vector<int>& a) {
int skip = 0, take = 0; // best excluding / including previous
for (int x : a) { int nskip = max(skip, take); take = skip + x; skip = nskip; }
return max(skip, take);
}Challenges & pitfalls
the circular split (213) — most people invent something complicated; the answer is just two linear runs; recognizing 740's costume (sort/bucket by value, then rob); the skip/take two-variable form is cleaner than dp[i-1]/dp[i-2] — but don't clobber skip before using it (the nskip temp).
nums = [2, 7, 9, 3, 1], can't rob two adjacent houses.- Track
best-if-we-skip-hereandbest-if-we-take-here: - house 2: take = 2, skip = 0.
- house 7: take =
0 + 7 = 7, skip =max(0, 2) = 2. - house 9: take =
2 + 9 = 11, skip =max(2, 7) = 7. - house 3: take =
7 + 3 = 10, skip =max(7, 11) = 11. - house 1: take =
11 + 1 = 12, skip =max(11, 10) = 11. - Answer
max(11, 12) =12 (rob houses 2 + 9 + 1). ✓ Only the "did I take the previous one" bit is remembered — two rolling numbers.
3Kadane: Best Subarray Ending Here
What it is: For max-subarray-type problems: best_ending_here = max(x, best_ending_here + x) — at each element, either extend the running subarray or restart at this element; the answer is the max over all positions.
Intuition: State precision is everything: dp[i] = best subarray ending exactly at i (not "up to i"). With that state, the choice is binary — a subarray ending at i is either {x} alone or an extension of the best one ending at i−1. A negative running best can never help an extension → restart. For products (152), sign flips mean the minimum can become the maximum after multiplying by a negative — track both. For circular (918), max-circular = total − min-subarray (the complement trick), with an all-negative guard.
Approach:
int maxSubArray(vector<int>& a) {
int best = a[0], cur = a[0];
for (int i = 1; i < (int)a.size(); ++i) {
cur = max(a[i], cur + a[i]);
best = max(best, cur);
}
return best;
}Challenges & pitfalls
the answer is best, not cur (pitfall 6); all-negative arrays (initialize from a[0], not 0 — initializing cur = 0 silently answers "empty subarray allowed," usually wrong); in 152 update max/min with temporaries (both formulas need the old values); in 918 the all-negative case makes total − min empty — guard with the plain Kadane result.
nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4].- Carry
cur= best subarray ending here (extend, or restart at this element): - -2 → cur −2. 1 →
max(1, −2+1) = 1(restart). -3 →max(-3, 1−3) = −2. 4 →max(4, −2+4) = 4(restart). -1 → 3. 2 → 5. 1 → 6. -5 → 1. 4 → 5. - Best over all positions = 6 (the subarray
[4, -1, 2, 1]). ✓ Whenever the running sum went negative, restarting beat extending — that's the one decision.
4Prefix Segmentation (Word Break / Decode Ways)
What it is: Can (or in how many ways can) the first i characters be split into valid pieces? dp[i] aggregates over every valid last piece s[j..i): dp[i] |= dp[j] && valid(s[j..i)) (feasibility) or dp[i] += dp[j] · ways(piece) (counting).
Intuition: Split by the last piece — the same move as Pattern 1, but the "last step" is now a variable-length chunk validated by a dictionary or a decoding rule. dp[0] = true/1 (the empty prefix) is what makes the first piece work — the base case is the design here.
Approach:
bool wordBreak(string s, vector<string>& dict) {
unordered_set<string> words(dict.begin(), dict.end());
int n = s.size();
vector<bool> dp(n + 1, false);
dp[0] = true; // empty prefix
for (int i = 1; i <= n; ++i)
for (int j = i - 1; j >= 0 && !dp[i]; --j) // j = start of last piece
dp[i] = dp[j] && words.count(s.substr(j, i - j));
return dp[n];
}Prune the inner loop by the max dictionary word length. Decode Ways (91) is the same skeleton with pieces of length 1–2 and validity rules ('0' can't stand alone, "10"–"26" for pairs). Min-cut palindrome partitioning (132) = same 1D skeleton where valid = palindrome and aggregation = min(dp[j] + 1).
Challenges & pitfalls
size-(n+1) indexing discipline (dp[i] = first i chars — pitfall 2); 91's zero rules are 80% of that problem (enumerate "0", "06", "10", "30" by hand before coding); O(n²·substr) cost — mention string_view/hashing or the word-length prune; counting variants can overflow int (use long long or the problem's modulus).
s = "leetcode", dictionary ["leet", "code"].dp[i]= "can the first i characters be split into dictionary words?" Startdp[0] = true.dp[4]: iss[0..4) = "leet"a word, withdp[0]true? Yes →dp[4] = true.dp[8]: iss[4..8) = "code"a word, withdp[4]true? Yes →dp[8] = true.dp[8] =true — "leetcode" splits into "leet" + "code". ✓ Thedp[0] = truebase is what lets the very first piece count.
5Coin Change / Unbounded Knapsack on the Value Axis
What it is: dp[a] = best/count for amount a, where each item can be used unlimited times: dp[a] = min(dp[a - coin] + 1) over coins (fewest coins) or dp[a] += dp[a - coin] (count). The state is a value, not an array index — the second big family of 1D states.
Intuition: Split by the last coin used. The subtle-but-crucial point is loop order semantics: coins outer / amount inner ⇒ each combination counted once, in a canonical coin order (518: combinations); amount outer / coins inner ⇒ different orderings counted separately (377: permutations). Same two loops, swapped, different universe — being able to explain why (the outer-coins loop fixes the order in which coin types may appear) is the interview differentiator.
Approach:
int coinChange(vector<int>& coins, int amount) {
const int INF = 1e9;
vector<int> dp(amount + 1, INF);
dp[0] = 0;
for (int a = 1; a <= amount; ++a)
for (int c : coins)
if (c <= a && dp[a - c] != INF)
dp[a] = min(dp[a], dp[a - c] + 1);
return dp[amount] == INF ? -1 : dp[amount];
}Challenges & pitfalls
pitfall 5 (loop order) — decide combinations vs permutations from the problem statement before writing loops; sentinel overflow (pitfall 3 — hence 1e9, not INT_MAX); dp[0] = 0/1 base; greedy-by-largest-coin fails for general denominations (have the counterexample ready: coins {1,3,4}, amount 6 — greedy 4+1+1 = 3 coins, optimal 3+3 = 2).
coins = [1, 2, 5], amount = 11, fewest coins.dp[a]= fewest coins to make amounta.dp[0] = 0. Fill upward, each amount taking the best "one coin on top of a smaller amount":dp[1]=1(1),dp[2]=1(2),dp[3]=2(2+1),dp[4]=2(2+2),dp[5]=1(5), …dp[10]=2(5+5),dp[11] = dp[6] + 1 = 3(via a 5: 5+5+1).- Answer 3. ✓ Greedy (5+5+1) happens to be optimal here, but for coins {1,3,4} making 6 it would fail — which is why this needs DP.
60/1 Knapsack Compressed to 1D (Subset Sum)
What it is: Each item usable at most once: can some subset hit target sum t (416), or reach a target with ± signs (494)? The 2D dp[item][sum] compresses to a 1D boolean/count array over sums — with the inner loop running backwards.
Intuition: In-place compression means dp[s] holds "previous row" values until overwritten. Iterating sums descending reads dp[s - x] from the previous item-row (this item not yet applied) — so each item is used ≤ once. Ascending would read the current row — the item gets reused, which is Pattern 5. One loop direction is the entire difference between 0/1 and unbounded knapsack; interviewers test exactly this, and "why backwards?" is the follow-up. The transforms: 494 reduces to "count subsets summing to (total + target)/2"; 1049 to "split stones into two near-equal halves."
Approach:
bool canPartition(vector<int>& nums) {
int total = accumulate(nums.begin(), nums.end(), 0);
if (total % 2) return false;
int target = total / 2;
vector<char> dp(target + 1, 0);
dp[0] = 1; // empty subset
for (int x : nums)
for (int s = target; s >= x; --s) // BACKWARDS: each item once
dp[s] = dp[s] | dp[s - x];
return dp[target];
}Challenges & pitfalls
the backwards loop and its why (pitfall 4 — rehearse the previous-row explanation verbatim); 494's algebra (derive sum(P) = (total + target)/2 on the whiteboard; parity/negative checks first); counting variants need the same backwards discipline with dp[s] += dp[s-x]; vector<bool> quirks in C++ — vector<char> is the pragmatic dodge.
nums = [1, 5, 11, 5], split into two equal-sum halves?- Total = 22, so each half must sum to
22 / 2 = 11(if the total were odd, immediately false). dp[s]= "can some subset reach sum s?" Startdp[0] = true. Apply each number, updating sums backwards so each number is used once:- after 1: sums {0, 1} reachable. after 5: {0, 1, 5, 6}. after 11: {…, 11, 12, …} →
dp[11]becomes true (just {11}). after the second 5: also {1,5,5} = 11. dp[11] =true → the set splits (e.g. {11} vs {1,5,5}). ✓ Going backwards over sums is what stops the same 5 from being counted twice.
7LIS Family: Best Over All Previous Compatible States
What it is: dp[i] = best chain ending at i, computed by scanning all previous j and extending any compatible one: dp[i] = max(dp[j]) + 1 over j < i, a[j] < a[i]. O(n²) baseline; the classic follow-up compresses to O(n log n) with the patience-sorting tails array + binary search.
Intuition: Unlike Patterns 1–3 (fixed lookback), here any earlier index may be the predecessor — that's what costs the inner loop. The O(n log n) insight: you don't need every dp[j], only, for each chain length, the smallest possible tail — and that tails array is provably sorted, so each element binary-searches its slot. The counting variant (673) carries a second array cnt[i] alongside dp[i].
Approach:
int lengthOfLIS(vector<int>& a) {
vector<int> tails; // tails[k] = min tail of an inc. subseq of length k+1
for (int x : a) {
auto it = lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x);
else *it = x; // improve the tail for this length
}
return tails.size();
}Challenges & pitfalls
tails is not an actual LIS — only its length is meaningful (interviewers bait this; reconstruction needs O(n²) parents or extra bookkeeping); lower_bound vs upper_bound = strict vs non-decreasing (one token, different problem); 646/354-style 2D inputs need the sort trick (sort key ascending, tie-break descending) to reduce to 1D LIS; 673 must count via the O(n²) form (or a BIT).
nums = [10, 9, 2, 5, 3, 7, 101, 18].- Maintain
tails, wheretails[k]is the smallest tail of an increasing subsequence of lengthk+1; for each number binary-search where it belongs (overwrite the first tail ≥ it, or append): 10→[10],9→[9],2→[2],5→[2,5],3→[2,3],7→[2,3,7],101→[2,3,7,101],18→[2,3,7,18].tailsends length 4 → LIS length 4. ✓ Only the length oftailsis meaningful (its contents[2,3,7,18]aren't an actual subsequence).
8State-Machine DP (Stock Problems)
What it is: The 1D index is time (days), but at each step you're in one of a few named states (holding a stock / not holding / cooling down), each with its own dp value. Transitions are the allowed moves between states with their profits/costs — literally draw the state diagram, then transcribe it into one rolling variable per state.
Intuition: When "what am I allowed to do next?" depends on a small piece of history (do I hold? did I just sell?), don't fight it — name the states and give each its own recurrence. The diagram is the derivation: nodes = states, edges = buy/sell/rest with weights ±price. Cooldown (309) adds a third node; a transaction fee (714) is one extra term on the sell edge; the same idiom scales to "at most k transactions" by multiplying states.
Approach (309, cooldown):
int maxProfit(vector<int>& p) {
int hold = INT_MIN, sold = 0, rest = 0; // states after today's action
for (int x : p) {
int prevSold = sold;
sold = hold + x; // sell today
hold = max(hold, rest - x); // keep holding, or buy (from rest only)
rest = max(rest, prevSold); // idle; cooldown feeds rest next day
}
return max(sold, rest);
}Challenges & pitfalls
stale-vs-fresh state reads (does today's hold update use yesterday's rest? — temporaries again; the #1 bug here); initial states (hold = INT_MIN = "impossible before any buy"); answer = best non-holding state at the end; deriving from the diagram beats memorizing six variants — draw it in the interview, transitions read off the edges.
prices = [1, 2, 3, 0, 2].- Three states each day:
hold(own a share),sold(just sold today),rest(idle, free to buy). Walk the days: - Buy on day 0 (price 1), sell on day 1 (price 2) → profit 1; day 2 is a forced cooldown; buy on day 3 (price 0), sell on day 4 (price 2) → profit 2.
- Best total profit =
1 + 2 =3. ✓ The cooldown state is exactly what forbids buying on the day right after a sale.
9Jump Games: DP That Wants to Be Greedy (or a Window)
What it is: Reachability/min-steps along an array where each cell says how far you can move next. The O(n²) DP ("for each i, try all previous jumpers") is the derivation, but the accepted solutions collapse it: reachability (55) → a running farthest-reach greedy; min jumps (45) → BFS-layers-in-disguise greedy; constrained variants (1871) → DP + a sliding-window count of reachable predecessors.
Intuition: This pattern earns its place by teaching the boundary between DP and greedy: when the transition has dominance structure ("a farther reach dominates a nearer one — nothing a nearer reach enables is lost"), the DP's inner loop collapses to O(1) greedy state. When dominance fails (weighted/conditional variants), you fall back to the DP — and if the transition window is a contiguous range, a sliding-window sum or monotonic deque rescues O(n). Being able to say why greedy is safe (an exchange argument) is what separates memorized greedy from derived greedy.
Approach (45, min jumps as implicit BFS layers):
int jump(vector<int>& a) {
int jumps = 0, curEnd = 0, farthest = 0;
for (int i = 0; i + 1 < (int)a.size(); ++i) {
farthest = max(farthest, i + a[i]);
if (i == curEnd) { ++jumps; curEnd = farthest; } // finished a BFS layer
}
return jumps;
}Challenges & pitfalls
off-by-one at the last index (i + 1 < n — you never jump from the end); explaining the BFS-layer view of 45 (each [curEnd+1, farthest] is the next layer); 1871's window: dp[i] = any dp[j] true for j in [i-maxJump, i-minJump] — keep a running count of trues in that window instead of rescanning (DP × sliding-window composition).
nums = [2, 3, 1, 1, 4], can you reach the last index? Each value is the max jump from that cell.- Track the farthest index reachable so far. At index 0 (value 2) → farthest 2. At index 1 (value 3) → farthest
max(2, 1+3) = 4, which already covers the last index (4). true. ✓ - Jump Game II (min jumps) on the same array: layer 0 covers index 0; from it you can reach up to index 2 (layer 1); from indices within layer 1 the farthest is index 4 (layer 2). So 2 jumps — like BFS levels expanding the reachable frontier.
▤Summary Table — Recognition Cheat Sheet
| # | Pattern | dp[i] means | Transition shape | Trigger phrase |
|---|---|---|---|---|
| 1 | Linear recurrence | ways/cost to reach step i | fixed lookback (i−1, i−2) | "climb", "reach step n", "count ways" |
| 2 | Take/skip | best over prefix 0..i | skip vs take+dp[i−2] | "no two adjacent", "can't take neighbors" |
| 3 | Kadane | best subarray ending at i | extend vs restart | "maximum subarray/product" |
| 4 | Prefix segmentation | can/ways to form first i chars | over all last pieces j..i | "break into dictionary words", "decode" |
| 5 | Unbounded knapsack | best/count for amount i | over coins, reuse allowed | "unlimited coins", "fewest coins", "combinations" |
| 6 | 0/1 knapsack (1D) | subset achieving sum i | per item, backwards over sums | "partition", "subset sum", "each item once" |
| 7 | LIS family | best chain ending at i | max over all compatible j < i | "longest increasing", "chain of pairs" |
| 8 | State machine | best per (day, named state) | edges of the state diagram | "cooldown", "fee", "hold/sell rules" |
| 9 | Jump games | reachable / min steps to i | range of predecessors | "jump", "reach the end" |
dp[i] = ⟨one sentence⟩. The choice at each step is ⟨X vs Y⟩, so the transition is ⟨recurrence⟩, base case ⟨B⟩, answer at ⟨where⟩ — and since it only looks back ⟨k⟩ steps, I can use O(1) space."