1D Dynamic Programming
Dynamic Programming
Experience is just remembered effort.
Most people feel intimidated by DP because it seems like a complex topic, but once you learn to recognize the patterns, it becomes much more approachable. The key is to understand that DP is all about figuring out the recurrence relations and then optimizing the way you compute them.
As always, try this problem for 20-30 minutes to warmup your curiosity machinery, and then read the rest of the article to see how we can solve it efficiently.
Problem: Climbing Stairs (LeetCode Link): You are climbing a staircase. It takes n
steps to reach the top. Each time you can either climb 1 or 2 steps. In how many distinct ways can you climb to the top? 💡 HintTo get to step n
, where could you have come from? You must have come from either step n-1
or step n-2
.
The Naive Recursive Approach
The most direct way to think about this is with recursion. The number of ways to get to step n
, let's call it ways(n)
, is the sum of the ways to get to the steps you could have come from:
ways(n) = ways(n-1) + ways(n-2)
You might recognize this equation if you've seen the Fibonacci sequence before. The base cases are: ways(0) = 1
(one way to stay at the ground) and ways(1) = 1
(one way to take one step).
def climbStairs(n):
if n <= 1:
return 1
return climbStairs(n - 1) + climbStairs(n - 2)
public int climbStairs(int n) {
if (n <= 1) return 1;
return climbStairs(n - 1) + climbStairs(n - 2);
}
int climbStairs(int n) {
if (n <= 1) return 1;
return climbStairs(n - 1) + climbStairs(n - 2);
}
This is simple and correct, but it's incredibly slow. If you call climbStairs(5)
, it will calculate climbStairs(3)
twice, climbStairs(2)
three times, and so on. This is the problem of overlapping subproblems.
The Core of DP: Remember Your Work
Dynamic Programming is simply a technique to avoid re-solving these overlapping subproblems. We do this by storing the results of subproblems in a cache (a hash map or an array) and reusing them. There are two main ways to apply this.
Strategy 1: Memoization (Top-Down DP)
This is the most intuitive upgrade from the naive recursive solution. We keep the recursive structure but add a cache to store results. Before computing ways(n)
, we check if we've already calculated it. If so, we return the cached value. If not, we compute it, store it in the cache, and then return it.
def climbStairs(n: int) -> int:
memo = {}
def solve(i):
if i in memo:
return memo[i]
if i <= 1:
return 1
memo[i] = solve(i - 1) + solve(i - 2)
return memo[i]
return solve(n)
public int climbStairs(int n) {
Map<Integer, Integer> memo = new HashMap<>();
return solve(n, memo);
}
private int solve(int n, Map<Integer, Integer> memo) {
if (memo.containsKey(n)) return memo.get(n);
if (n <= 1) return 1;
int result = solve(n - 1, memo) + solve(n - 2, memo);
memo.put(n, result);
return result;
}
int solve(int n, unordered_map<int, int>& memo) {
if (memo.count(n)) return memo[n];
if (n <= 1) return 1;
int result = solve(n - 1, memo) + solve(n - 2, memo);
memo[n] = result;
return result;
}
int climbStairs(int n) {
unordered_map<int, int> memo;
return solve(n, memo);
}
Strategy 2: Tabulation (Bottom-Up DP)
Instead of starting from the top (n
) and going down, we can start from the bottom and build our way up. We create a DP table (usually an array) and fill it out iteratively.
- Define DP State:
dp[i]
will store the number of ways to reach stepi
. - Base Cases:
dp[0] = 1
(one way to reach the start),dp[1] = 1
(one way to take one step). - Recurrence Relation:
dp[i] = dp[i-1] + dp[i-2]
. - Iteration: We loop from
i = 2
up ton
, filling the table.
def climbStairs(n: int) -> int:
if n <= 1: return 1
dp = [0] * (n + 1)
dp[0], dp[1] = 1, 1
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
public int climbStairs(int n) {
if (n <= 1) return 1;
int[] dp = new int[n + 1];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
int climbStairs(int n) {
if (n <= 1) return 1;
vector<int> dp(n + 1);
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
Space Optimization
For our climbStairs
tabulation, notice that to calculate dp[i]
, we only need dp[i-1]
and dp[i-2]
. We don't need the rest of the array! This means we can optimize the space complexity from to by only storing the last two values.
def climbStairs(n: int) -> int:
if n <= 1: return 1
one_back, two_back = 1, 1
for i in range(2, n + 1):
current = one_back + two_back
two_back = one_back
one_back = current
return one_back
public int climbStairs(int n) {
if (n <= 1) return 1;
int oneBack = 1, twoBack = 1;
for (int i = 2; i <= n; i++) {
int current = oneBack + twoBack;
twoBack = oneBack;
oneBack = current;
}
return oneBack;
}
int climbStairs(int n) {
if (n <= 1) return 1;
int oneBack = 1, twoBack = 1;
for (int i = 2; i <= n; i++) {
int current = oneBack + twoBack;
twoBack = oneBack;
oneBack = current;
}
return oneBack;
}
Nice! isn't it? Can you do the same for the other problems? Try it out!
Bonus: In real-world software, we rarely compute the same expensive thing more than once. Whether it’s rendering a UI, fetching data from a remote server, or running a recommendation engine, memoization shows up as caching, and tabulation appears as precomputation.
More Problems & Variations
- House Robber (LeetCode Link): You are a robber planning to rob houses. You cannot rob adjacent houses. What's the maximum amount you can rob?
💡 HintThe decision at house
i
depends on what you did at housei-1
.dp[i]
could be the max money robbed up to housei
. The recurrence isdp[i] = max(dp[i-1], dp[i-2] + nums[i])
. - Min Cost Climbing Stairs (LeetCode Link): You are given an integer array
cost
wherecost[i]
is the cost ofi
th step on a staircase. Once you pay the cost, you can either climb one or two steps. Find the minimum cost to reach the top. 💡 HintThis is very similar to Climbing Stairs, but with costs.dp[i]
is the min cost to reach stepi
. The recurrence isdp[i] = cost[i] + min(dp[i-1], dp[i-2])
. - Coin Change (LeetCode Link): Given coins of different denominations and a total amount, find the fewest number of coins needed to make up that amount.
💡 Hint
dp[i]
is the fewest coins to make amounti
. For each amount, you iterate through the available coins to see which one leads to the optimal solution.