This page mainly introduces the basic ideas of dynamic programming, as well as approaches to designing states and state transition equations in dynamic programming, helping beginners gain a preliminary understanding.
Other pages in this section introduce methods for building dynamic-programming models for various types of problems, as well as various optimization techniques for dynamic programming.
Introduction
[IOI1994] Digital Triangle
Given a number triangle with rows (), find a path that starts at the top and ends at any position on the bottom, maximizing the sum of the numbers along the path. At each step, one may move to the point immediately down-left or down-right of the current point.
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
In the example above, the optimal path is .
The simplest brute-force idea is to try all paths. Since the number of paths is on the order of , this approach is unacceptable.
Notice the following fact: every decision made along an optimal path is itself optimal.
For the optimal path mentioned in the example, consider only the first four steps, . There is no path from the top to the -nd number in the -th row with a greater weight.
For each point, there are only two choices for the next step: move down-left or down-right (if it exists). Therefore, we only need to record the maximum weight at the current point and use this maximum weight to make the next decision, updating the maximum weights of subsequent points.
This has another benefit: we successfully reduce the problem size by dividing one problem into multiple smaller problems. To obtain the optimal solution from the top to the -th row, we only need the information about the optimal solution from the top to the -th row.
There is still a problem: the overlapping parts among subproblems are numerous, and the same subproblem may be visited repeatedly, so the efficiency remains low. The solution is to store the solution to each subproblem and use memoization to restrict the visitation order, ensuring that each subproblem is visited only once.
These are some basic ideas of dynamic programming. The following sections will introduce the ideas of dynamic programming more systematically.
Principles of dynamic programming
Problems solvable by dynamic programming need to satisfy three conditions: optimal substructure, no aftereffect, and overlapping subproblems.
Optimal substructure
Having optimal substructure may also indicate that a problem is suitable for a greedy solution.
Make sure that all subproblems used in the optimal solution are considered.
- Prove that the first component of an optimal solution to the problem is making a choice;
- For a given problem, among its possible first-step choices, assume that you already know which choice will produce an optimal solution. You do not care how this choice was obtained; simply assume that it is already known;
- Given the choice that yields an optimal solution, determine which subproblems this choice produces and how to best characterize the subproblem space;
- Prove that, as components of the optimal solution to the original problem, the solution to each subproblem is itself optimal. Use proof by contradiction: suppose the solution added for a subproblem is not its own optimal solution. Then replace the current non-optimal solution in the original problem with the subproblem's optimal solution to obtain a better solution to the original problem, contradicting the assumption that the original solution is optimal.
Keep the subproblem space as simple as possible and expand it only when necessary.
The differences in optimal substructure appear in two aspects:
- How many subproblems are involved in the optimal solution to the original problem;
- How many choices must be considered when determining which subproblems are used by the optimal solution.
Each vertex in a subproblem graph corresponds to a subproblem, while the choices that need to be considered correspond to edges incident to the subproblem vertices.
No aftereffect
A subproblem that has already been solved will not be affected by subsequent decisions.
Overlapping subproblems
If there are many overlapping subproblems, we can use space to store their solutions, avoiding repeated solutions of the same subproblems and thereby improving efficiency.
Basic idea
For a problem solvable by dynamic programming, the following approach is generally used:
- Divide the original problem into several stages, with each stage corresponding to several subproblems, and extract the features of these subproblems (called states);
- Find the possible decisions for each state, or the transitions among states (described mathematically as the state transition equations);
- Solve the problems in each stage in order.
From the perspective of graph theory, construct a directed acyclic graph, where each state corresponds to a node in the graph and decisions correspond to edges between nodes. The problem is thereby transformed into finding a longest (shortest) path on a DAG (see DP on a DAG).
Longest common subsequence
Longest common subsequence problem
Given a sequence of length and a sequence of length (), find a longest sequence that is a subsequence of both and .
The definition of a subsequence can be found in Subsequence. A brief example: the common subsequences of the strings abcde and acde include a, c, d, e, ac, ad, ae, cd, ce, de, acd, ade, ace, cde, and acde; the length of the longest common subsequence is 4.
Let denote the length of the longest common subsequence when considering only the first elements of and the first elements of . Finding the length of the longest common subsequence in this situation is the subproblem. is the state described above, and is the final state to be reached, namely the desired result.
For each , there are three decisions. If , it can be appended to the end of the common subsequence; the other two decisions are to skip or . The state transition equation is:
See SourceForge's interactive LCS page for a better understanding of the LCS implementation process.
Reference implementation
C++
int n, m, a[MAXN], b[MAXM], f[MAXN][MAXM];
int dp() {
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (a[i] == b[j])
f[i][j] = f[i - 1][j - 1] + 1;
else
f[i][j] = std::max(f[i - 1][j], f[i][j - 1]);
return f[n][m];
}
Python
def dp(n, m, a, b):
f = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i] == b[j]:
f[i][j] = f[i - 1][j - 1] + 1
else:
f[i][j] = max(f[i - 1][j], f[i][j - 1])
return f[n][m]
This method has time complexity .
In addition, this problem has an algorithm1. Interested students can explore it on their own.
Longest non-decreasing subsequence
Longest non-decreasing subsequence problem
Given a sequence of length (), find a longest subsequence of such that each element is no smaller than the preceding element.
Algorithm 1
Let denote the length of the longest non-decreasing subsequence ending at . The desired answer is .
When calculating , try appending to the ends of other longest non-decreasing subsequences to update the answer. Thus, the following state transition equation can be written: .
Reference implementation
C++
int n, a[MAXN], d[MAXN];
int dp() {
d[1] = 1;
int ans = 1;
for (int i = 2; i <= n; i++) {
d[i] = 1;
for (int j = 1; j < i; j++)
if (a[j] <= a[i]) {
d[i] = std::max(d[i], d[j] + 1);
ans = std::max(ans, d[i]);
}
}
return ans;
}
Python
def dp(n, a):
d = [0] * (n + 1)
d[1] = 1
ans = 1
for i in range(2, n + 1):
d[i] = 1
for j in range(1, i):
if a[j] <= a[i]:
d[i] = max(d[i], d[j] + 1)
ans = max(ans, d[i])
return ans
It is easy to see that this algorithm has time complexity .
Algorithm 2
When the range of expands to , the first approach is no longer fast enough. The following gives an approach.
Consider the previously defined state , representing that the longest non-decreasing subsequence ending at the -th element has length . Unlike the previous method, which processes states by fixed , here we directly determine whether is valid:
- The initial state is necessarily valid.
- For any , if there exists a such that is valid and , then is valid.
Finally, we only need to find the valid state with the largest to obtain the length of the longest non-decreasing subsequence.
Let the original sequence be , and define an array , where the -th position stores the minimum possible final element of a non-decreasing subsequence of length . Initially the sequence is empty. Let iterate from to , and successively find the length of the longest non-decreasing subsequence among the first elements. For the current element :
- If is greater than or equal to the last element in sequence , directly insert at the end of sequence .
- Explanation: if is greater than or equal to the final element of the current longest subsequence, a non-decreasing subsequence exists that can append . Not inserting it would destroy optimality.
- If is strictly smaller than the last element in , find the first element greater than it and replace that element with .
- Explanation: inserting it at the end would destroy the monotonicity of ; replacement ensures that the final element for each length is as small as possible, preserving more possibilities for subsequent elements.
- Optimization: because is non-decreasing, binary search can directly find the insertion position, reducing the overall complexity to instead of the complexity of brute-force searching.
If the actual longest non-decreasing subsequence must also be output, additionally maintain an array , representing the position of the minimum final element among non-decreasing subsequences of length (if there are several, any one may be chosen). During maintenance, when inserting element into , simultaneously update to . Also record the optimal predecessor of as . Finally, starting from any valid state of maximum length and tracing backward along predecessors , the complete subsequence can be obtained.
Reference implementation
C++
int n, a[MAXN], d[MAXN], di[MAXN], pre[MAXN], res[MAXN];
int dp() {
int ans = 0;
for (int i = 1; i <= n; ++i) {
int tmp = std::upper_bound(d, d + ans, a[i]) - d;
pre[i] = tmp ? di[tmp - 1] : -1;
d[tmp] = a[i];
di[tmp] = i;
if (tmp == ans) ++ans;
}
// Construct the subsequence.
for (int k = ans, i = di[ans - 1]; k; --k) {
res[k] = a[i];
i = pre[i];
}
return ans;
}
Python
n = 0
a = [0] * MAXN
d = [0] * MAXN
di = [0] * MAXN
pre = [0] * MAXN
res = [0] * MAXN
def dp():
ans = 0
for i in range(1, n + 1):
tmp = bisect.bisect_right(d, a[i], 0, ans)
pre[i] = di[tmp - 1] if tmp else -1
d[tmp] = a[i]
di[tmp] = i
if tmp == ans:
ans += 1
# Construct the subsequence
k = ans
i = di[ans - 1]
while k:
res[k] = a[i]
i = pre[i]
k -= 1
return ans
This algorithm has time complexity . The time complexity of outputting the answer is .
Note
For the strictly increasing subsequence problem, similarly let denote the minimum final element among all longest strictly increasing subsequences of length .
Note that in step 2, if , because adjacent elements in a longest strictly increasing subsequence cannot be equal, the first element not smaller than must be found in sequence and replaced with .
In the implementation (taking C++ as an example), change the upper_bound function to lower_bound.