Dynamic Programming: Optimizing Recursion
A practical guide to Dynamic Programming (DP), covering Memoization (Top-Down) and Tabulation (Bottom-Up) for Fibonacci, Knapsack, and LCS.
In our previous post on Recursion, we solved problems like Fibonacci and Knapsack. While the code was clean, it was inefficient. Pure recursion often solves the same subproblems over and over again, leading to exponential time complexity ($O(2^n)$).
Dynamic Programming (DP) is simply recursion with a “cache.” By storing the results of subproblems, we ensure that each unique input is computed only once.
1. Fibonacci: Top-Down vs Bottom-Up
There are two main ways to implement DP:
- Top-Down (Memoization): Keep the recursive logic but add a lookup table.
- Bottom-Up (Tabulation): Start from the smallest case and iteratively build up to the answer.
[Image of comparison between top-down memoization and bottom-up tabulation for fibonacci]
Top-Down (Memoization)
We pass a table array to store results. Before computing fib(n), we check if table[n] already has a value.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Top-Down: Recursion + Caching
fun fibDPRecur(n: Int, table: Array<Int?>): Int {
// 1. Table Lookup (Memoization Check)
if (table[n] != null) return table[n]!!
// 2. Base Condition
if (n == 0 || n == 1) {
table[n] = n
} else {
// 3. Compute and Store
table[n] = fibDPRecur(n - 1, table) + fibDPRecur(n - 2, table)
}
return table[n]!!
}
Bottom-Up (Tabulation)
Here, we avoid recursion entirely. We fill the table iteratively from 0 to n.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Bottom-Up: Iterative Filling
fun fibDp(n: Int): Int {
if (n <= 1) return n
val table = Array<Int?>(n + 1) { null }
table[0] = 0
table[1] = 1
for (i in 2..n) {
table[i] = table[i - 1]!! + table[i - 2]!!
}
return table[n]!!
}
2. 0/1 Knapsack with Memoization
In the Knapsack problem, our state depends on two variables: position (items left) and currWeight (capacity left). Therefore, our memoization table must be a 2D Array.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
fun knapSackDpRecur(
weights: Array<Int>,
profits: Array<Int>,
currWeight: Int,
position: Int,
memo: Array<Array<Int>>
): Int {
// Base Case
if (position == 0 || currWeight == 0) return 0
// Check Cache
if (memo[position][currWeight] != -1) {
return memo[position][currWeight]
}
var pickedProfit = 0
val currPosition = position - 1
// Logic: Pick vs Don't Pick
if (weights[currPosition] <= currWeight) {
pickedProfit = profits[currPosition] + knapSackDpRecur(
weights,
profits,
currWeight - weights[currPosition],
currPosition,
memo
)
}
val notPickedProfit = knapSackDpRecur(weights, profits, currWeight, currPosition, memo)
// Store Result
val result = maxOf(pickedProfit, notPickedProfit)
memo[position][currWeight] = result
return result
}
3. Longest Common Subsequence (LCS)
Similarly, LCS depends on two indices: position1 (string 1 index) and position2 (string 2 index). We cache the result of every (p1, p2) pair.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
fun longestCommonSubsequenceDp(
sequence1: String,
sequence2: String,
position1: Int,
position2: Int,
memo: Array<Array<Int>>
): Int {
if (position1 == 0 || position2 == 0) return 0
if (memo[position1][position2] != -1) return memo[position1][position2]
if (sequence1[position1] == sequence2[position2]) {
// Match: 1 + result of remaining
val res = 1 + longestCommonSubsequenceDp(
sequence1,
sequence2,
position1 - 1,
position2 - 1,
memo
)
memo[position1 - 1][position2 - 1] = res
return res
} else {
// No Match: Try skipping char from either string
val res = maxOf(
longestCommonSubsequenceDp(sequence1, sequence2, position1 - 1, position2, memo),
longestCommonSubsequenceDp(sequence1, sequence2, position1, position2 - 1, memo)
)
memo[position1][position2] = res
return res
}
}
Complexity Comparison
| Algorithm | Recursive (Naive) | Dynamic Programming |
|---|---|---|
| Fibonacci | $O(2^n)$ | $O(n)$ |
| Knapsack | O(N) | $O(M \times N)$ |
| LCS | O(N) | $O(M \times N)$ |
Where $N, M$ are lengths and $W$ is capacity.
Summary
Dynamic Programming is not magic; it’s just Recursion + Memory.
- Start with a working recursive solution.
- Identify the changing parameters (state).
- Add a memo table to store results for those states.
- Return the stored result if it exists.
Source Code
🚀 Code: All the code for this tutorial is available in my DSA-Kotlin Repository.