Dynamic Programming
Introduction
TODO: What is dynamic programming?
TODO: Motivate why the examples provided are interesting to look at.
Fibonacci Sequence
Introduction
The Fibonacci sequence is a sequence in which each number is the sum of the two preceding ones. The sequence commonly begins with and , so the sequence is written as:
Mathematically, the Fibonacci sequence is commonly defined by the following recurrence relation:
For our purposes, we are interested in computing a single term of the sequence.
Naive Recursive Algorithm
A very simple algorithm is to simply implement our definition as a recursive function, though the runtime performance is a nasty . Calculating took 45 seconds with an AMD Ryzen 7 5800HS CPU:
def fib(n):
if n <= 1: return n
return fib(n - 1) + fib(n - 2)
print(fib(12)) # 144
from timeit import timeit
timeit(lambda : fib(42), number=1) # approx. 45 seconds
Top-Down Memoization Algorithm
One way to speed it up is the recognition that once we’ve computed a term of the sequence, we can store it for future use. This algorithm runs in time since we calculate each term only once:
def fib(n):
memo = {0: 0, 1: 1}
def _fib(n):
if n not in memo: memo[n] = _fib(n - 1) + _fib(n - 2)
return memo[n]
return _fib(n)
print(fib(12)) # 144
from timeit import timeit
timeit(lambda : fib(42), number=1) # approx. 1.8e-05 seconds (18 microseconds)
timeit(lambda : fib(994), number=1) # approx. 0.00040 seconds (400 microseconds)
timeit(lambda : fib(995), number=1) # Python 3.10.9 'maximum recursion depth exceeded' error
Interestingly, if we calculate for or higher, Python throws a maximum recursion depth exceeded
error.
Bottom-Up Algorithm
You may have already spotted that rather than start from and recursively figuring out what we need to solve it, we could instead start from (and hardcode the base cases and ) and work our way upwards. This algorithm also runs in :
def fib(n):
if n <= 1: return n
a, b = 0, 1
for _ in range(1, n):
a, b = b, a + b
return b
print(fib(12)) # 144
from timeit import timeit
timeit(lambda : fib(42), number=1) # approx. 4.7e-06 seconds (4.7 microseconds)
timeit(lambda : fib(994), number=1) # approx. 3.9e-05 seconds (39 microseconds)
timeit(lambda : fib(1000000), number=1) # approx. 5.6 seconds
References
- Wikipedia: The mathematical notation was taken from here. Also, I stole a bit of the wording from it for the introduction.
0-1 Knapsack Problem
Problem Statement
You have a bag with a maximum weight carrying limit, and a set of items. Each item has a weight and dollar value. Find a subset of items with the largest dollar value possible that can fit within in the bag’s weight limit.
There is only one copy of each item available for you to take. Each item is either taken in its entirety, or not taken at all. You cannot “partially take” an item.
Example
Suppose your bag can only carry up to in total. The possible items we can choose from are shown in the table below:
weight | value | |
---|---|---|
Item 1 | ||
Item 2 | ||
Item 3 | ||
Item 4 | ||
Item 5 |
The optimal solution is to take all items except Item 1. The total value of these items is . The total weight of these items is , which is clearly within the bag’s limit.
If we took Item 1, we will only have remaining. We wouldn’t be able to take Item 2, which is a significantly valuable item in the list. The best we can do is to take Item 3 and Item 5, giving us a total of .
Naive Brute Force Solution
We can try enumerating every possible subset of items:
By rejecting all subsets above the weight limit () and getting the highest-value subset, we can clearly see that this will lead to an optimal solution. However, the runtime complexity is .
Bottom-Up Tabulation Algorithm
Let:
- the bag’s carrying weight limit.
- the number of items we can choose from.
- the set of all items we can choose from. (This intentionally uses one-based indexing.)
- the weight of item .
- the dollar value of item .
For this solution, we will assume integer weight values, , and for all possible .
Our main data structure will be a table with size . For example:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
1 | |||||||||||||||||
2 | |||||||||||||||||
3 | |||||||||||||||||
4 | |||||||||||||||||
5 |
corresponds to the subset of items we have currently selected from. For our example:
- means we have selected from an empty set .
- means we have selected from .
- means we have selected from .
- means we have selected from .
- means we have selected from .
- means we have selected from .
corresponds to hypothetical weight limits up until the weight limit . For example:
- corresponds to a bag of weight limit ,
- corresponds to a bag of weight limit ,
- corresponds to a bag of weight limit , etc.
Each cell of is the highest total dollar value possible for the given and .
To fill the remaining cells, we must apply an optimal substructure. This can be expressed simply as:
(1) states the trivial case where choosing from zero items will always optimally total zero value. This lets us prefill zeroes in the first row as shown in the initial table above.
TODO: Make the table reference above clear? Original Latex version has an actual\cref{}
.
(2) is a recursive equation based around a yes/no decision on whether or not to include an item in the bag. Importantly, this equation only references the previous row , meaning that we must calculate each row in order starting from . Without concerning ourselves with the details of the calculation, let’s fill out this first row :
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | |
2 | |||||||||||||||||
3 | |||||||||||||||||
4 | |||||||||||||||||
5 |
Using the results of row , we can now calculate row :
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | |
2 | 0 | 0 | 0 | 0 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | |
3 | |||||||||||||||||
4 | |||||||||||||||||
5 |
…and so on.
To understand how equation (2) works and how we’ve been calculating these rows, let’s use the row as an example since this is where things get more interesting:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | |
2 | 0 | 0 | 0 | 0 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | |
3 | 0 | 0 | 2 | 2 | 10 | 10 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | |
4 | |||||||||||||||||
5 |
Let’s take a closer look at how is calculated. is the highest dollar value of a bag with a carrying capacity of , selecting only from the set of items . If we apply equation (2), we get:
The last line (3) reads that we choose the best dollar value of two options:
- Option 1: We put item in the bag. To calculate this option, we are essentially taking the empty bag, putting item into it, and then asking for the best dollar value that we can put in the space that remains in the bag using only the previous items . The weight of item is , so the remaining space is .
- Option 2: We don’t put item in the bag. To calculate this option, we ask what the highest dollar value of a bag with a carrying capacity of the same , except we select only from items .
Continuing on from (3):
This matches what is written in the cell.
Continuing until the whole table is complete, we get:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | |
2 | 0 | 0 | 0 | 0 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | |
3 | 0 | 0 | 2 | 2 | 10 | 10 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | |
4 | 0 | 2 | 2 | 4 | 10 | 12 | 12 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | |
5 | 0 | 2 | 3 | 4 | 10 | 12 | 13 | 14 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 |
Thus, the best dollar value that we can put in a bag using items is .
To read back an optimal set of items, we must backtrack starting from . The idea is to first look directly backwards to see if the weight is the same, otherwise we subtract the value and the weight and check the appropriate cell. If done correctly, we touch the following cells:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | |||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | ||
2 | 0 | 0 | 0 | 0 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | ||
3 | 0 | 0 | 2 | 2 | 10 | 10 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | ||
4 | 0 | 2 | 2 | 4 | 10 | 12 | 12 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | ||
5 | 0 | 2 | 3 | 4 | 10 | 12 | 13 | 14 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 |
To understand how it works, let’s start from the beginning at :
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | |||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | ||
2 | 0 | 0 | 0 | 0 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | 10 | ||
3 | 0 | 0 | 2 | 2 | 10 | 10 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | 12 | ||
4 | 0 | 2 | 2 | 4 | 10 | 12 | 12 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | 14 | ||
5 | 0 | 2 | 3 | 4 | 10 | 12 | 13 | 14 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 |
Directly behind is . Here, we check for one of two possible branches:
- Branch 1: If , then we know that our optimal set does contain item . This is because the optimal set’s composition must have changed when we introduced the new item .
- Branch 2: If , then we say that our optimal set does not necessarily contain item . This is because the optimal set’s composition doesn’t need to have changed when we introduced the new item .
(Side note: Branch 2 is carefully worded to say that it’s possible but uncertain that item can be included in the optimal set, but that we know for certain that it can be left out, hence we “say” that the optimal set does not necessarily contain . It simplifies our discussion to skip over checking if we can include .)
In this example, we see that , so we take item as part of the optimal set.
Now, depending on the branch, we will visit a new cell. This is simply the corresponding cell as described by equation (2):
- If we took Branch 1: We visit . In this case, we visit .
- If we took Branch 2: We visit . In this case, we visit .
This process can repeat until we arrive at the row.
Thus, we backtrack through the table:
- took Branch 1 at ,
- took Branch 1 at ,
- took Branch 1 at ,
- took Branch 1 at , and
- took Branch 2 at .
TODO: The original Latex version has a direct reference back to the backtracking table rather than just saying “the table”. Maybe we should figure out how to make this clearer here?
Therefore, our optimal set is , which has a total value of .
This tabulation algorithm runs in time and space. Further minor improvements to the algorithm are possible and is left as an exercise for the reader.
Naive Top-Down Recursion Algorithm
TODO: Write this section!Top-Down Memoization Algorithm
TODO: Write this section!References
- Wikipedia: The example and the mathematical notation was taken from here.
- GeeksforGeeks: Online practice problem.