6 - Dynamic Programming
Dynamic Programming (DP) is a powerful algorithmic technique used to solve complex problems by breaking them down into simpler subproblems. It's particularly effective when a problem has overlapping subproblems and optimal substructure.
Understanding Dynamic Programming
What is Dynamic Programming?
Dynamic Programming is a method for solving complex problems by breaking them down into simpler subproblems and storing the results to avoid redundant calculations. The name "dynamic programming" was chosen for political reasons and doesn't have a clear technical meaning - it was designed to sound impressive!
Real-World Analogy
Think of planning a road trip with multiple possible routes. Instead of recalculating the time for common segments every time, you:
- Calculate the time for each road segment once
- Write down these results
- Reuse them when evaluating different routes that share segments
This is exactly what dynamic programming does - it solves each subproblem once and stores the solution for future use.
Key Concepts
Overlapping Subproblems
A problem has overlapping subproblems when the same subproblems need to be solved multiple times.
Example: In calculating the Fibonacci sequence, F(5) requires calculating F(4) and F(3). But F(4) also requires F(3). Without dynamic programming, F(3) would be calculated twice.
F(5)
├── F(4)
│ ├── F(3) ← Calculated twice!
│ │ ├── F(2)
│ │ └── F(1)
│ └── F(2)
└── F(3)
├── F(2)
└── F(1)
Dynamic programming addresses this by solving each subproblem only once and storing the results for future use.
Optimal Substructure
A problem has optimal substructure when the optimal solution to the problem contains optimal solutions to its subproblems.
Example: In the shortest path problem, if the shortest path from A to C goes through B, then the path from A to B and the path from B to C must also be the shortest paths between those points.
This property allows us to build the solution to the original problem from the solutions to its subproblems.
Approaches to Dynamic Programming
There are two main approaches to implementing dynamic programming:
1. Top-Down Approach (Memoization)
The top-down approach starts with the original problem and recursively breaks it down into subproblems. To avoid redundant calculations, it stores the results of subproblems in a cache (usually a dictionary or array).
Example: Fibonacci Sequence with Memoization
/// <summary>
/// Calculates the nth Fibonacci number using memoization.
/// </summary>
/// <param name="n">The position in the Fibonacci sequence.</param>
/// <returns>The nth Fibonacci number.</returns>
public int Fibonacci(int n)
{
// Create a dictionary to store previously calculated Fibonacci numbers
return FibonacciMemoized(n, new Dictionary<int, int>());
}
/// <summary>
/// Helper method that implements the memoized Fibonacci calculation.
/// </summary>
/// <param name="n">The position in the Fibonacci sequence.</param>
/// <param name="memo">Dictionary to store previously calculated values.</param>
/// <returns>The nth Fibonacci number.</returns>
private int FibonacciMemoized(int n, Dictionary<int, int> memo)
{
// Base cases: F(0) = 0, F(1) = 1
if (n <= 1)
return n;
// If we've already calculated this value, return it from the cache
if (memo.ContainsKey(n))
return memo[n];
// Calculate F(n) = F(n-1) + F(n-2) and store in the cache
memo[n] = FibonacciMemoized(n - 1, memo) + FibonacciMemoized(n - 2, memo);
return memo[n];
}
Step-by-Step Explanation
Let's trace through calculating F(5) using memoization:
- Call
Fibonacci(5)
, which callsFibonacciMemoized(5, {})
- Since 5 > 1 and 5 is not in the memo, calculate
FibonacciMemoized(4, {}) + FibonacciMemoized(3, {})
- For
FibonacciMemoized(4, {})
:- Calculate
FibonacciMemoized(3, {}) + FibonacciMemoized(2, {})
- For
FibonacciMemoized(3, {})
:- Calculate
FibonacciMemoized(2, {}) + FibonacciMemoized(1, {})
- For
FibonacciMemoized(2, {})
:- Calculate
FibonacciMemoized(1, {}) + FibonacciMemoized(0, {})
= 1 + 0 = 1
- Calculate
- Store memo[2] = 1
FibonacciMemoized(1, {})
returns 1 (base case)- Store memo[3] = 1 + 1 = 2
- Calculate
- For
FibonacciMemoized(2, {})
:- Return 1 from memo (already calculated)
- Store memo[4] = 2 + 1 = 3
- Calculate
- For
FibonacciMemoized(3, {})
:- Return 2 from memo (already calculated)
- Store memo[5] = 3 + 2 = 5
- Return 5
Notice how FibonacciMemoized(2, {})
and FibonacciMemoized(3, {})
are only calculated once, despite being needed multiple times.
2. Bottom-Up Approach (Tabulation)
The bottom-up approach starts with the smallest subproblems and iteratively builds up to the original problem. It typically uses an array or table to store the results of subproblems.
Example: Fibonacci Sequence with Tabulation
/// <summary>
/// Calculates the nth Fibonacci number using tabulation.
/// </summary>
/// <param name="n">The position in the Fibonacci sequence.</param>
/// <returns>The nth Fibonacci number.</returns>
public int Fibonacci(int n)
{
// Handle base cases
if (n <= 1)
return n;
// Create an array to store all Fibonacci numbers from 0 to n
int[] fib = new int[n + 1];
fib[0] = 0; // F(0) = 0
fib[1] = 1; // F(1) = 1
// Build up the array by calculating each Fibonacci number
for (int i = 2; i <= n; i++)
{
// Each number is the sum of the two previous numbers
fib[i] = fib[i - 1] + fib[i - 2];
}
// Return the nth Fibonacci number
return fib[n];
}
Step-by-Step Explanation
Let's trace through calculating F(5) using tabulation:
- Initialize
fib[0] = 0
andfib[1] = 1
- For i = 2:
fib[2] = fib[1] + fib[0] = 1 + 0 = 1
- For i = 3:
fib[3] = fib[2] + fib[1] = 1 + 1 = 2
- For i = 4:
fib[4] = fib[3] + fib[2] = 2 + 1 = 3
- For i = 5:
fib[5] = fib[4] + fib[3] = 3 + 2 = 5
- Return
fib[5] = 5
The bottom-up approach calculates all Fibonacci numbers from 0 to n in order, without recursion.
Comparison of Approaches
Aspect | Top-Down (Memoization) | Bottom-Up (Tabulation) |
---|---|---|
Implementation | Usually recursive | Usually iterative |
Subproblem Solving | Solves only necessary subproblems | Solves all subproblems |
Space Efficiency | Can be more space-efficient if not all subproblems are needed | Uses space for all subproblems |
Stack Overflow | Potential issue for deep recursion | Not an issue (iterative) |
Code Complexity | Often more intuitive and closer to the problem definition | Sometimes more complex to formulate |
When to Use | When not all subproblems need to be solved | When most subproblems need to be solved anyway |
Classic Dynamic Programming Problems
1. Longest Common Subsequence (LCS)
The LCS problem finds the longest subsequence common to two sequences. A subsequence is a sequence that can be derived from another sequence by deleting some or no elements without changing the order of the remaining elements.
/// <summary>
/// Finds the length of the longest common subsequence of two strings.
/// </summary>
/// <param name="text1">First string.</param>
/// <param name="text2">Second string.</param>
/// <returns>The length of the longest common subsequence.</returns>
public int LongestCommonSubsequence(string text1, string text2)
{
int m = text1.Length;
int n = text2.Length;
// Create a 2D array to store LCS lengths
// dp[i,j] represents the length of LCS of text1[0...i-1] and text2[0...j-1]
int[,] dp = new int[m + 1, n + 1];
// Fill the dp table
for (int i = 1; i <= m; i++)
{
for (int j = 1; j <= n; j++)
{
// If current characters match, extend the LCS
if (text1[i - 1] == text2[j - 1])
{
dp[i, j] = dp[i - 1, j - 1] + 1;
}
else
{
// If characters don't match, take the maximum LCS
// either excluding the current character from text1
// or excluding the current character from text2
dp[i, j] = Math.Max(dp[i - 1, j], dp[i, j - 1]);
}
}
}
// The bottom-right cell contains the length of the LCS
return dp[m, n];
}
Step-by-Step Explanation
Let's trace through finding the LCS of "ABCDE" and "ACE":
-
Initialize a dp table of size (6×4) with zeros:
| "" | A | C | E
---|---|---|---|---
"" | 0 | 0 | 0 | 0
A | 0 | | |
B | 0 | | |
C | 0 | | |
D | 0 | | |
E | 0 | | | -
Fill the table:
- For i=1, j=1: text1[0] = 'A', text2[0] = 'A', they match, so dp[1,1] = dp[0,0] + 1 = 1
- For i=1, j=2: text1[0] = 'A', text2[1] = 'C', they don't match, so dp[1,2] = max(dp[0,2], dp[1,1]) = max(0, 1) = 1
- Continue filling...
-
Final dp table:
| "" | A | C | E
---|---|---|---|---
"" | 0 | 0 | 0 | 0
A | 0 | 1 | 1 | 1
B | 0 | 1 | 1 | 1
C | 0 | 1 | 2 | 2
D | 0 | 1 | 2 | 2
E | 0 | 1 | 2 | 3 -
The LCS length is dp[5,3] = 3, which corresponds to the subsequence "ACE".