Dynamic Programming
Level 2: Intermediate - Master optimization through memoization and tabulation
Dynamic Programming Fundamentals
What is Dynamic Programming?
Core Concept
Dynamic Programming (DP) is an optimization technique that solves complex problems by breaking them down into simpler subproblems and storing the results to avoid redundant calculations.
📝 Real-Life Analogy:
Think of doing homework problems. Instead of solving the same math problem multiple times, you write down the answer once and refer back to it whenever you need it again.
🎯 When to Use DP:
- Optimal Substructure: Problem can be broken into smaller subproblems
- Overlapping Subproblems: Same subproblems are solved multiple times
- Optimization: Finding minimum, maximum, or counting solutions
Memoization (Top-Down)
Fibonacci with Memoization
Memoization stores the results of expensive function calls and returns the cached result when the same inputs occur again.
🔄 Memoization vs Naive Recursion:
Naive Fibonacci(5) - Exponential Time O(2^n): fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1) / \ / \ / \ fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) / \ fib(1) fib(0) ❌ Problem: fib(3) calculated 2 times, fib(2) calculated 3 times! Memoized Fibonacci(5) - Linear Time O(n): fib(5) → memo[5] = fib(4) + fib(3) fib(4) → memo[4] = fib(3) + fib(2) fib(3) → memo[3] = fib(2) + fib(1) ← Calculated once, stored fib(2) → memo[2] = fib(1) + fib(0) ← Calculated once, stored fib(1) → memo[1] = 1 fib(0) → memo[0] = 0 ✅ Each subproblem calculated exactly once! Memo Table: [0, 1, 1, 2, 3, 5] fib(0) through fib(5)
🌍 Real-World Applications:
💰 Financial Trading: Caching complex option pricing calculations to avoid recalculating the same scenarios
🎮 Game Development: Storing pathfinding results for NPCs to avoid recalculating routes in similar game states
🔍 Web Search: Caching search results and ranking calculations for frequently queried terms
🧬 Bioinformatics: Storing DNA sequence alignment scores to speed up genetic analysis
TypeScript
// Without memoization - O(2^n) time complexity function fibonacciNaive(n: number): number { if (n <= 1) return n; return fibonacciNaive(n - 1) + fibonacciNaive(n - 2); } // With memoization - O(n) time complexity function fibonacciMemo(n: number, memo: Map= new Map()): number { if (n <= 1) return n; if (memo.has(n)) { return memo.get(n)!; } const result = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo); memo.set(n, result); return result; } // Alternative using object cache function fibonacciMemoObj(n: number, cache: {[key: number]: number} = {}): number { if (n <= 1) return n; if (cache[n] !== undefined) { return cache[n]; } cache[n] = fibonacciMemoObj(n - 1, cache) + fibonacciMemoObj(n - 2, cache); return cache[n]; } // Example usage console.log(fibonacciMemo(40)); // Much faster than naive approach
Python
# Without memoization - O(2^n) time complexity def fibonacci_naive(n): if n <= 1: return n return fibonacci_naive(n - 1) + fibonacci_naive(n - 2) # With memoization using dictionary def fibonacci_memo(n, memo=None): if memo is None: memo = {} if n <= 1: return n if n in memo: return memo[n] memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo) return memo[n] # Using Python's built-in lru_cache decorator from functools import lru_cache @lru_cache(maxsize=None) def fibonacci_lru(n): if n <= 1: return n return fibonacci_lru(n - 1) + fibonacci_lru(n - 2) # Example usage print(fibonacci_memo(40)) # Much faster than naive approach print(fibonacci_lru(40)) # Even cleaner with decorator
OCaml
(* Without memoization - O(2^n) time complexity *) let rec fibonacci_naive n = if n <= 1 then n else fibonacci_naive (n - 1) + fibonacci_naive (n - 2) (* With memoization using hashtable *) let fibonacci_memo n = let memo = Hashtbl.create 100 in let rec fib n = if n <= 1 then n else try Hashtbl.find memo n with Not_found -> let result = fib (n - 1) + fib (n - 2) in Hashtbl.add memo n result; result in fib n (* Alternative using Map for immutable memoization *) module IntMap = Map.Make(Int) let fibonacci_map n = let rec fib n memo = if n <= 1 then (n, memo) else try (IntMap.find n memo, memo) with Not_found -> let (result1, memo1) = fib (n - 1) memo in let (result2, memo2) = fib (n - 2) memo1 in let result = result1 + result2 in let new_memo = IntMap.add n result memo2 in (result, new_memo) in fst (fib n IntMap.empty) (* Example usage *) let result = fibonacci_memo 40 (* Much faster than naive approach *)
🎯 Memoization Benefits:
- Easy to implement: Add caching to existing recursive solution
- Natural recursion: Maintains recursive structure
- Lazy evaluation: Only computes needed subproblems
- Space efficient: Only stores computed results
Tabulation (Bottom-Up)
Fibonacci with Tabulation
Tabulation builds up solutions from the smallest subproblems to the target problem, typically using iteration and arrays.
TypeScript
// Tabulation approach - O(n) time, O(n) space function fibonacciTab(n: number): number { if (n <= 1) return n; const dp: number[] = new Array(n + 1); dp[0] = 0; dp[1] = 1; for (let i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } // Space-optimized version - O(n) time, O(1) space function fibonacciOptimized(n: number): number { if (n <= 1) return n; let prev2 = 0; let prev1 = 1; for (let i = 2; i <= n; i++) { const current = prev1 + prev2; prev2 = prev1; prev1 = current; } return prev1; } // Example: Climbing Stairs Problem function climbStairs(n: number): number { if (n <= 2) return n; const dp: number[] = new Array(n + 1); dp[1] = 1; dp[2] = 2; for (let i = 3; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } console.log(fibonacciTab(10)); // 55 console.log(climbStairs(5)); // 8 ways to climb 5 stairs
Python
# Tabulation approach - O(n) time, O(n) space def fibonacci_tab(n): if n <= 1: return n dp = [0] * (n + 1) dp[0] = 0 dp[1] = 1 for i in range(2, n + 1): dp[i] = dp[i - 1] + dp[i - 2] return dp[n] # Space-optimized version - O(n) time, O(1) space def fibonacci_optimized(n): if n <= 1: return n prev2, prev1 = 0, 1 for i in range(2, n + 1): current = prev1 + prev2 prev2, prev1 = prev1, current return prev1 # Example: Climbing Stairs Problem def climb_stairs(n): if n <= 2: return n dp = [0] * (n + 1) dp[1] = 1 dp[2] = 2 for i in range(3, n + 1): dp[i] = dp[i - 1] + dp[i - 2] return dp[n] # Example: House Robber Problem def rob_houses(nums): if not nums: return 0 if len(nums) == 1: return nums[0] dp = [0] * len(nums) dp[0] = nums[0] dp[1] = max(nums[0], nums[1]) for i in range(2, len(nums)): dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]) return dp[-1] print(fibonacci_tab(10)) # 55 print(climb_stairs(5)) # 8 ways to climb 5 stairs print(rob_houses([2, 7, 9, 3, 1])) # 12 (rob houses 0, 2, 4)
OCaml
(* Tabulation approach - O(n) time, O(n) space *) let fibonacci_tab n = if n <= 1 then n else let dp = Array.make (n + 1) 0 in dp.(0) <- 0; dp.(1) <- 1; for i = 2 to n do dp.(i) <- dp.(i - 1) + dp.(i - 2) done; dp.(n) (* Space-optimized version - O(n) time, O(1) space *) let fibonacci_optimized n = if n <= 1 then n else let prev2 = ref 0 in let prev1 = ref 1 in for i = 2 to n do let current = !prev1 + !prev2 in prev2 := !prev1; prev1 := current done; !prev1 (* Example: Climbing Stairs Problem *) let climb_stairs n = if n <= 2 then n else let dp = Array.make (n + 1) 0 in dp.(1) <- 1; dp.(2) <- 2; for i = 3 to n do dp.(i) <- dp.(i - 1) + dp.(i - 2) done; dp.(n) (* Example: House Robber Problem *) let rob_houses nums = let len = Array.length nums in if len = 0 then 0 else if len = 1 then nums.(0) else let dp = Array.make len 0 in dp.(0) <- nums.(0); dp.(1) <- max nums.(0) nums.(1); for i = 2 to len - 1 do dp.(i) <- max dp.(i - 1) (dp.(i - 2) + nums.(i)) done; dp.(len - 1) (* Example usage *) let result1 = fibonacci_tab 10 (* 55 *) let result2 = climb_stairs 5 (* 8 ways to climb 5 stairs *) let result3 = rob_houses [|2; 7; 9; 3; 1|] (* 12 *)
🎯 Tabulation Benefits:
- No recursion overhead: Uses iteration instead
- Predictable space: Clear memory usage pattern
- Bottom-up approach: Builds from base cases
- Often more efficient: Better cache locality
Classic DP Problems
0/1 Knapsack Problem
Given items with weights and values, and a knapsack with limited capacity, find the maximum value that can be obtained.
🎒 Real-Life Analogy:
You're packing for a trip with a weight limit. Each item has a value to you and a weight. You want to maximize value while staying under the weight limit.
📊 DP Table Filling Process:
Items: [weight, value] Item 1: [1, 1] Item 2: [3, 4] Item 3: [4, 5] Item 4: [5, 7] Capacity: 7 DP Table: dp[i][w] = max value using first i items with capacity w w→ 0 1 2 3 4 5 6 7 i=0 0 0 0 0 0 0 0 0 ← No items i=1 0 1 1 1 1 1 1 1 ← Item 1 (w=1, v=1) i=2 0 1 1 4 5 5 5 5 ← Item 2 (w=3, v=4) i=3 0 1 1 4 5 6 6 9 ← Item 3 (w=4, v=5) i=4 0 1 1 4 5 7 8 9 ← Item 4 (w=5, v=7) ↑ ↑ Base case Answer: 9 Step-by-step for dp[2][4] (Items 1,2 with capacity 4): • Don't take Item 2: dp[1][4] = 1 • Take Item 2: dp[1][4-3] + 4 = dp[1][1] + 4 = 1 + 4 = 5 • dp[2][4] = max(1, 5) = 5 ✅ Optimal solution: Items 2 and 3 (values 4+5=9, weights 3+4=7)
🌍 Real-World Applications:
💼 Resource Allocation: Optimizing budget allocation across projects with limited resources
📦 Cargo Loading: Maximizing value of goods in shipping containers with weight constraints
💾 Memory Management: Selecting which processes to keep in limited RAM for optimal performance
🎯 Portfolio Optimization: Selecting investments to maximize returns within risk constraints
⚡ Performance Comparison:
Approach | Time Complexity | Space Complexity | Best For |
---|---|---|---|
Brute Force | O(2^n) | O(n) | ❌ Never practical |
2D DP Table | O(n × W) | O(n × W) | ✅ Learning & tracing |
1D DP Array | O(n × W) | O(W) | ✅ Production use |
Memoization | O(n × W) | O(n × W) | ⚠️ Recursive overhead |
TypeScript
function knapsack(weights: number[], values: number[], capacity: number): number { const n = weights.length; // dp[i][w] = maximum value using first i items with capacity w const dp: number[][] = Array(n + 1).fill(null).map(() => Array(capacity + 1).fill(0)); for (let i = 1; i <= n; i++) { for (let w = 1; w <= capacity; w++) { // Don't take item i-1 dp[i][w] = dp[i - 1][w]; // Take item i-1 if it fits if (weights[i - 1] <= w) { const takeItem = dp[i - 1][w - weights[i - 1]] + values[i - 1]; dp[i][w] = Math.max(dp[i][w], takeItem); } } } return dp[n][capacity]; } // Space-optimized version using 1D array function knapsackOptimized(weights: number[], values: number[], capacity: number): number { const dp: number[] = new Array(capacity + 1).fill(0); for (let i = 0; i < weights.length; i++) { // Traverse backwards to avoid using updated values for (let w = capacity; w >= weights[i]; w--) { dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]); } } return dp[capacity]; } // Example usage const weights = [1, 3, 4, 5]; const values = [1, 4, 5, 7]; const capacity = 7; console.log(knapsack(weights, values, capacity)); // 9
Python
def knapsack(weights, values, capacity): n = len(weights) # dp[i][w] = maximum value using first i items with capacity w dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)] for i in range(1, n + 1): for w in range(1, capacity + 1): # Don't take item i-1 dp[i][w] = dp[i - 1][w] # Take item i-1 if it fits if weights[i - 1] <= w: take_item = dp[i - 1][w - weights[i - 1]] + values[i - 1] dp[i][w] = max(dp[i][w], take_item) return dp[n][capacity] # Space-optimized version using 1D array def knapsack_optimized(weights, values, capacity): dp = [0] * (capacity + 1) for i in range(len(weights)): # Traverse backwards to avoid using updated values for w in range(capacity, weights[i] - 1, -1): dp[w] = max(dp[w], dp[w - weights[i]] + values[i]) return dp[capacity] # Longest Common Subsequence def lcs(text1, text2): m, n = len(text1), len(text2) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if text1[i - 1] == text2[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) return dp[m][n] # Example usage weights = [1, 3, 4, 5] values = [1, 4, 5, 7] capacity = 7 print(knapsack(weights, values, capacity)) # 9 print(lcs("abcde", "ace")) # 3 (longest common subsequence is "ace")
OCaml
(* 0/1 Knapsack Problem *) let knapsack weights values capacity = let n = Array.length weights in let dp = Array.make_matrix (n + 1) (capacity + 1) 0 in for i = 1 to n do for w = 1 to capacity do (* Don't take item i-1 *) dp.(i).(w) <- dp.(i - 1).(w); (* Take item i-1 if it fits *) if weights.(i - 1) <= w then let take_item = dp.(i - 1).(w - weights.(i - 1)) + values.(i - 1) in dp.(i).(w) <- max dp.(i).(w) take_item done done; dp.(n).(capacity) (* Space-optimized version *) let knapsack_optimized weights values capacity = let dp = Array.make (capacity + 1) 0 in for i = 0 to Array.length weights - 1 do (* Traverse backwards to avoid using updated values *) for w = capacity downto weights.(i) do dp.(w) <- max dp.(w) (dp.(w - weights.(i)) + values.(i)) done done; dp.(capacity) (* Longest Common Subsequence *) let lcs text1 text2 = let m = String.length text1 in let n = String.length text2 in let dp = Array.make_matrix (m + 1) (n + 1) 0 in for i = 1 to m do for j = 1 to n do if text1.[i - 1] = text2.[j - 1] then dp.(i).(j) <- dp.(i - 1).(j - 1) + 1 else dp.(i).(j) <- max dp.(i - 1).(j) dp.(i).(j - 1) done done; dp.(m).(n) (* Example usage *) let weights = [|1; 3; 4; 5|] let values = [|1; 4; 5; 7|] let capacity = 7 let result1 = knapsack weights values capacity (* 9 *) let result2 = lcs "abcde" "ace" (* 3 *)
🎯 Common DP Problem Patterns:
- Linear DP: Fibonacci, climbing stairs, house robber
- Grid DP: Unique paths, minimum path sum
- String DP: Longest common subsequence, edit distance
- Knapsack DP: 0/1 knapsack, coin change, subset sum
Problem Type | Time Complexity | Space Complexity |
---|---|---|
1D DP (Fibonacci) | O(n) | O(n) → O(1) |
2D DP (Knapsack) | O(n × W) | O(n × W) → O(W) |
String DP (LCS) | O(m × n) | O(m × n) → O(min(m,n)) |