The whole topic distilled to one idea: DP is 95% recursion + 5% memoization. Learn the four-step template once, then watch it solve ten problems unchanged.
Prefer a problem-first reference with LeetCode links and a 3-step framework? See the companion notes: Dynamic Programming — Complete Study Guide.
Hand-copy this into your notebook. Telegraphic by design — every line is derivable from the detailed notes below. Markers (★ ⚠ 💡) sit in the margin so you can box them.
DP = two job types only: 1. OPTIMIZE → find min / max value 2. COUNT → number of ways to reach a goal DP = RECURSION (95%) + MEMOIZATION (5%) memo = just cache the subproblem's answer, reuse it effect: time drops exponential → polynomial
1PARAMETERS → what describes a subproblem? 2BASE CASE → "what if it doesn't exist?" (smallest input) 3OPTIONS → at this index: pick / skip (recurse on each) 4COMBINE+CACHE→ count→SUM | optimize→MIN/MAX ; store; return
#changing params → array dimension
1 changing → 1-D dp[]
2 changing → 2-D dp[][]
3 changing → 3-D / HashMap
constants (array itself, n) do NOT count
HashMap key = concat changing params e.g. i + "," + j
init dp = -1 → "-1 means not computed yet"
| Top-down | Bottom-up | |
|---|---|---|
| engine | recursion + memo | for-loop fills array |
| start | goal, recurse down | base index 0 / 0,0 → up |
| i-2 jump | passed as param | lookup[i-2] |
| TC / SC | SAME for both | |
PICK item → consume capacity/target, add value
SKIP item → move index, leave capacity
combine = SUM (count) / MAX / MIN (optimize)
guard: if item > remaining → SKIP only (1 option)
| problem | params (var) | combine |
|---|---|---|
| Coin change (ways) | sum, n | SUM |
| Climb stairs | n | SUM(n-1,n-2,n-3) |
| House robber | i | MAX(skip, i-2+v) |
| Palindrome del. | i, j | match→recurse; else 1+MIN |
| 0/1 Knapsack | W, i | MAX(take, skip) |
| Subset sum | sum, n | OR (take/skip) |
| Min partition | n, s1 | MIN(|s1-s2|) |
| LIS | idx, prevIdx | MAX(take, skip) |
| Decode ways | i | SUM(1-digit,2-digit) |
| Inc. path matrix | i, j | MAX over 4 dirs (DFS+memo) |
★TRICK stop chasing "winding/unwinding". Trust the function: right base + right recurse = right answer. ★TRICK #changing-params decides 1D/2D/3D. Done. ⚠PITFALL item > target → DON'T return 0. SKIP it. ⚠PITFALL House robber: also dp[i]=max(dp[i], nums[i]) for all-negative arrays. ⚠PITFALL base: use i==0 (size can't be <0), keep it simple. 💡TIP LIS prevIdx+1 shifts the -1 sentinel into a valid array index. 💡TIP Greedy = brute force (best-right-now). DP = explore ALL options, then min/max. Different beasts.
You're in an interview. The question is "count the ways a child can climb n stairs taking 1, 2, or
3 steps." You write the obvious recursion, it works for n = 5, and then the
interviewer says "now n is 40." Your code crawls. The same f(37) is being
recomputed thousands of times across different branches, each from scratch. That single wasted recomputation
is the entire reason dynamic programming exists — and fixing it is, genuinely, about five lines of
code.
The whole thesis here is that DP is not a scary new technique. It's the recursion you already know, with a notepad bolted on. Once you believe that, every problem below becomes a fill-in-the-blanks exercise on one template. We'll build that template first, then run it ten times.
Plain English first: dynamic programming is a way of solving a big problem by solving smaller versions of the same problem and remembering the answers so you never solve the same small version twice. The "remembering" is the only thing that separates it from plain recursion.
Here's a sharp filter for when a problem is a DP problem. DP solves exactly two kinds of question:
| Job type | What you're asked | Signal words |
|---|---|---|
| Optimization | Find the best (minimum or maximum) value | "minimum", "maximum", "longest", "least" |
| Counting | How many ways to achieve a condition | "number of ways", "count", "total ways" |
A common question: "if I see the word minimize, is it always DP?" No. Shortest-path-in-a-grid also says "minimum" but is solved by BFS. And many "best choice" problems are greedy. The real tell is: do you face options where you can't know the right choice locally, forcing you to explore all of them? That's DP. If the locally-best choice is always safe, it's greedy.
A mental model: you're running a 50 km marathon and may pick up money once. Greedy grabs the first ₿50 it sees (best right now, "tomorrow doesn't exist"). DP remembers there might be ₿500 at the 25 km mark, so it explores both and takes the max. Greedy = best-at-this-moment brute force; DP = explore every option, then min/max. [+] currency unit illustrative.
Definition: every DP solution in this session is the same four moves. Memorize this and you stop "thinking about DP" entirely — you just fill in the blanks.
n); some change on each
call (an index, a remaining sum). Only the changing ones matter.
Beginners get stuck mentally "unwinding" the recursion call-by-call. Be blunt with yourself: that approach will sink you in an interview. Instead, trust the significance of the function: "this call returns the correct answer for its subproblem." If your base case is right and your recursive calls are right, the whole thing is right — you don't need to simulate the stack in your head. Trace by hand only once, to believe it; then think only at the current-index level.
Definition: the same DP can be coded two ways. Top-down = recursion that starts at the goal and dives toward the base, caching as it returns. Bottom-up = a loop that starts at the base case and fills a table upward until it reaches the goal.
In the house-robber walkthrough the instructor shows the recursive i-2 jump
becoming lookup[i-2] in a loop — same logic, the for-loop just plays the
role recursion did. Complexity is the same either way. Most people
prefer top-down because it's more intuitive; reach for bottom-up if you want to
avoid deep call stacks. Either is a correct interview answer.
It's tempting to claim bottom-up wins on huge inputs because it skips the call stack. Not so: with a correct base/guard before each recursive call, top-down won't blow the stack unnecessarily, and the time/space complexity is the same. Recursion only goes exponential when you forget memoization — the cache, not the loop, is what saves you.
The single most useful trick in the session. Beginners freeze on "should this be a 1-D, 2-D, or 3-D array?" The answer: count the parameters that change on each recursive call. That count is your number of dimensions.
| Changing parameters | Memo structure | Example problem |
|---|---|---|
| 1 | dp[] (1-D) |
Climb stairs (just n) |
| 2 | dp[][] (2-D) |
Knapsack (W, index) |
| 3+ | 3-D array or HashMap | concat keys into a string |
For a HashMap, build the key by concatenating the changing parameters: String key = i + "," + j;. Because those values are unique per subproblem, the key is
unique. Constants like the array and its length never count toward the dimension — they don't
change, so they can't distinguish subproblems.
Fill your dp array with -1 up front. Then a single check — if (dp[i][j] != -1) return dp[i][j]; — means "already computed, reuse it."
That one line is the dynamic-programming part. Everything else is recursion you already know.
(Use -1 only when valid answers are non-negative; otherwise use a HashMap's
"contains key" check.)
The ten problems — same template, every time
Each problem below follows the identical rhythm: plain-English statement → the four template steps → the code the class arrived at. Notice you're never inventing a new technique — you're choosing parameters and an option set.
Plain English: given coin denominations and a target sum, how many different combinations
add up to the target? You have an infinite supply of each coin. For coins = {1,2,3}, sum = 4 the answer is 4: {1,1,1,1}, {1,1,2}, {2,2}, {1,3}.
sum and coin index n → two changing → 2-D memo.sum == 0 → return 1 (one
valid way found); n == 0 (no coins left) → return 0.a[n-1] ≤ sum): pick it
— stay on the same index (infinite supply!) and reduce the sum — plus
skip it — move to the previous coin, sum unchanged.
A student wanted to return 0 the moment a coin is bigger than the remaining sum.
Wrong — that kills valid combinations using smaller coins. The correct move is to
skip that coin and move to the previous one (the else branch).
Returning 0 only happens at a true dead end (sum goes negative or coins run out).
int coinChange(int[] a, int sum, int n, int[][] dp) { // base cases if (sum == 0) return dp[n][sum] = 1; // found a way if (n == 0) return 0; // no coins left if (dp[n][sum] != -1) return dp[n][sum]; // memo hit if (a[n-1] <= sum) // PICK (same n, infinite supply) + SKIP (n-1) return dp[n][sum] = coinChange(a, sum - a[n-1], n, dp) + coinChange(a, sum, n - 1, dp); else // coin too big — no choice but to skip it return dp[n][sum] = coinChange(a, sum, n - 1, dp); }
Time O(N×sum) · Space O(N×sum)
(sum, coin) states over and over
— the class noted the call stack "grows exponentially" without the memo.Plain English: a child climbs n stairs, hopping 1, 2, or 3 steps
at a time. How many distinct ways to reach the top? For n = 3 the answer is 4: 1+1+1, 1+2, 2+1, 3.
n, the steps remaining → one
changing → 1-D memo.n == 0 → return 1 (reached
the top exactly — one valid path); n < 0 → return
0 (overshot).
n-1, n-2, n-3.int climb(int n, int[] dp) { if (n == 0) return 1; // reached top exactly if (n < 0) return 0; // overshot if (dp[n] != -1) return dp[n]; // memo hit return dp[n] = climb(n-1, dp) + climb(n-2, dp) + climb(n-3, dp); }
Time O(n) · Space O(n)
f(2) to
show why memoization is needed.
n < 0 guard "to be safe" is cleaner than only
guarding inside each recursive call.Plain English: pick a subset of array elements with the maximum sum, but you may never
pick two adjacent elements. For {1,2,9,4,5,0,4,11,6} the best is
{1,9,5,11} = 26.
i → 1-D.i → add nums[i] and jump to i-2 (skip the neighbor); skip
→ move to i-1.The "age condition": if every number is negative, the running best is still negative —
but a single large positive element should be allowed to be the answer on its own. So after the
usual recurrence, also clamp: dp[i] = max(dp[i], nums[i]). The bottom-up base
cases handle size 1 (return nums[0]) and size 2 (max(nums[0], nums[1])) directly.
// bottom-up: lookup[i] = best non-adjacent sum up to index i int robber(int[] nums) { int n = nums.length; if (n == 0) return 0; if (n == 1) return nums[0]; int[] lookup = new int[n]; lookup[0] = nums[0]; lookup[1] = Math.max(nums[0], nums[1]); for (int i = 2; i < n; i++) { lookup[i] = Math.max(lookup[i-1], lookup[i-2] + nums[i]); // skip vs pick lookup[i] = Math.max(lookup[i], nums[i]); // all-negative guard } return lookup[n-1]; }
Time O(n) · Space O(n)
i-2 jump becomes lookup[i-2], the i-1 skip becomes lookup[i-1].
Plain English: delete the fewest characters so the remaining string reads the same
forwards and backwards. "ACBCDBAA" needs 3 deletions.
The obvious idea is "reverse it and compare." But there's a better other property:
walk a pointer from each end inward — if first == last, both shrink
inward. This two-pointer view is what makes the recursion's (i, j) parameters
obvious.
i and end index j → two changing → 2-D memo (or HashMap keyed i+"|"+j). The string and its length are constants.i ≥ j (pointers met or crossed) → return
0.
s[i] == s[j], no deletion needed — recurse on
(i+1, j-1). Otherwise you must delete one end: 1 + min(
delete-last (i, j-1), delete-first (i+1, j) ).
int minDeletions(String x, int i, int j, Map<String,Integer> memo) { if (i >= j) return 0; // 0 or 1 char left String key = i + "|" + j; if (memo.containsKey(key)) return memo.get(key); int ans; if (x.charAt(i) == x.charAt(j)) ans = minDeletions(x, i+1, j-1, memo); // ends match, free else ans = 1 + Math.min(minDeletions(x, i, j-1, memo), // drop last minDeletions(x, i+1, j, memo)); // drop first memo.put(key, ans); return ans; }
1 + min(...) shape (each operation costs 1, minimize the total) is the
edit-distance family. Recognizing it unlocks Levenshtein, longest-common-subsequence, etc.Plain English: you have items, each with a weight and a value, and a bag that holds at
most weight W. Maximize total value without exceeding the capacity. Each item is
taken whole or not at all ("0/1").
W and item index
n → 2-D. One index serves both the value and weight
arrays.
n == 0 or W == 0 →
return 0.wt[n-1] > W) you can only
skip. Otherwise choose the max of: take — add val[n-1], reduce capacity by wt[n-1], move to n-1 — vs skip — move to n-1, capacity
unchanged.
A point to watch: in the skip branch you do not add
val[n-1], because the item never went in the bag. Adding value in both branches
is a classic silent bug.
int knapSack(int W, int[] wt, int[] val, int n, int[][] dp) { if (n == 0 || W == 0) return 0; if (dp[n][W] != -1) return dp[n][W]; if (wt[n-1] > W) // too heavy → must skip return dp[n][W] = knapSack(W, wt, val, n-1, dp); return dp[n][W] = Math.max(val[n-1] + knapSack(W - wt[n-1], wt, val, n-1, dp), // take knapSack(W, wt, val, n-1, dp)); // skip }
Time O(N×W) · Space O(N×W)
Plain English: given non-negative integers and a target, is there any subset that
adds up exactly to the target? {3,34,4,12,5,2}, target 9 → true (4+5). Target 30 → false.
sum and index n → 2-D. Each element used at most once (no infinite
supply).sum == 0 → true (1); n ≤ 0 → false (0).a[n-1] > sum you must exclude.
Otherwise it's an OR of include (sum - a[n-1], index
n-1) and exclude (sum, index n-1).
int subsetSum(int[] a, int n, int sum, int[][] tab) { if (sum == 0) return 1; // found target if (n <= 0) return 0; // out of elements if (tab[n-1][sum] != -1) return tab[n-1][sum]; if (a[n-1] > sum) // can't include return tab[n-1][sum] = subsetSum(a, n-1, sum, tab); // include OR exclude boolean ok = subsetSum(a, n-1, sum, tab) != 0 || subsetSum(a, n-1, sum - a[n-1], tab) != 0; return tab[n-1][sum] = ok ? 1 : 0; }
Time O(N×sum) · Space O(N×sum)
It's tempting to keep reaching for "the next element." Instead, focus only on the current element's two choices. The next element takes care of itself when it becomes the current element in the recursive call. Thinking one index at a time is the whole point of the "trust the function" trick.
Plain English: split a set into two groups so the difference between their sums is as
small as possible; return that minimum difference. {10,20,15,5,25} splits into sums
35 and 40 → difference 5.
n and S1
(sum collected in group 1) → 2-D. You don't need S2
separately — it's totalSum − S1.n < 0 → return |S1 − S2|.S1 + a[n])
or group 2 (S2 + a[n]).
Because every element goes to exactly one group, S2 = totalSum − S1. So the
unique key is just (n, S1) — tracking S2 too would
be redundant and inflate your state space. This "the rest is implied" insight collapses a 3-D-looking
state into 2-D.
int minDiff(int[] S, int n, int s1, int s2, Map<String,Integer> memo) { if (n < 0) return Math.abs(s1 - s2); String key = n + "|" + s1; // s2 is implied if (memo.containsKey(key)) return memo.get(key); int inc = minDiff(S, n-1, s1 + S[n], s2, memo); // → group 1 int exc = minDiff(S, n-1, s1, s2 + S[n], memo); // → group 2 int ans = Math.min(inc, exc); memo.put(key, ans); return ans; }
Time O(N×sum) · Space O(N×sum)
Plain English: find the length of the longest subsequence whose elements are in strictly
increasing order. {3,10,2,1,20} → {3,10,20}, length
3.
prevIdx tracks.idx and prevIdx (the index of the last element kept) → 2-D.idx == n → return 0 (nothing
left to extend).idx+1, same prevIdx); or, only if prevIdx == -1 or a[idx] > a[prevIdx], take it (1 + f(idx+1, idx)).prevIdx starts at -1 (nothing kept yet), but arrays can't
be indexed at -1. So the memo is indexed as dp[idx][prevIdx + 1] — the +1 shifts the range to
start at 0. This detail matters and plays a critical role.
prevIdx remembers the last element added so the increasing rule can
be enforced.int f(int idx, int prevIdx, int n, int[] a, int[][] dp) { if (idx == n) return 0; if (dp[idx][prevIdx + 1] != -1) return dp[idx][prevIdx + 1]; int skip = f(idx + 1, prevIdx, n, a, dp); int take = Integer.MIN_VALUE; if (prevIdx == -1 || a[idx] > a[prevIdx]) take = 1 + f(idx + 1, idx, n, a, dp); // current becomes new prev return dp[idx][prevIdx + 1] = Math.max(take, skip); }
Time O(n²) · Space O(n²)
One tempting idea is dumping elements into a sorted Set to find the increasing run. That fails: a Set reorders elements, but a subsequence must preserve the original order. Reordering changes the problem entirely.
Plain English: with mapping 1→A, 2→B, … 26→Z, count how many ways a digit string can be
decoded. "123" → 3 (ABC, AW, LC); "1221" → 5.
i in the string →
1-D table T where T[i] = ways to decode
the first i characters.
T[0] = 1 (empty string, one way) and T[1] = 1.i: if the single digit > 0, it decodes alone → add T[i-1]. If the
previous two digits form 10–26, that pair decodes too → add T[i-2].
int count(int x) { String seq = String.valueOf(x); int[] T = new int[seq.length() + 1]; T[0] = 1; T[1] = 1; for (int i = 2; i <= seq.length(); i++) { if (seq.charAt(i-1) > '0') // single digit 1–9 T[i] = T[i-1]; if (seq.charAt(i-2) == '1' || // two-digit 10–26 (seq.charAt(i-2) == '2' && seq.charAt(i-1) <= '6')) T[i] += T[i-2]; } return T[seq.length()]; }
Time O(n) · Space O(n)
T[i] = T[i-1] + T[i-2]"
— but each term is gated by a validity check (digit > 0; pair within 10–26).
Conditional Fibonacci.Plain English: in an n×n grid of distinct numbers, find the
longest path where each step moves to an adjacent cell (up/down/left/right) whose value is exactly
one greater. The path 6→7→8→9 gives length 4.
(i, j) → HashMap
keyed "i,j" (effectively 2-D). The matrix is constant.current + 1 and within bounds.This connects to the earlier grid/BFS material: it's a standard 4-direction DFS, plus the
rule "only step where neighbor − current == 1," plus memoization on each
cell. Recognizing a "new" problem as a known traversal with one added guard is the meta-skill the whole
session is teaching.
int dfs(int[][] m, int i, int j, Map<String,Integer> memo) { String key = i + "," + j; if (memo.containsKey(key)) return memo.get(key); int best = 1; // the cell itself int[][] dirs = {{0,1},{1,0},{0,-1},{-1,0}}; for (int[] d : dirs) { int r = i + d[0], c = j + d[1]; if (r >= 0 && r < m.length && c >= 0 && c < m.length && m[r][c] - m[i][j] == 1) // the extra rule best = Math.max(best, 1 + dfs(m, r, c, memo)); } memo.put(key, best); return best; }
It's tempting to guess the HashMap key should be the cell's value. It isn't: the key is the
index (i, j), because that's what changes per subproblem.
Values happen to be distinct here, but coordinates are the honest state.
The follow-ups this material naturally invites. Each answer is short enough to say out loud. Click to expand.
Look for two signatures: you're asked for a min/max (optimization) or a count of ways, and you face choices where the locally-best option isn't provably correct, forcing you to explore all options. If the locally-greedy choice is always safe it's greedy; if there's a clear graph distance it may be BFS/Dijkstra. When unsure, try two or three tiny examples by hand and see whether sub-results repeat.
DP is recursion plus memoization — roughly "95% recursion, 5% memo." You write the same recursive solution (parameters, base case, options, combine), then add one cache check so each unique subproblem is computed only once. That single change drops the time complexity from exponential to polynomial without altering the logic.
Count the parameters that change between recursive calls. One changing parameter → 1-D array; two → 2-D; three or more → a multi-dimensional array or a HashMap keyed by the concatenated changing parameters. Constants like the input array and its length never count, because they can't distinguish one subproblem from another.
Either; they have identical time and space complexity and are mechanically interconvertible. Top-down (recursion + memo) is usually more intuitive and easier to derive from the recurrence, so it's a safe default. Bottom-up (iterative table filling) avoids deep call stacks, which can matter for very large inputs. State that they're equivalent and pick whichever you can implement cleanly under pressure.
Because smaller items further along may still complete a valid solution. Returning 0 throws those away. The correct move is to skip the oversized item and recurse on the previous index, leaving the capacity unchanged. You only return a true failure value (0 or false) at a genuine dead end — the target goes negative, or you run out of items.
Value is the reward for putting an item in the bag. In the "skip" branch the item never enters the bag, so there's nothing to add — you just carry the capacity forward unchanged. Adding the value in both branches is a common bug that silently inflates the answer.
prevIdx for and why the +1
in the memo index? ❯prevIdx holds the index of the last element you kept, so you can enforce "strictly
increasing" by comparing the current element against a[prevIdx]. It starts at
-1 (nothing kept yet), and since arrays can't be indexed at -1, the memo
uses dp[idx][prevIdx + 1] to shift the valid range to start at 0.
No. Both forms visit the same set of subproblems exactly once thanks to memoization, so time and space are identical. The difference is only the driver: recursion descends from the goal and caches on the way back; the loop builds the same table forward from the base case. A correctly memoized top-down solution won't overflow the stack any more than necessary.
Greedy commits to the best-looking choice at each step and never reconsiders — it's essentially structured brute force, valid only when the local optimum guarantees the global one. DP explores all options for each decision and then takes the min/max, precisely because you can't trust a single local choice. If a counterexample shows greedy failing on small inputs, you need DP.
Compiled and refined into a single reference on dynamic programming. Filler removed; garbled terms reconciled against the code. Items marked [+] are clarifying additions not in the sources; ! would flag a source conflict (none material here). Related topics: backtracking and trie data structures — both recursion-heavy.