- Introduction
- Steps to Solve Dynamic Programming Problem
- Classic Models of Dynamic Programming
- Common State Transition Formula
- Optimization for Dynamic Programming
- References
Dynamic programming is a computer programming method, that simplifies a complicated problem by breaking it down into simpler sub-problems in a recursive manner.
There are two key attributes that a problem must have in order for dynamic programming to be applicable
- optimal substructure: an optimal solution can be constructed from optimal solutions of its subproblems.
- overlapping sub-problems: the problem can be broken down into subproblems which are reused several times or a recursive algorithm for the problem solves the same subproblem over and over rather than always generating new subproblems
If a problem can be solved by combining optimal solutions to non-overlapping sub-problems, the strategy is called "Divide and Conquer" instead. This is why merge sort and quick sort are not classified as dynamic programming problems.
Let's use Fibonacci number as an example. This can be resolved in either of two ways
Top-down approach is the direct fall-out of the recursive formulation of any problem. If the solution to any problem can be formulated recursively using the solution to its sub-problems, and if its sub-problems are overlapping, then one can easily memoize or store the solutions to the sub-problems in a table. This approach is usually used to solve the problem with tree structure, which starts with the root node and finds all sub-problems in siblings.
int fibonacci(int n, int[] f) {
if(n == 0) {
f[0] = 1;
return f[0]
}
if(n == 1) {
f[1] = 1;
return f[1];
}
if(f[n] != 0) { // 0 means the state is not calculated yet
return f[n];
} else {
f[n] = fibonacci(n-1, f) + fibonacci(n-2, f);
return f[n];
}
}
Memoization method is used to store the most recent state values, so that if next time calling from the same state, it can be simply returned from the memory.
- Bottom-up approach: Once we formulate the solution to a problem recursively as in terms of its sub-problems, we can try reformulating the problem in a bottom-up fashion: try solving the sub-problems first and use their solutions to build-on and arrive at solutions to bigger sub-problems.
int fibonacci(int n) {
if(n == 0) return 0;
if(n == 1) return 1;
int[] dp = new int[n+1];
dp[0] = 0;
dp[1] = 1;
for(int i = 2; i <=n ; ++i) {
dp[i] = dp[i-1]+dp[i-2];
}
return dp[n];
}
To solve this problem, as destination state n, we need to start with the base state as state 0. Tabulation method focuses on filling the entries of the cache, until the target value has been reached.
In general, tabulation usually outperforms memoization by a constant factor. This is because tabulation has no overhead for recursion.
Dynamic programming amounts to breaking down an optimization problem into simpler sub-problems, and storing the solution to each sub-problem so that each sub-problem is only solved once.
Generally, I will go through the following steps to solve any DP problem
- Recognize a DP Problem
- Identify States with Least Variables
- Formulating State Transition
- Adding Memoization or Tabulation for States
- Further Optimize
DP is essentially an optimization technique. It solves problem by breaking it down into a collection of simpler subproblems, solving each of those subproblems just once, and storing their solutions. So any problems can be split to multiple steps and each step can be used more than once, they can be solved and optimized by DP solution.
- Typically, all the problems that require to maximize or minimize certain quantity or counting problems that say to count the arrangements under certain condition or certain probability problems can be solved by using Dynamic Programming.
- All dynamic programming problems satisfy the overlapping subproblems property and most of the classic dynamic problems also satisfy the optimal substructure property. Once, we observe these properties in a given problem, be sure that it can be solved using DP.
DP problems are all about states and their transition.
This is the most basic step which must be done very carefully because the state transition depends on the choice of state definition we make. So, let’s see what do we mean by the term “state”. State can be defined as the set of parameters that can uniquely identify a certain position or standing in the given problem. This set of parameters should be as small as possible to reduce state space.
For example: in the famous Knapsack problem, we define our state by two parameters index and weight i.e DP[index][weight]. Here DP[index][weight] tells us the maximum profit it can make by taking items from range 0 to index having the capacity of sack to be weight. Therefore, here the parameters index and weight together can uniquely identify a subproblem for the knapsack problem.
The hardest part in DP is how to formulate the state transition and identify the base state. It requires a lot of intuition, observation and practice. Most of problems can be applied by certain model of dynamic programming. They will be introduced in Classic Models of Dynamic Programming.
Once the state transition is well formulated, adding storage (memoization or tabulation) is trivial. It just needs to be aligned with state definition.
A DP solution can be, sometimes, further optimized in term of space complexity.
Linear model is the most common one to solve DP problems. Linear
here means the states are distributed as linear, and the tabulation is usually an array.
Take this problem, People on a Rickety Bridge, as an example.
Question: N (N <= 50) people need to cross a rickety bridge at night. Unfortunately, they have one torch and the bridge is to dangerous to cross without a torch. The bridge can support only two people at a time. All the people don’t take the same time to cross the bridge. Time for i-th person is T[i]. What is the shortest time needed for all of them to cross the bridge?
The initial solution most people will think of is to use the fastest person as an usher to guide everyone across (greedy algorithm). So the total time is
T = minPTime * (N-2) + (totalSum-minPTime)
Let's verify this by some real data. There are 4 people, time for each one is 1, 2, 5, 10. The time based on this formula is 19. However, the correct answer is 17. The process is
- 1 and 2 go cross - 2 mins
- 2 comes back - 2 mins
- 5 and 10 go across - 10 mins
- 1 comes back - 1 mins
- 1 and 2 go across - 2 mins
So, the initial greedy solution cannot reach the optimal state.
Before solving this problem, we need to sort the time per person. The least time of first i people go cross the bright is opt[i]. If i-1 people have crossed,
opt[i] = opt[i-1] + a[1] + a[i]
If less than i-1 people have crossed,
opt[i] = opt[i-2] + a[1] + a[i] + 2*a[2]
Finally
opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2*a[2] }
Interval model is commonly defined as dp[i][j], which specifies the optimal solution between state i and state j. The transition is based on dp[i-1][j] and/or dp[i][j-1]. The final optimal will be dp[1][len].
Question: Given a string A, which length n is less than 1000, find the min cost of converting it to be a palindrome by inserting letter each time.
State: dp[i][j] is the min cost of converting the substring A[i...j]
Base Case: dp[i][i] = 0, dp[i][i+1] = 0 if A[i] = A[i+1]
Transition:
- If A[i] = A[j], dp[i][j] = dp[i-1][j-1]
- If A[i] != A[j], dp[i][j] = mim{dp[i][j-1] (add A[j] before A[i]), dp[i-1][j] (add A[i] after A[j])} + 1
Knapsack problem is one of most famous problems in dynamic programming.
Question: Given weights and values of n items (every item has and only has one copy), put these items in a knapsack of capacity W to get the maximum total value in the knapsack.
State: f[i][v] specifies the max value putting i-th item to the knapsack of capacity v
Base Case: f[0][v] = 0, f[i][0] = 0
Transition: f[i][v] = max{ f[i-1][v], f[i-1][v - C[i]] + W[i] }
Question: Given weights and values of n items (every item has infinite copies), put these items in a knapsack of capacity W to get the maximum total value in the knapsack.
State: f[i][v] specifies the max value putting i-th item to the knapsack of capacity v
Base Case: f[0][v] = 0, f[i][0] = 0
Transition: f[i][v] = max{ f[i-1][v], f[i-1][v - k * C[i]] + k * W[i] | | 0 <= k <=� v/Ci }
State compressing model is used to handle the dynamic programming problem with small data set. It can optimize storage by converting states to binary.
It is usually too hard for a coding interview. I will add more details later.
Tree model specifies the state as node in a tree and all transitions happen there.
There are three major factors for dynamic programming
- the tabulation of all subproblems
- the dependencies between subproblems, which is essentially a graph
- the order of filling out tabulation (the topological order of graph)
The common state transition formula includes
- d[i] = opt{ d[j] + w(j, i) | 0 <= i < j } (1 <= i <= n)
- d[i][j] = opt{ d[i-1][j] + xi, d[i][j-1] + yj, d[i-1][j-1] + zij } (1<= i, j <= n)
- d[i][j] = w(i, j) + opt{ d[i][k-1] + d[k][j] }, (1 <= i < j <= n)
- d[i][j] = opt{ d[i'][j'] + w(i', j', i, j) | 0 <= i' < i, 0 <= j' < j}
Take the example as the palindrome problem in Interval Model again.
The transition is defined as
- If A[i] = A[j], dp[i][j] = dp[i-1][j-1]
- If A[i] != A[j], dp[i][j] = mim{dp[i][j-1] (add A[j] before A[i]), dp[i-1][j] (add A[i] after A[j])} + 1
dp[i][j] is a 2D matrix, and the result at i-th row is only based (i-1)-th and (i+1)-th rows. So we only need a matrix as dp[2][N] to store all states. As the result, the space complexity is optimized from O(n^2) to O(n).
- https://en.wikipedia.org/wiki/Dynamic_programming
- https://en.wikipedia.org/wiki/Knapsack_problem#0-1_knapsack_problem
- https://www.geeksforgeeks.org/dynamic-programming/
- https://dev.to/nikolaotasevic/dynamic-programming--7-steps-to-solve-any-dp-interview-problem-3870
- https://www.freecodecamp.org/news/demystifying-dynamic-programming-3efafb8d4296/
- http://cppblog.com/menjitianya/archive/2015/10/23/212084.html