Dynamic Programming
JB
Posted on May 31, 2020
Resources:
- In-depth explanation with examples
- Wikipedia article
- 0/1 Knapsack problem video
- Longest Common Subsequence (LCS) problem video
- Bottom-up LCS implementation
- Top-down LCS implementation
- Top-down 0/1 Knapsack implementation
- Bottom-up 0/1 Knapsack implementation
Takeaways:
- Dynamic programming is a process by which a larger problem is reduced to sub-problems.
- These sub-problems are easier to reason about, easier to solve individually, and are typically decision problems.
- By solving these sub-problems, dynamic programming enables us to build up an answer to the larger, more complex, problem.
- If a problem can be solved optimally in this way then the problem is considered to have an optimal substructure.
- Optimal substructure just means that a problem has an optimal solution that has been constructed from optimal solutions of it's sub-problems.
- Dynamic programming solutions are usually either top-down or bottom-up.
- Top-down means we start from our input(s) and work downwards to our base-case using recursion (remember recursive functions have a base-case that determines when the recursion should end, similar to an exit condition in a while loop).
- Bottom-up means we start from the base-case and work upwards.
- Bottom-up is recursion free and thus will avoid building up a call stack of
O(n)
size. This means bottom-up approaches can be more memory efficient than their top-down counterparts.
- Bottom-up is recursion free and thus will avoid building up a call stack of
- Top-down solutions use recursion and memoization to solve the larger problem, whereas bottom-up solutions will use a nested for-loop to build a solution matrix/table before contructing the answer from the matrix.
- Memoization is sort of like caching. We remember the results of a computation so we don't have to recompute the same thing over and over. We instead check the "cache" and see if we have already done the work previously. Here's a good article on memoization.
- A sub-problem solution table, or matrix, is a matrix with each coordinate (
x,y
) containing the answer to a sub-problem (x,y
axis are representations of the problem inputs). - For example,
x
might represent a weight input, andy
might represent acost
input. Given a weight of1
and a cost of$3
we can find out in our matrix what the optimal solution is for those inputs by going to thex,y
coordinates1,3
. - Top-down dynamic programming solutions memoize the solutions to sub-problems to avoid duplicated work.
- Bottom-up solutions merely keep a solution matrix/table in memory, but only use it for constructing the solution - meaning technically there is no memoization, as the same sub-problem does not get solved more than once.
- A famous dynamic programming problem is the 0/1 Knapsack problem
- The 0/1 Knapsack problem statement is:
- Given a knapsack & a set of items (each item has a weight and value) determine the items to put in your knapsack. The total weight of the knapsack must be less than or equal to a given limit, and the total value should be as large as possible.
- The items cannot be split/divided. Which is where the
0/1
comes from - we either take an item or leave it.
- The following is an overview of solutions to 0/1 Knapsack (at the conclusion of this post you will find actual code for both top-down and bottom-up solutions):
- The naive, brute force, solution to 0/1 Knapsack is to try all possible combinations of items, keeping track of which subset yields the greatest value whilst being under the weight limit.
- This approach is exponential
O(2^n)
, with space beingO(n)
- The algorithm would be something like:
- Define a recursive function (e.g
solveKnapsack(int[] values, int[] weights, int weightLimit, int index)
) - Define a base-case at the start of our function that will ensure our index and weight limit are not out of bounds.
- For each item
i
(i.e value ofi
isvalues[i]
, weight ofi
isweight[i]
):- Compute a value
v1
which includes itemi
if it's weight is lower than our weight limit, deducti
's weight from our limit & then recursively process the remaining items. - Otherwise, if
i
has a weight greater than our limit, compute a valuev2
which exludes itemi
and then recursively process the remaining items.
- Compute a value
- At the end of our recursive function, return the maximum between
v1
andv2
.
- Define a recursive function (e.g
- This approach is exponential
- If our naive approach is written recursively similar to described, then we can see that this approach is top-down - it is working from our inputs downwards to a base-case.
- Each call to
solveKnapsack
in our naive solution is solving a sub-problem: do we include or exlude itemi
? And whatever the answer, what is the value (v1
orv2
) of that decision? - We can add an extra parameter (representing a table or matrix) to our function that could be used to memoize the results of these sub-problems. This will help to avoid duplicated work (assuming we also add a check that will return the memoized value/sub-problem solution, if it has already been solved)
- A bottom-up approach merely removes recursion from our previous solution and builds up the matrix at the start of the function. We can then use the matrix to determine things like: what is the maximum value? what items were selected? (via backtracking).
Time & space complexities for the top-down and bottom-up solutions are
O(nm)
wheren
is the number of items andm
is the weight limit.Another famous problem that can be solved with dynamic programming is Longest Common Subsequence (LCS).
-
LCS problem statement is:
- Given two strings
inputA
andinputB
, return the length of their longest common subsequence. - Other variations might also ask you to return the LCS string itself. In my implementations I return the length, but also print the LCS before returning.
- Given two strings
-
So what exactly is a subsequence?
- A subsequence of a string
A
is a new stringB
that has some of the characters fromA
deleted, without changing the relative order of the remaining characters.- This is unlike a substring, which is simply a sub-section of another string.
- For example: given
A = "abcdefg"
a subsequenceB
could be"acdeg"
or"ag"
or"abd"
. Notice howB
's characters all exist inA
? And how each character's relative position is unchanged? -
"acb"
&"dac"
would represent a change in the relative order and so are examples of invalid subsequences ofA
. - Further, we can say that if two strings share a subsequence it is a common subsequence. An example of a common subsequence: Given two strings
A
&B
(A = "hello", B = "below"
) a common subsequnceC
of the two isC = "el"
.
- A subsequence of a string
-
The solutions to LCS are strikingly similar to the ones for 0/1 Knapsack:
- The naive approach, again, is brute force and checks every possible subsequence in
inputA[0..n]
andinputB[0..m]
. The longest subsequence that exists in both inputs will be returned.- This approach is
O(n * 2^m)
- This approach is
- But what decisions does our naive approach make? How is it finding the subsequences?
- For each recursive call to
solveLCS(string inputA, string inputB)
, if bothinputA
&inputB
end with the same character then we remove the trailing character from each and recursively callsolveLCS(inputA[0..n-1], inputB[0..m-1])
and return the result.- Why? Because if they end with the same character, that means we have found a subsequence - we remove that character and process the rest of each string to determine how long the longest subsequence is.
- If our inputs do not have the same last character, then we have yet to identify a subsequence so we must remove a trailing character from one of the inputs and recursively call
solveLCS()
, returning the result.- We do this for both
inputA
andinputB
((inputA[0..n-1], inputB[0..m])
&(inputA[0..n], inputB[0..m-1])
), after computing the LCS of both we return the larger of the two values.
- We do this for both
- The base-case for our naive recursive algorithm checks if
inputA
orinputB
are empty strings (as we will be removing characters from each during the recursion).
- For each recursive call to
- The naive approach, again, is brute force and checks every possible subsequence in
Like 0/1 Knapsack, to transform the naive recursive solution into a dynamic top-down solution all we need to do is add an additional data structure as a parameter. This parameter will represent our table/matrix and will be used to memoize each sub-problem's solution. We effectively cache each decision made in the recursive routine.
Again, like with 0/1 Knapsack, a bottom-up solution will not use recursion and instead, upfront, build the solution matrix using a nested for-loop. From the resulting matrix we can deduce solutions to: what is the longest common subsequence? What characters are in that subsequence?
Time & space complexities for the top-down and bottom-up LCS solutions are
O(nm)
wheren
is the number of characters ininputA
andm
is the number of characters ininputB
. The top-down solution, due to recursion, is technicallyO(nm + n)
- but asymptotically we don't factor in the extra cost (because it is constant).
Lastly, you will notice that both 0/1 Knapsack and LCS problems are solvable in exponential time when approached naively. Dynamic programming not only gives us a blueprint for solving both, but enables us to reduce the running time for both these problems to polynomial running time (specifically O(nm)
).
- Polynomial running time is simply a category of running times. Formally, it is any running time that is
O(n^k)
wherek
is non-negative. Polynomial running time is much better than exponential running time. Here is a good comparison of various time complexities and their rates of growth.
Below are top-down & bottom-up solutions to LCS & 0/1 Knapsack problems, with test cases:
As always, if you found any errors in this post please let me know!
Posted on May 31, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.