Dynamic Programming
One-liner: if a problem is made of smaller copies of itself that overlap, solve each copy once and remember the answer.
The analogy
You are climbing a staircase and a friend asks how many ways there are to reach step 40. You do not recount from the ground every time. You write on each step "ways to get here", and each new number is just the sum of the two numbers below it. DP is the notebook: the recursion tells you what depends on what, and the notebook makes sure you never answer the same question twice.
Recognition signals
Reach for DP when you see:
- A request for a count of ways, a minimum / maximum, or a yes/no feasibility — not the list of answers.
- A natural recursive description, where the answer for a big input is built from answers for smaller inputs.
- The same smaller inputs would be reached again and again (overlapping subproblems), and the best overall answer uses best answers for the parts (optimal substructure).
- Inputs are too big for exponential search — typical constraints are n up to a few thousand.
Step-by-step walkthrough
Use Climbing Stairs: you can take 1 or 2 steps; how many ways to reach step n?
- Brute force. To reach step n you came from n-1 or n-2, so
ways(n) = ways(n-1) + ways(n-2). Correct, but the recursion tree branches twice at every level.
// climbBrute is the plain recursion: correct but O(2^n) because the same
// subproblems are solved again and again.
func climbBrute(n int) int {
if n <= 1 {
return 1
}
return climbBrute(n-1) + climbBrute(n-2)
}- Spot the repetition.
ways(5)callsways(3)twice,ways(2)three times, and so on. Only n distinct states exist, but we solve exponentially many calls. - Memoize (top-down). Keep the recursion, add a cache. Each state is computed once, so time drops to O(n). This is the safest first move: it needs only the state and the transition.
- Tabulate (bottom-up). Fill an array from the base cases upward in an order that satisfies the dependencies. No recursion, no stack depth risk.
- Compress space. If a state needs only the last few entries, keep only those.
Code template
The top-down template: base case, cache check, transition, store.
// Template: top-down. Define the state, write the transition, handle base cases,
// and cache every answer so each state is computed once.
// Here the state is just `i` (a 1-D problem); more parameters -> a map or a 2-D table.
func memoTemplate(n int) int {
memo := make([]int, n+1)
for i := range memo {
memo[i] = -1 // -1 = "not computed yet"
}
var solve func(i int) int
solve = func(i int) int {
if i <= 1 { // base case: the smallest subproblems
return 1
}
if memo[i] != -1 { // already solved: reuse
return memo[i]
}
memo[i] = solve(i-1) + solve(i-2) // transition: combine smaller states
return memo[i]
}
return solve(n)
}Why each part exists:
memo[i] = -1You need a marker for "not computed yet". Use a value that can never be a real answer (-1, or a separate seen array when -1 is valid).
if i <= 1 { return 1 }Base case. The smallest subproblems must be answered directly, otherwise the recursion never stops (or reads out of range).
if memo[i] != -1 { return memo[i] }This one line turns exponential into linear: a state already solved is returned instantly instead of re-expanded.
memo[i] = solve(i-1) + solve(i-2)Transition. Combine smaller states. For counting use +, for optimisation use min or max over the choices.
The real solutions
Climbing stairs — bottom-up, O(1) space
State: ways to reach step i. Only the last two values matter.
// ClimbStairs: ways to reach step n taking 1 or 2 steps at a time.
// Bottom-up with O(1) space: dp[i] only needs dp[i-1] and dp[i-2].
func ClimbStairs(n int) int {
prev, cur := 1, 1 // ways to reach step 0 and step 1
for i := 2; i <= n; i++ {
prev, cur = cur, prev+cur
}
return cur
}House robber — take it or skip it
State: best loot among the first i houses. Transition: either skip house i, or take it and add the best up to i-2.
// Rob: max loot from a row of houses, never two adjacent.
// State: best loot using houses[0..i]. Transition: skip house i, or take it + best up to i-2.
func Rob(nums []int) int {
take, skip := 0, 0 // best if we may use the previous house / best up to two houses ago
for _, v := range nums {
take, skip = max(skip+v, take), take
}
return take
}Coin change — choose the last coin
State: fewest coins for amount a. Transition: try every coin as the last one. Fill small amounts first. Greedy fails here: with coins 1, 3, 4 and amount 6, taking 4 first gives three coins, while 3 + 3 gives two.
// CoinChange: fewest coins that make up amount, or -1.
// State: dp[a] = fewest coins for amount a. Order: small amounts first.
func CoinChange(coins []int, amount int) int {
inf := amount + 1 // larger than any real answer
dp := make([]int, amount+1)
for a := 1; a <= amount; a++ {
dp[a] = inf
for _, c := range coins {
if c <= a {
dp[a] = min(dp[a], dp[a-c]+1)
}
}
}
if dp[amount] >= inf {
return -1
}
return dp[amount]
}Coin change — watch the table fill
Step through the code above. Each cell is "the fewest coins for this amount"; the highlighted cell on the left is the answer you are borrowing from.
- a
- 0
- coin
- —
- dp[a]
- 0
dp[a] will hold the fewest coins that make amount a. "∞" (inf = 12) means "not reachable yet".
// CoinChange: fewest coins that make up amount, or -1.
// State: dp[a] = fewest coins for amount a. Order: small amounts first.
func CoinChange(coins []int, amount int) int {
inf := amount + 1 // larger than any real answer
dp := make([]int, amount+1)
for a := 1; a <= amount; a++ {
dp[a] = inf
for _, c := range coins {
if c <= a {
dp[a] = min(dp[a], dp[a-c]+1)
}
}
}
if dp[amount] >= inf {
return -1
}
return dp[amount]
}Longest common subsequence — a 2-D table
State: the answer for the first i characters of one string and the first j of the other. Row 0 and column 0 (an empty prefix) are the base cases. A match extends the diagonal; otherwise drop a character from either side.
// LongestCommonSubsequence.
// State: dp[i][j] = LCS length of a[:i] and b[:j]. Row 0 and column 0 are the empty-prefix base cases.
func LongestCommonSubsequence(a, b string) int {
dp := make([][]int, len(a)+1)
for i := range dp {
dp[i] = make([]int, len(b)+1)
}
for i := 1; i <= len(a); i++ {
for j := 1; j <= len(b); j++ {
if a[i-1] == b[j-1] {
dp[i][j] = dp[i-1][j-1] + 1 // match: extend the diagonal
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) // drop one character
}
}
}
return dp[len(a)][len(b)]
}Edit distance — three moves
Same table shape. When characters differ, the cheapest of replace (diagonal), delete (up), insert (left) wins. The base cases are not zero: turning a string into the empty string costs its length.
// MinDistance: fewest insert / delete / replace edits to turn a into b.
// State: dp[i][j] = edits to turn a[:i] into b[:j].
func MinDistance(a, b string) int {
dp := make([][]int, len(a)+1)
for i := range dp {
dp[i] = make([]int, len(b)+1)
dp[i][0] = i // delete everything
}
for j := 0; j <= len(b); j++ {
dp[0][j] = j // insert everything
}
for i := 1; i <= len(a); i++ {
for j := 1; j <= len(b); j++ {
if a[i-1] == b[j-1] {
dp[i][j] = dp[i-1][j-1]
} else {
dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) // replace, delete, insert
}
}
}
return dp[len(a)][len(b)]
}0/1 knapsack — direction is the whole trick
Each item may be used once. With a 1-D table, loop the capacity downwards, so dp[c - w] still reflects the state before this item. Looping upwards would let the item be counted repeatedly (that is the unbounded version).
// Knapsack: max value using each item at most once within capacity (0/1 knapsack).
// 1-D table; loop capacity DOWNWARD so an item cannot be counted twice.
func Knapsack(weights, values []int, capacity int) int {
dp := make([]int, capacity+1)
for i, w := range weights {
for c := capacity; c >= w; c-- {
dp[c] = max(dp[c], dp[c-w]+values[i])
}
}
return dp[capacity]
}Longest increasing subsequence — state ends at i
State: the best length of a subsequence that ends exactly at index i. The answer is the maximum over all i, not the last cell.
// LengthOfLIS: length of the longest strictly increasing subsequence.
// State: dp[i] = best length ending exactly at nums[i].
func LengthOfLIS(nums []int) int {
best := 0
dp := make([]int, len(nums))
for i := range nums {
dp[i] = 1
for j := 0; j < i; j++ {
if nums[j] < nums[i] {
dp[i] = max(dp[i], dp[j]+1)
}
}
best = max(best, dp[i])
}
return best
}More 1-D problems from the 150-problem roadmap
Each one is the same four-step recipe — state, transition, base case, order — with a different sentence for "what does dp[i] mean".
Min Cost Climbing Stairs — the cost to stand on a step is its own cost plus the cheaper of the two steps below it:
// MinCostClimbingStairs: you may start on step 0 or 1; the top is just past the last step.
// State: cost to stand on step i = cost[i] + the cheaper of the two steps you could have come from.
func MinCostClimbingStairs(cost []int) int {
a, b := 0, 0 // cheapest cost to reach the two most recent steps (starting is free)
for i := 2; i <= len(cost); i++ {
a, b = b, min(b+cost[i-1], a+cost[i-2])
}
return b
}House Robber II — the street is a circle. Break it into two lines and reuse House Robber:
// RobCircle: houses form a circle, so the first and last cannot both be robbed.
// Solve the line twice (without the last house, then without the first) and take the better one.
func RobCircle(nums []int) int {
if len(nums) == 1 {
return nums[0]
}
return max(Rob(nums[:len(nums)-1]), Rob(nums[1:]))
}Decode Ways — the last digit alone, or the last two digits together; each is valid only in its own range:
// NumDecodings: "12" can be "AB" (1,2) or "L" (12).
// dp[i] = ways to decode the first i digits = (last digit alone is 1-9) + (last two digits are 10-26).
func NumDecodings(s string) int {
if len(s) == 0 || s[0] == '0' {
return 0
}
prev2, prev1 := 1, 1 // ways for 0 digits and for 1 digit
for i := 2; i <= len(s); i++ {
cur := 0
if s[i-1] != '0' {
cur += prev1
}
if two := int(s[i-2]-'0')*10 + int(s[i-1]-'0'); two >= 10 && two <= 26 {
cur += prev2
}
prev2, prev1 = prev1, cur
}
return prev1
}Word Break — a prefix can be split if an earlier prefix can, and the piece between them is a word:
// WordBreak: can s be split into dictionary words?
// dp[i] = the first i letters can be split, true when some earlier cut j works AND s[j:i] is a word.
func WordBreak(s string, words []string) bool {
dict := make(map[string]bool, len(words))
for _, w := range words {
dict[w] = true
}
dp := make([]bool, len(s)+1)
dp[0] = true
for i := 1; i <= len(s); i++ {
for j := 0; j < i; j++ {
if dp[j] && dict[s[j:i]] {
dp[i] = true
break
}
}
}
return dp[len(s)]
}Partition Equal Subset Sum — restate it as "can some numbers add up to half the total?", a 0/1 knapsack on yes/no:
// CanPartition: split into two subsets with equal sums = can some numbers reach exactly half?
// reachable[s] means "some chosen numbers add up to s". Loop DOWNWARDS so each number is used once.
func CanPartition(nums []int) bool {
total := 0
for _, n := range nums {
total += n
}
if total%2 != 0 {
return false
}
half := total / 2
reachable := make([]bool, half+1)
reachable[0] = true
for _, n := range nums {
for s := half; s >= n; s-- {
reachable[s] = reachable[s] || reachable[s-n]
}
}
return reachable[half]
}Maximum Product Subarray — a negative can turn the smallest product into the largest, so carry both:
// MaxProduct: a negative number can flip the smallest product into the largest,
// so track BOTH the max and the min product of a subarray ending here.
func MaxProduct(nums []int) int {
best, hi, lo := nums[0], nums[0], nums[0]
for _, n := range nums[1:] {
a, b := hi*n, lo*n
hi = max(n, max(a, b))
lo = min(n, min(a, b))
best = max(best, hi)
}
return best
}Longest Palindromic Substring / Palindromic Substrings — no table needed: expand from each of the 2n−1 centres:
// LongestPalindrome: every palindrome has a centre, and there are 2n-1 centres
// (n letters and n-1 gaps between letters). Expand outward from each while the ends match.
func LongestPalindrome(s string) string {
start, length := 0, 0
expand := func(l, r int) {
for l >= 0 && r < len(s) && s[l] == s[r] {
l--
r++
}
if r-l-1 > length {
start, length = l+1, r-l-1
}
}
for i := range s {
expand(i, i) // odd length, centred on a letter
expand(i, i+1) // even length, centred between two letters
}
return s[start : start+length]
}
// CountSubstrings: the same expansion, counting every success instead of keeping the longest.
func CountSubstrings(s string) int {
count := 0
expand := func(l, r int) {
for l >= 0 && r < len(s) && s[l] == s[r] {
count++
l--
r++
}
}
for i := range s {
expand(i, i)
expand(i, i+1)
}
return count
}For two-index tables (grids, two strings, ranges) continue with 2-D Dynamic Programming.
Complexity
| Approach | Time | Space |
|---|---|---|
| Plain recursion (stairs) | O(2^n) | O(n) stack |
| Memoization / tabulation (1-D) | O(n) states × O(1) transition | O(n), often O(1) after compressing |
| Coin change | O(amount × coins) | O(amount) |
| LCS / edit distance | O(m × n) | O(m × n) |
| LIS (this version) | O(n²) | O(n) |
A reliable rule: time = number of states × work per state.
Common mistakes
Practice ladder
Ordered Easy → Hard. For each, write the state, transition, base case and order before touching code.
- 1.Climbing StairsEasyHow could you have arrived at the last step?
- 2.House RobberMediumAt each house there are only two options.
- 3.Coin ChangeMediumTry each coin as the last one you used.
- 4.Longest Increasing SubsequenceMediumDefine the state as the best sequence ending exactly here.
- 5.Unique PathsMedium
- 6.Word BreakMedium
- 7.Longest Common SubsequenceMediumTwo strings means two indexes in the state.
- 8.Partition Equal Subset SumMediumRestate it as: can some items reach exactly half the total?
- 9.Edit DistanceMediumThree possible last operations.
- 10.Burst BalloonsHardThink about which balloon is burst LAST in a range.
Which pattern? Drills
Unlabeled problems — pick the pattern, then read why.
You can climb 1 or 2 stairs at a time. How many distinct ways are there to reach the top of a staircase with n steps?
Which pattern?