LC Patterns — 1D Dynamic Programming

Previous: binary-search, two-pointers, sliding-window · Code: C++
Per-pattern template: What it is → Intuition → Approach → Challenges & pitfalls → LC problems (max 3) → Worked examples.

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

The 5-question framework — answer these before writing any code, out loud in interviews: 1. State: what does 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.)
Recognition trigger: "count the ways…", "min/max cost to reach…", "longest/best sequence ending at…", "can you form…" — combined with choices at each step whose greedy version fails. If a greedy exchange argument works, prefer greedy (Pattern 9 shows the boundary).

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

  1. 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.
  2. 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.
  3. Sentinel arithmetic: INT_MAX for "impossible" then dp[j] + cost overflows. Guard: if (dp[j] != INT_MAX) ... — or use a huge-but-safe constant like 1e9.
  4. Wrong inner-loop direction in compressed knapsack (Pattern 6) — backwards for 0/1, forwards for unbounded. The single most-tested DP mechanic.
  5. Loop order changes the meaning (Pattern 5): coins-outer counts combinations; amount-outer counts permutations. Not a style choice — a semantics choice.
  6. Answer location (framework Q5): forgetting the max over 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.

LC 70 Climbing StairsLC 746 Min Cost Climbing StairsLC 509 Fibonacci Number
Example 1: Climbing Stairs (LC 70)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).

LC 198 House RobberLC 213 House Robber II — circularLC 740 Delete and Earn
Example 1: House Robber (LC 198)nums = [2, 7, 9, 3, 1], can't rob two adjacent houses.
  • Track best-if-we-skip-here and best-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.

LC 53 Maximum SubarrayLC 152 Maximum Product SubarrayLC 918 Maximum Sum Circular Subarray
Example 1: Maximum Subarray (LC 53)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).

LC 139 Word BreakLC 91 Decode WaysLC 132 Palindrome Partitioning II
Example 1: Word Break (LC 139)s = "leetcode", dictionary ["leet", "code"].
  • dp[i] = "can the first i characters be split into dictionary words?" Start dp[0] = true.
    • dp[4]: is s[0..4) = "leet" a word, with dp[0] true? Yes → dp[4] = true.
    • dp[8]: is s[4..8) = "code" a word, with dp[4] true? Yes → dp[8] = true.
  • dp[8] = true — "leetcode" splits into "leet" + "code". ✓ The dp[0] = true base 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).

LC 322 Coin ChangeLC 518 Coin Change IILC 377 Combination Sum IV
Example 1: Coin Change (LC 322)coins = [1, 2, 5], amount = 11, fewest coins.
  • dp[a] = fewest coins to make amount a. 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.

LC 416 Partition Equal Subset SumLC 494 Target SumLC 1049 Last Stone Weight II
Example 1: Partition Equal Subset Sum (LC 416)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?" Start dp[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).

LC 300 Longest Increasing SubsequenceLC 673 Number of Longest Increasing SubsequenceLC 646 Maximum Length of Pair Chain
Example 1: Longest Increasing Subsequence (LC 300)nums = [10, 9, 2, 5, 3, 7, 101, 18].
  • Maintain tails, where tails[k] is the smallest tail of an increasing subsequence of length k+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].
  • tails ends length 4 → LIS length 4. ✓ Only the length of tails is 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.

LC 122 Best Time to Buy and Sell Stock IILC 309 Best Time with CooldownLC 714 Best Time with Transaction Fee
Example 1: Best Time to Buy and Sell with Cooldown (LC 309)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).

LC 55 Jump GameLC 45 Jump Game IILC 1871 Jump Game VII
Example 1: Jump Game (LC 55)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

#Patterndp[i] meansTransition shapeTrigger phrase
1Linear recurrenceways/cost to reach step ifixed lookback (i−1, i−2)"climb", "reach step n", "count ways"
2Take/skipbest over prefix 0..iskip vs take+dp[i−2]"no two adjacent", "can't take neighbors"
3Kadanebest subarray ending at iextend vs restart"maximum subarray/product"
4Prefix segmentationcan/ways to form first i charsover all last pieces j..i"break into dictionary words", "decode"
5Unbounded knapsackbest/count for amount iover coins, reuse allowed"unlimited coins", "fewest coins", "combinations"
60/1 knapsack (1D)subset achieving sum iper item, backwards over sums"partition", "subset sum", "each item once"
7LIS familybest chain ending at imax over all compatible j < i"longest increasing", "chain of pairs"
8State machinebest per (day, named state)edges of the state diagram"cooldown", "fee", "hold/sell rules"
9Jump gamesreachable / min steps to irange of predecessors"jump", "reach the end"
Study order: 1 → 2 → 3 (the rolling-variable trio — until the 5-question framework is reflexive) → 4 → 5 → 6 (the two knapsacks together, focusing on the loop-direction contrast) → 8 → 7 (including the O(n log n) upgrade) → 9 (the DP↔greedy boundary, best saved for last).
The interview sentence (per your communication playbook §1.3): "Let me define the state precisely first: 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."