Algorithm Design AI
Algorithm Design AI
Skerritt.blog
Contents
Problem Solving Paradigms .................................................................................................................... 1
Skerritt.blog ............................................................................................................................................ 1
Greedy Algorithms .................................................................................................................................. 4
Why Are Greedy Algorithms Called Greedy? ...................................................................................... 4
What Are Greedy Algorithms Used For?............................................................................................. 4
How Do I Create a Greedy Algorithm? ................................................................................................ 4
Counting Change Using Greedy .............................................................................................................. 5
Is Greedy Optimal? Does Greedy Always Work? ................................................................................ 7
Dijkstra's Algorithm................................................................................................................................. 8
Fractional Knapsack Problem Using Greedy Algorithm .................................................................... 13
Greedy vs Divide & Conquer vs Dynamic Programming ................................................................... 15
Conclusion ......................................................................................................................................... 16
Divide & Conquer .................................................................................................................................. 17
What is divide and conquer? 🌎 ...................................................................................................... 17
Merge Sort 🤖 .............................................................................................................................. 20
Towers of Hanoi 🗼 ...................................................................................................................... 21
Fibonacci Numbers 🐰.................................................................................................................. 23
Conclusion 📕 ................................................................................................................................... 25
Dynamic Programming.......................................................................................................................... 27
Why Is Dynamic Programming Called Dynamic Programming? ....................................................... 27
What are Sub-Problems? .................................................................................................................. 27
What is Memoisation in Dynamic Programming? ............................................................................ 28
How to Identify Dynamic Programming Problems ........................................................................... 30
How to Solve Problems using Dynamic Programming ...................................................................... 33
Step 1. Write the Problem out .......................................................................................................... 33
2. Mathematical Recurrences ........................................................................................................... 34
1: Define the Base Case................................................................................................................. 34
2: What Decision Do I Make at Step n? ........................................................................................ 34
3. Determine the Dimensions of the Memoisation Array and the Direction in Which It Should Be
Filled .................................................................................................................................................. 36
4. Coding Our Solution ...................................................................................................................... 36
Knapsack Problem................................................................................................................................. 42
Maths Behind {0, 1} Knapsack Problem ............................................................................................ 42
Tabulation of Knapsack Problem .................................................................................................. 43
Finding the Optimal Set for {0, 1} Knapsack Problem Using Dynamic Programming ................... 48
Coding {0, 1} Knapsack Problem in Dynamic Programming With Python .................................... 49
Time Complexity of a Dynamic Programming Problem .................................................................... 50
Dynamic Programming vs Divide & Conquer vs Greedy ............................................................... 51
Tabulation (Bottom-Up) vs Memoisation (Top-Down) ..................................................................... 52
Memoisation (Top-Down) ............................................................................................................. 52
Tabulation (Bottom-Up) ................................................................................................................ 53
Tabulation & Memosation - Advantages and Disadvantages ....................................................... 53
Conclusion ......................................................................................................................................... 54
Greedy Algorithms
Greedy algorithms aim to make the optimal choice at that given moment. Each step it
chooses the optimal choice, without knowing the future. It attempts to find the globally
optimal way to solve the entire problem using this method.
Greedy algorithms are greedy. They do not look into the future to decide the global optimal
solution. They are only concerned with the optimal solution locally. This means that the
overall optimal solution may be different from the solution the algorithm chooses.
They never look backwards at what they've done to see if they could optimise globally. This
is the main difference between Greedy and Dynamic Programming.
And that's it. There isn't much to it. Greedy algorithms are generally easier to code than
Divide & Conquer or Dynamic Programming.
Counting Change Using Greedy
Imagine you're a vending machine. Someone gives you £1 and buys a drink for £0.60p.
There's no 30p coin in pound sterling, how do you calculate how much change to return?
For reference, this is the denomination of each coin in the UK:
1p, 2p, 5p, 10p, 20p, 50p, £1
The greedy algorithm starts from the highest denomination and works backwards. Our
algorithm starts at £1. £1 is more than 30p, so it can't use it. It does this for 50p. It reaches
20p. 20p < 30p, so it takes 1 20p.
The algorithm needs to return change of 10p. It tries 20p again, but 20p > 10p. It then goes
to 10p. It chooses 1 10p, and now our return is 0 we stop the algorithm.
We return 1x20p and 1x10p.
This algorithm works quite well in real life. Let's use another example, this time we have the
denomination next to how many of that coin is in the machine, (denomination, how
many).
(1p, 10), (2p, 3), (5p, 1), (10p, 0), (20p, 1p), (50p, 19p), (100p,
16)
The algorithm is asked to return change of 30p again. 100p (£1) is no. Same for 50. 20p, we
can do that. We pick 1x 20p. We now need to return 10p. 20p has run out, so we move
down 1.
10p has run out, so we move down 1.
We have 5p, so we choose 1x5p. We now need to return 5p. 5p has run out, so we move
down one.
We choose 1 2p coin. We now need to return 3p. We choose another 2p coin. We now need
to return 1p. We move down one.
We choose 1x 1p coin.
In total, our algorithm selected these coins to return as change:
Let's code something. First, we need to define the problem. We'll start with the
denominations.
Now onto the core function. Given denominations and an amount to give change, we want
to return a list of how many times that coin was returned.
If our denominations list is as above, then [6, 3, 0, 0, 0, 0, 0] represents taking
6 1p coins and 3 2p coins, but 0 of all other coins.
We create a list, the size of denominations long and fill it with 0's.
We want to loop backwards, from largest to smallest. Reversed(x) reverses x and lets us
loop backwards. Enumerate means "for loop through this list, but keep the position in
another variable". In our example when we start the loop. coin = 100 and pos = 6.
Our next step is repeatedly choosing a coin for as long as we can use that coin. If we need to
give change = 40 we want our algorithm to choose 20, then 20 again until it can no longer
use 20. We do this using a for loop.
While the coin can still fit into change, add that coin to our return list, toGiveBack and
remove it from change.
The runtime of this algorithm is dominated by the 2 loops, thus it is 𝑂(𝑛2 )
.
Dijkstra's Algorithm
Dijkstra's algorithm finds the shortest path from a node to every other node in the graph. In
our example, we'll be using a weighted directed graph. Each edge has a direction, and each
edge has a weight.
Dijkstra's algorithm has many uses. It can be very useful within road networks where you
need to find the fastest route to a place. The algorithm is also used for:
• IP Routing
• A* Algorithm
• Telephone networks
The algorithm follows these rules:
1. Every time we want to visit a new node, we will choose the node with the smallest
known distance.
2. Once we've moved to the node, we check each of its neighbouring nodes. We calculate
the distance from the neighbouring nodes to the root nodes by summing the cost of
the edges that lead to that new node.
3. If the distance to a node is less than a known distance, we'll update the shortest
distance.
Our first step is to pick the starting node. Let's choose A. All the distances start at infinity, as
we don't know their distance until we reach a node that does know the distance.
We mark off A on our unvisited nodes list. The distance from A to A is 0. The distance from A
to B is 4. The distance from A to C is 2. We updated our distance listing on the right-hand
side.
We then pick the smallest edge where the vertex hasn't been chosen. The smallest edge is A
-> C, and we haven't chosen C yet. We visit C.
Notice how we're picking the smallest distance from our current node to a node we haven't
visited yet. We're being greedy. In this case, the greedy method is the global optimal
solution.
Since A -> C -> B is smaller than A -> B, we update B with this information. We then add in
the distances from the other nodes we can now reach.
Our next smallest vertex with a node we haven't visited yet is B, with 3. We visit B.
We do the same for B. Then we pick the smallest vertex we haven't visited yet, D.
We don't update any of the distances this time. Our last node is then E.
There are no updates again. To find the shortest path from A to the other nodes, we walk
back through our graph.
We pick A first, then C, then B. If you need to create the shortest path from A to every other
node as a graph, you can run this algorithm using a table on the right hand side.
Using this table it is easy to draw out the shortest distance from A to every other node in
the graph:
𝑣𝑎𝑙𝑢𝑒
The first step to solving the fractional knapsack problem is to calculate 𝑤𝑒𝑖𝑔ℎ𝑡
And now we greedily select the largest ones. To do this, we can sort them according to
𝑣𝑎𝑙𝑢𝑒
𝑤𝑒𝑖𝑔ℎ𝑡
in descending order. Luckily for us, they are already sorted. The largest one is 3.2.
knapsack value = 16
knapsack total weight = 5 (out of 7)
Then we select Francium (I know it's not a gem, but Judy is a bit strange 😉)
knapsack value = 19
knapsack weight = 6
Now, we add Sapphire. But if we add Sapphire, our total weight will come to 8.
In the fractional knapsack problem, we can cut items up to take fractions of them. We have
a weight of 1 left in the bag. Our sapphire is weight 2. We calculate the ratio of:
𝑤𝑒𝑖𝑔ℎ𝑡 𝑜𝑓 𝑘𝑛𝑎𝑝𝑠𝑎𝑐𝑘 𝑙𝑒𝑓𝑡
𝑤𝑒𝑖𝑔ℎ𝑡 𝑜𝑓 𝑖𝑡𝑒𝑚
And then multiply this ratio by the value of the item to get how much value of that item we
can take.
1
∗6=3
2
knapsack value = 21
knapsack weight = 7
The greedy algorithm can optimally solve the fractional knapsack problem, but it cannot
optimally solve the {0, 1} knapsack problem. In this problem instead of taking a fraction of
an item, you either take it {1} or you don't {0}. To solve this, you need to use Dynamic
Programming.
𝑣𝑎𝑙𝑢𝑒
The runtime for this algorithm is O(n log n). Calculating 𝑤𝑒𝑖𝑔ℎ𝑡 is O(1). Our main step is
𝑣𝑎𝑙𝑢𝑒
sorting from largest 𝑤𝑒𝑖𝑔ℎ𝑡, which takes O(n log n) time.
And we want to add them all together. We first divide the problem into 8 equal sub-
problems. We do this by breaking the addition up into individual numbers.
We then begin to add 2 numbers at a time.
Why do we break it down to individual numbers at stage 1? Why don't we just start from
stage 2? Because while this list of numbers is even if the list was odd you would need to
break it down to individual numbers to better handle it.
A divide and conquer algorithm tries to break a problem down into as many little chunks as
possible since it is easier to solve with little chunks. It typically does this with recursion.
Formally the technique is, as defined in the famous Introduction to Algorithms by Cormen,
Leiserson, Rivest, and Stein, is:
1. Divide
If the problem is small, then solve it directly. Otherwise, divide the problem into smaller
subsets of the same problem.
2. Conquer
Conquer the smaller problems by solving them recursively. If the subproblems are small
enough, recursion is not needed and you can solve them directly.
Recursion is when a function calls itself. It's a hard concept to understand if you've never
heard of it before. This page provides a good explanation. In short, a recursive function is
one like this:
In this image, we break down the 8 numbers into separate digits. Just like we did earlier.
Once we've done this, we can begin the sorting process.
It compares 51 and 13. Since 13 is smaller, it puts it in the left-hand side. It does this for (10,
64), (34, 5), (32, 21).
It then merges (13, 51) with (10, 64). It knows that 13 is the smallest in the first list, and 10 is
the smallest in the right list. 10 is smaller than 13, therefore we don't need to compare 13 to
64. We're comparing & merging two sorted lists.
In recursion we use the term base case to refer to the absolute smallest value we can deal
with. With Merge Sort, the base case is 1. That means we split the list up until we get sub-
lists of length 1. That's also why we go down all the way to 1 and not 2. If the base case was
2, we would stop at the 2 numbers.
If the length of the list (n) is larger then 1, then we divide the list and each sub-list by 2 until
we get sub-lists of size 1. If n = 1, the list is already sorted so we do nothing.
Merge Sort is an example of a divide and conquer algorithm. Let's look at one more
algorithm to really understand how divide and conquer works.
Towers of Hanoi 🗼
The Towers of Hanoi is a mathematical problem which consists of 3 pegs and in this
instance, 3 discs. This problem is mostly used to teach recursion, but it does have some real-
world uses.
Each disc is a different size. We want to move all discs to peg C so that the largest is on the
bottom, second largest on top of the largest, third largest (smallest) on top of all of them.
There are some rules to this game:
1. We can only move 1 disc at a time.
2. A disc cannot be placed on top of other discs that are smaller than it.
We want to use the smallest number of moves possible. If we have 1 disc, we only need to
move it once. If we have 2 discs, we need to move it 3 times.
The number of moves is a power of 2 minus 1. If we have 4 discs, we calculate the minimum
number of moves as 24 = 16 − 1 = 15.
To solve the above example we want to store the smallest disc in a buffer peg (1 move). See
below for a gif on solving Tower of Hanoi with 3 pegs and 3 discs.
We start with a base case, disk == 0. source is the peg you're starting at. dest is the
final destination peg. spare is the spare peg.
FUNCTION MoveTower(disk, source, dest, spare):
IF disk == 0, THEN:
move disk from source to dest
ELSE:
MoveTower(disk - 1, source, spare, dest) // Step 1
move disk from source to dest // Step 2
MoveTower(disk - 1, spare, dest, source) // Step 3
END IF
Notice that with step 1 we switch dest and source. We do not do this for step 3.
With recursion, we can be sure of 2 things:
1. It always has a base case (if it doesn't, how does the algorithm know to end?)
2. The function calls itself.
The algorithm gets a little confusing with steps 1 and 3. They both call the same function.
This is where multi-threading comes in. You can run steps 1 and 3 on different threads - at
the same time.
Since 2 is more than 1, we move it down one more level again. So far you've seen what the
divide and conquer technique is. You should understand how it works and what code looks
like. Next, let's learn how to formally define an algorithm to a problem using divide and
conquer. This part is the most important in my opinion. Once you know this, it'll be
exponentially easier to create divide and conquer algorithms.
Fibonacci Numbers 🐰
The Fibonacci numbers can be found in nature. The way rabbits produceis in the style of the
Fibonacci numbers. You have 2 rabbits that make 3, 3 rabbits make 5, 5 rabbits make 9 and
so on.
The numbers start at 1 and the next number is the current number + the previous number.
Here it’s 1 + 0 = 1. Then 1 + 1 = 2. 2 + 1 = 3 and so on.
We can describe this relation using a recursion. A recurrence is an equation which defines a
function in terms of its smaller inputs. Recurrence and recursion sound similar and are
similar.
With Fibonacci numbers if n = 0 or 1, it results in 1. Else, recursively add f(n-1) + f(n -2) until
you reach the base case. Let's start off by creating a non-recursive Fibonacci number
calculator.
We know that if n = 0 or 1, return 1.
The Fibonacci numbers are the last two numbers added together.
Now we've seen this, let's turn it into recursion using a recurrence.
𝐹(𝑛) = {𝑛, If n = 0 or 1
When creating a recurrence, we always start with the base case. The base case here is if n
== 0 or 1, return n.
If we don't return n, but instead return 1 this leads to a bug. For example, F(0) would result
in 1. When really, it should result in 0.
Next, we have the formula. If n isn't 0 or 1, what do we do? We calculate F(n - 1) + F(n - 2).
In the end, we want to merge all the numbers together to get our final result. We do this
using addition.
𝑛, If n = 0 or 1
𝐹(𝑛) = {
𝐹(𝑛 − 1) + 𝐹(𝑛 − 2), if n > 1
This is the formal definition of the Fibonacci numbers. Normally, recurrences are used to
talk about the running time of a divide and conquer algorithm. My algorithms professor and
I think it's actually a good tool to create a divide and conquer algorithm.
With knowledge of divide and conquer, the above code is cleaner and easier to read.
We often calculate the result of a recurrence using an execution tree. Computer overlords
🤖 don't need to do this, but it's useful for humans to see how your divide and conquer
algorithm works. For F(4) this looks like:
n is 4, and n is larger than 0 or 1. So we do f(n-1) + f(n-2). We ignore the addition for now.
This results in 2 new nodes, 3 and 2. 3 is larger than 0 or 1 so we do the same. Same for 2.
We do this until we get a bunch of nodes which are either 0 or 1. We then add all the nodes
together. 1 + 1 + 0 + 0 + 1 = 3, which is the right answer.
Conclusion 📕
Once you've identified how to break a problem down into many smaller pieces, you can use
concurrent programming to execute these pieces at the same time (on different threads)
thereby speeding up the whole algorithm.
Divide and conquer algorithms are one of the fastest and perhaps easiest ways to increase
the speed of an algorithm and are incredibly useful in everyday programming. Here are the
most important topics we covered in this article:
• What is divide and conquer?
• Recursion
• MergeSort
• Towers of Hanoi
• Coding a divide and conquer algorithm
• Recurrences
• Fibonacci numbers
The next step is to explore multithreading. Choose your programming language of choice
and Google, as an example, "Python multithreading". Figure out how it works and see if you
can attack any problems in your own code from this new angle.
Dynamic Programming
Dynamic programming is breaking down a problem into smaller sub-problems, solving each
sub-problem and storing the solutions to each of these sub-problems in an array (or similar
data structure) so each sub-problem is only calculated once.
It is both a mathematical optimisation method and a computer programming method.
Optimisation problems seek the maximum or minimum solution. The general rule is that if
you encounter a problem where the initial algorithm is solved in O(2n) time, it is better
solved using Dynamic Programming.
Sub-problems are smaller versions of the original problem. Let's see an example. With the
equation below:
1+2+3+4
We can break this down to:
1+2
3+4
Once we solve these two smaller problems, we can add the solutions to these sub-problems
to find the solution to the overall problem.
Notice how these sub-problems breaks down the original problem into components that
build up the solution. This is a small example but it illustrates the beauty of Dynamic
Programming well. If we expand the problem to adding 100's of numbers it becomes clearer
why we need Dynamic Programming. Take this example:
6+5+3+3+2+4+6+5
We have 6 + 5 twice. The first time we see it, we work out 6 + 5. When we see it the
second time we think to ourselves:
"Ah, 6 + 5. I've seen this before. It's 11!"
Let's see why storing answers to solutions make sense. We're going to look at a famous
problem, Fibonacci sequence. This problem is normally solved in Divide and Conquer.
There are 3 main parts to divide and conquer:
1. Divide the problem into smaller sub-problems of the same type.
2. Conquer - solve the sub-problems recursively.
3. Combine - Combine all the sub-problems to create a solution to the original problem.
Dynamic programming has one extra step added to step 2. This is memoisation.
The Fibonacci sequence is a sequence of numbers. It's the last number + the current
number. We start at 1.
1+0= 1
1+1= 2
2+1= 3
3+2= 5
5+3= 8
In Python, this is:
If you're not familiar with recursion I have a blog post written for you that you should read
first.
Let's calculate F(4). In an execution tree, this looks like:
Starts at 4, it splits into two. Fibonacci 3 and 2. Each of these 2 then split into 2 more for a
total of 4. 1, 2, 0, and 1. We stop splitting when we reach 0 or 1. 2 is split again, into 1 and 0.
The levels go from top to bottom. They look like this: 4 new level 3, 2 new level 1, 2, 0, 1
new level 1, 0
We calculate F(2) twice. On bigger inputs (such as F(10)) the repetition builds up. The
purpose of dynamic programming is to not calculate the same thing twice.
Instead of calculating F(2) twice, we store the solution somewhere and only calculate it
once.
We'll store the solution in an array. F[2] = 1. Below is some Python code to calculate the
Fibonacci sequence using Dynamic Programming.
In theory, Dynamic Programming can solve every problem. The question is then:
"When should I solve this problem with dynamic programming?"
We should use dynamic programming for problems that are between tractable and
intractable problems.
Tractable problems are those that can be solved in polynomial time. That's a fancy way of
saying we can solve it in a fast manner. Binary search and sorting are all fast. Intractable
problems are those that run in exponential time. They're slow. Intractable problems are
those that can only be solved by bruteforcing through every single combination (NP hard).
When we see terms like:
"shortest/longest, minimized/maximized, least/most, fewest/greatest, "biggest/smallest"
When we see these kinds of terms, the problem may ask for a specific number ( "find the
minimum number of edit operations") or it may ask for a result ( "find the longest common
subsequence"). The latter type of problem is harder to recognize as a dynamic programming
problem. If something sounds like optimisation, Dynamic Programming can solve it.
Imagine we've found a problem that's an optimisation problem, but we're not sure if it can
be solved with Dynamic Programming. First, identify what we're optimising for. Once we
realize what we're optimising for, we have to decide how easy it is to perform that
optimisation. Sometimes, the greedy approach is enough for an optimal solution.
Dynamic programming takes the brute force approach. It Identifies repeated work, and
eliminates repetition.
Before we even start to plan the problem as a dynamic programming problem, think about
what the brute force solution might look like. Are sub steps repeated in the brute-force
solution? If so, we try to imagine the problem as a dynamic programming problem.
Mastering dynamic programming is all about understanding the problem. List all the inputs
that can affect the answers. Once we've identified all the inputs and outputs, try to identify
whether the problem can be broken into subproblems. If we can identify subproblems, we
can probably use Dynamic Programming.
Then, figure out what the recurrence is and solve it. When we're trying to figure out the
recurrence, remember that whatever recurrence we write has to help us find the answer.
Sometimes the answer will be the result of the recurrence, and sometimes we will have to
get the result by looking at a few results from the recurrence
Dynamic Programming can solve many problems, but that does not mean there isn't a more
efficient solution out there. Solving a problem with Dynamic Programming feels like magic,
but remember that dynamic programming is merely a clever brute force. Sometimes it pays
off well, and sometimes it helps only a little.
How to Solve Problems using Dynamic Programming
Recurrences are also used to define problems. If it's difficult to turn your subproblems into
maths, then it may be the wrong subproblem.
There are 2 steps to creating a mathematical recurrence:
1: Define the Base Case
It doesn't have to be 0. The base case is the smallest possible denomination of a problem.
We saw this with the Fibonacci sequence. The base was:
• If n == 0 or n == 1, return 1
It's important to know where the base case lies, so we can create the recurrence. In our
problem, we have one decision to make:
• Put that pile of clothes on to be washed
or
• Don’t wash that pile of clothes today
If n is 0, that is, if we have 0 PoC then we do nothing. Our base case is:
if n == 0, return 0
2: What Decision Do I Make at Step n?
Now we know what the base case is, if we're at step n what do we do? For each pile of
clothes that is compatible with the schedule so far. Compatible means that the start time is
after the finish time of the pile of clothes currently being washed. The algorithm has 2
options:
1. Wash that pile of clothes
2. Don't wash that pile of clothes
We know what happens at the base case, and what happens else. We now need to find out
what information the algorithm needs to go backwards (or forwards).
"If my algorithm is at step i, what information would it need to decide what to do in step i+1?"
To decide between the two options, the algorithm needs to know the next compatible PoC
(pile of clothes). The next compatible PoC for a given pile, p, is the PoC, n, such that 𝑠𝑛 (the
start time for PoC n) happens after 𝑓𝑝 (the finish time for PoC p). The difference between 𝑠𝑛
and 𝑓𝑝 should be minimised.
In English, imagine we have one washing machine. We put in a pile of clothes at 13:00. Our
next pile of clothes starts at 13:01. We can't open the washing machine and put in the one
that starts at 13:00. Our next compatible pile of clothes is the one that starts after the finish
time of the one currently being washed.
"If my algorithm is at step i, what information did it need to decide what to do in step i-1?"
The algorithm needs to know about future decisions. The ones made for PoC i through n to
decide whether to run or not run PoC i-1.
Now that we’ve answered these questions, we’ve started to form a recurring mathematical
decision in our mind. If not, that’s also okay, it becomes easier to write recurrences as we
get exposed to more problems.
Here’s our recurrence:
0, If i = 0
𝑂𝑃𝑇(𝑖) = {
𝑚𝑎𝑥𝑣𝑖 + 𝑂𝑃𝑇(𝑛𝑒𝑥𝑡[𝑖]), 𝑂𝑃𝑇(𝑖 + 1), if n > 1
Let's explore in detail what makes this mathematical recurrence. OPT(i) represents the
maximum value schedule for PoC i through to n such that PoC is sorted by start times. OPT(i)
is our subproblem from earlier.
We start with the base case. All recurrences need somewhere to stop. If we call OPT(0) we'll
be returned with 0.
To determine the value of OPT(i), there are two options. We want to take the maximum of
these options to meet our goal. Our goal is the maximum value schedule for all piles of
clothes. Once we choose the option that gives the maximum result at step i, we memoize its
value as OPT(i).
Mathematically, the two options - run or not run PoC i, are represented as:
𝑣𝑖 + 𝑂𝑃𝑇(𝑛𝑒𝑥𝑡[𝑛])
This represents the decision to run PoC i. It adds the value gained from PoC i to
OPT(next[n]), where next[n] represents the next compatible pile of clothing following PoC i.
When we add these two values together, we get the maximum value schedule from i
through to n such that they are sorted by start time if i runs.
Sorted by start time here because next[n] is the one immediately after v_i, so by default,
they are sorted by start time.
𝑂𝑃𝑇(𝑖 + 1)
If we decide not to run i, our value is then OPT(i + 1). The value is not gained. OPT(i + 1)
gives the maximum value schedule for i+1 through to n, such that they are sorted by start
times.
3. Determine the Dimensions of the Memoisation Array and the Direction in Which It
Should Be Filled
The solution to our Dynamic Programming problem is OPT(1). We can write out the solution
as the maximum value schedule for PoC 1 through n such that PoC is sorted by start time.
This goes hand in hand with "maximum value schedule for PoC i through to n".
From step 2:
𝑂𝑃𝑇(1) = 𝑚𝑎𝑥𝑣1 + 𝑂𝑃𝑇(𝑛𝑒𝑥𝑡[1]), 𝑂𝑃𝑇(2)
Going back to our Fibonacci numbers earlier, our Dynamic Programming solution relied on
the fact that the Fibonacci numbers for 0 through to n - 1 were already memoised. That is,
to find F(5) we already memoised F(0), F(1), F(2), F(3), F(4). We want to do the same thing
here.
The problem we have is figuring out how to fill out a memoisation table. In the scheduling
problem, we know that OPT(1) relies on the solutions to OPT(2) and OPT(next[1]). PoC 2 and
next[1] have start times after PoC 1 due to sorting. We need to fill our memoisation table
from OPT(n) to OPT(1).
We can see our array is one dimensional, from 1 to n. But, if we couldn't see that we can
work it out another way. The dimensions of the array are equal to the number and size of
the variables on which OPT(x) relies. In our algorithm, we have OPT(i) - one variable, i. This
means our array will be 1-dimensional and its size will be n, as there are n piles of clothes.
If we know that n = 5, then our memoisation array might look like this:
memo = [0, OPT(1), OPT(2), OPT(3), OPT(4), OPT(5)]
0 is also the base case. memo[0] = 0, per our recurrence from earlier.
Start time, finish time, and the total profit (benefit) of running that job.
The next step we want to program is the schedule.
Earlier, we learnt that the table is 1 dimensional. We sort the jobs by start time, create this
empty table and set table[0] to be the profit of job[0]. Since we've sorted by start times, the
first compatible job is always job[0].
Our next step is to fill in the entries using the recurrence we learnt earlier. To find the next
compatible job, we're using Binary Search. In the full code posted later, it'll include this. For
now, let's worry about understanding the algorithm.
If the next compatible job returns -1, that means that all jobs before the index, i, conflict
with it (so cannot be used). Inclprof means we're including that item in the maximum value
set. We then store it in table[i], so we can use this calculation again later.
Our final step is then to return the profit of all items up to n-1.
Imagine you are a criminal. Dastardly smart. You break into Bill Gates’s mansion. Wow,
okay!?!? How many rooms is this? His washing machine room is larger than my entire
house??? Ok, time to stop getting distracted. You brought a small bag with you. A knapsack -
if you will.
You can only fit so much into it. Let’s give this an arbitrary number. The bag will support
weight 15, but no more. What we want to do is maximise how much money we'll make, 𝑏.
The greedy approach is to pick the item with the highest value which can fit into the bag.
Let's try that. We're going to steal Bill Gates's TV. £4000? Nice. But his TV weighs 15. So...
We leave with £4000.
TV = (£4000, 15)
# (value, weight)
Bill Gates's has a lot of watches. Let's say he has 2 watches. Each watch weighs 5 and each
one is worth £2250. When we steal both, we get £4500 with a weight of 10.
watch1 = (£2250, 5)
watch2 = (£2250, 5)
watch1 + watch2
>>> (£4500, 10)
In the greedy approach, we wouldn't choose these watches first. But to us as humans, it
makes sense to go for smaller items which have higher values. The Greedy approach cannot
optimally solve the {0,1} Knapsack problem. The {0, 1} means we either take the item whole
item {1} or we don't {0}. However, Dynamic programming can optimally solve the {0, 1}
knapsack problem.
The simple solution to this problem is to consider all the subsets of all items. For every
single combination of Bill Gates's stuff, we calculate the total weight and value of this
combination.
Only those with weight less than 𝑊𝑚𝑎𝑥 are considered. We then pick the combination which
has the highest value. This is a disaster! How long would this take? Bill Gates's would come
back home far before you're even 1/3rd of the way there! In Big O, this algorithm takes
𝑂(𝑛2 ) time.
You can see we already have a rough idea of the solution and what the problem is, without
having to write it down in maths!
Let B[k, w] be the maximum total benefit obtained using a subset of 𝑆𝑘 Having total weight
at most w.
Then we define B[0, w] = 0 for each 𝑤 ≤ 𝑊𝑚𝑎𝑥 .
Our desired solution is then B[n, 𝑊𝑚𝑎𝑥 ].
𝐵[𝑘 − 1, 𝑤], If w < 𝑤𝑘
(𝑖) = {
𝑚𝑎𝑥𝐵[𝑘 − 1, 𝑤], 𝑏𝑘 + 𝐵[𝑘 − 1, 𝑤 − 𝑤𝑘 ], otherwise
Okay, pull out some pen and paper. No, really. Things are about to get confusing real fast.
This memoisation table is 2-dimensional. We have these items:
(1, 1), (3, 4), (4, 5), (5, 7)
The weight is 7. We start counting at 0. We put each tuple on the left-hand side. Ok. Now to
fill out the table!
0 1 2 3 4 5 6 7
(1, 1) 0
(4, 3) 0
(5, 4) 0
(7, 5) 0
The columns are weight. At weight 0, we have a total weight of 0. At weight 1, we have a
total weight of 1. Obvious, I know. But this is an important distinction to make which will be
useful later on.
When our weight is 0, we can't carry anything no matter what. The total weight of
everything at 0 is 0.
0 1 2 3 4 5 6 7
(1, 1) 0 1
(4, 3) 0
(5, 4) 0
(7, 5) 0
If our total weight is 1, the best item we can take is (1, 1). As we go down through this array,
we can take more items. At the row for (4, 3) we can either take (1, 1) or (4, 3). But for now,
we can only take (1, 1). Our maximum benefit for this row then is 1.
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0
(5, 4) 0
(7, 5) 0
If our total weight is 2, the best we can do is 1. We only have 1 of each item. We cannot
duplicate items. So no matter where we are in row 1, the absolute best we can do is (1, 1).
Let's start using (4, 3) now. If the total weight is 1, but the weight of (4, 3) is 3 we cannot
take the item yet until we have a weight of at least 3.
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1
(5, 4) 0
(7, 5) 0
Now we have a weight of 3. Let's compare some things. We want to take the max of:
𝑀𝐴𝑋(4 + 𝑇[0][0],1)
If we're at 2, 3 we can either take the value from the last row or use the item on that row.
We go up one row and count back 3 (since the weight of this item is 3).
Actually, the formula is whatever weight is remaining when we minus the weight of the item
on that row. The weight of (4, 3) is 3 and we're at weight 3. 3 - 3 = 0. Therefore, we're at
T[0][0]. T[previous row's number][current total weight - item weight].
𝑀𝐴𝑋(4 + 𝑇[0][0],1)
The 1 is because of the previous item. The max here is 4.
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4
(5, 4) 0
(7, 5) 0
𝑚𝑎𝑥(4 + 𝑡[0][1],1)
Total weight is 4, item weight is 3. 4 - 3 = 1. Previous row is 0. t[0][1].
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5
(5, 4) 0
(7, 5) 0
I won't bore you with the rest of this row, as nothing exciting happens. We have 2 items.
And we've used both of them to make 5. Since there are no new items, the maximum value
is 5.
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0
(7, 5) 0
Onto our next row:
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0 1 1 4
(7, 5) 0
Here's a little secret. Our tuples are ordered by weight! That means that we can fill in the
previous rows of data up to the next weight point. We know that 4 is already the maximum,
so we can fill in the rest.. This is where memoisation comes into play! We already have the
data, why bother re-calculating it?
We go up one row and head 4 steps back. That gives us:
𝑚𝑎𝑥(4 + 𝑇[2][0],5)
.
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0 1 1 4 5
(7, 5) 0
If we had total weight 7 and we had the 3 items (1, 1), (4, 3), (5, 4) the best we can do is 9.
Since our new item starts at weight 5, we can copy from the previous row until we get to
weight 5.
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0 1 1 4 5 6 6 9
(7, 5) 0 1 1 4 5
Now, what items do we actually pick for the optimal set? We start with this item:
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0 1 1 4 5 6 6 9
(7, 5) 0 1 1 4 5 7 8 9
We want to know where the 9 comes from. It's coming from the top because the number
directly above 9 on the 4th row is 9. Since it's coming from the top, the item (7, 5) is not
used in the optimal set.
Where does this 9 come from?
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0 1 1 4 5 6 6 9
(7, 5) 0 1 1 4 5 7 8 9
This 9 is not coming from the row above it. Item (5, 4) must be in the optimal set.
We now go up one row, and go back 4 steps. 4 steps because the item, (5, 4), has weight 4.
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0 1 1 4 5 6 6 9
(7, 5) 0 1 1 4 5 7 8 9
4 does not come from the row above. The item (4, 3) must be in the optimal set.
The weight of item (4, 3) is 3. We go up and we go back 3 steps and reach:
0 1 2 3 4 5 6 7
(1, 1) 0 1 1 1 1 1 1 1
(4, 3) 0 1 1 4 5 5 5 5
(5, 4) 0 1 1 4 5 6 6 9
(7, 5) 0 1 1 4 5 7 8 9
As soon as we reach a point where the weight is 0, we're done. Our two selected items are
(5, 4) and (4, 3). The total weight is 7 and our total benefit is 9. We add the two tuples
together to find this out.
Let's begin coding this.
Now we know how it works, and we've derived the recurrence for it - it shouldn't be too
hard to code it. If our two-dimensional array is i (row) and j (column) then we have:
If our weight j is less than the weight of item i (i does not contribute to j) then:
This is what the core heart of the program does. I've copied some code from hereto help
explain this. I'm not going to explain this code much, as there isn't much more to it than
what I've already explained. If you're confused by it, leave a comment below or email me
😁
Time Complexity of a Dynamic Programming Problem
Now, I'll be honest. The master theorem deserves a blog post of its own.
Dynamic Programming & Divide and Conquer are similar. Dynamic Programming is based on
Divide and Conquer, except we memoise the results.
But, Greedy is different. It aims to optimise by making the best choice at that moment.
Sometimes, this doesn't optimise for the whole problem. Take this question as an example.
We have 3 coins:
1p, 15p, 25p
And someone wants us to give a change of 30p. With Greedy, it would select 25, then 5 * 1
for a total of 6 coins. The optimal solution is 2 * 15. Greedy works from largest to smallest.
At the point where it was at 25, the best choice would be to pick 25.
Tabulation (Bottom-Up) vs Memoisation (Top-Down)
We've computed all the subproblems but have no idea what the optimal evaluation order is.
We would then perform a recursive call from the root, and hope we get close to the optimal
solution or obtain a proof that we will arrive at the optimal solution. Memoisation ensures
you never recompute a subproblem because we cache the results, thus duplicate sub-trees
are not recomputed.
From our Fibonacci sequence earlier, we start at the root node. The subtree F(2) isn't
calculated twice.
This starts at the top of the tree and evaluates the subproblems from the leaves/subtrees
back up towards the root. Memoisation is a top-down approach.
Tabulation (Bottom-Up)
We've also seen Dynamic Programming being used as a 'table-filling' algorithm. Usually, this
table is multidimensional. This is like memoisation, but with one major difference. We have
to pick the exact order in which we will do our computations. The knapsack problem we
saw, we filled in the table from left to right - top to bottom. We knew the exact order of
which to fill the table.
Sometimes the 'table' is not like the tables we've seen. It can be a more complicated
structure such as trees. Or specific to the problem domain, such as cities within flying
distance on a map.
Tabulation & Memosation - Advantages and Disadvantages
Conclusion
Most of the problems you'll encounter within Dynamic Programming already exist in one
shape or another. Often, your problem will build on from the answers for previous
problems. Here's a list of common problems that use Dynamic Programming.
I hope that whenever you encounter a problem, you think to yourself "can this problem be
solved with ?" and try it.