mirror of
https://github.com/labuladong/fucking-algorithm.git
synced 2025-07-05 12:04:37 +08:00
Finish translation
This commit is contained in:
134
dynamic_programming/OptimalSubstructure.md
Normal file
134
dynamic_programming/OptimalSubstructure.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Q&A on Dynamic Programming
|
||||
|
||||
**Translator: [qy-yang](https://github.com/qy-yang)**
|
||||
|
||||
**Author: [labuladong](https://github.com/labuladong)**
|
||||
|
||||
This article will answer two questions:
|
||||
|
||||
1. What exactly is called "optimal substructure" and what is the relationship with dynamic programming?
|
||||
|
||||
2. Why does dynamic programming have various ways to traverse `dp` arrays, some are traversing fowards, some are traversing backwards, and some are traversing diagonally.
|
||||
|
||||
### 1. Optimal substructure
|
||||
|
||||
"Optimal substructure" is a specific property of some problems and is not exclusive to dynamic programming. In other words, many problems actually have optimal substructures, but most of them do not have overlapping subproblems, so we cannot classify them dynamic programming problems.
|
||||
|
||||
Let me give an easy-to-understand example: suppose your school has 10 classes, and you have calculated the highest test score for each class. So now I ask you to calculate the highest grade in the school, can you calculate? Of course, and you don't need to re-traverse the scores of every student in the school for comparison, but take the largest of the 10 highest scores to get the highest score of the whole school.
|
||||
|
||||
The example is **exhibiting optimal substructure**: the optimal solution of a problem can be derived from the optimal solultion of the subproblem. Calculating the highest score of **each class** is the subproblems. Once you know the answers to all the subprbolems, you can use this to derive the solution of the original problem that calculating the highest score across the school.
|
||||
|
||||
You see, such a simple problem has the optimal-substructure property, but since there is no overlapping subproblems, we cannot simply use dynamic programming to find the optimal value.
|
||||
|
||||
Another example: Suppose your school has 10 classes, and you know the maximum score difference (the difference between the highest and lowest scores) for each class. And now I ask you to calculate the maximum score difference among the students in the school, can you calculate it? You can figure it out, but you can't calculate it by knowing the maximum score difference of these 10 classes. Because the maximum score difference of the 10 classes does not necessarily contain the maximum score difference of the entire school, for example, the maximum score difference of the whole school may be the difference between the highest score of class 3 and the lowest score of class 6.
|
||||
|
||||
The question I asked you this time does **not have optimal sub-structure**, because you cannot get the optimal solution of the entire school through the optimal solution of each class, there is no way to build an optimal solution to the problem from optimal solutions to subproblems. As mentioned earlier in "Detailed Explanation of Dynamic Programming", in order to satisfy the optimal substructure, the subproblems must be independent of each other. The maximum score difference of the whole school may appear between the two classes. Obviously, the subproblems are not independent, so this problem does not have the optimal substructure.
|
||||
|
||||
**So what can be done when it lacks of optimal substructure? The strategy is: recontructing the problem**. For the problem of maximum score difference, as we can't use the known score difference of each class, I write a piece of brute-force code like this:
|
||||
|
||||
```java
|
||||
int result = 0;
|
||||
for (Student a : school) {
|
||||
for (Student b : school) {
|
||||
if (a is b) continue;
|
||||
result = max(result, |a.score - b.score|);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
```
|
||||
|
||||
Reconstructing the problem or transforming it to an equivalent problem: isn't the maximum score difference equivalent to the difference between the highest score and the lowest score, isn't that the highest and lowest score required, isn't it the first question we discussed, doesn't it exhibit the optimal substructure? Thinking it differently and we can apply the optimal substructure to solve the highest-score & lowest-score problem, and then go back to solve the problem of maximum score difference, isn't it more efficient?
|
||||
|
||||
Of course, the example above is very simple, but readers should note that we are constantly seeking the maximum/minimum values when solving the dynamic programming problem. It is essencially the same with the example given. Dynamic programming is nothing more than solving the overlapping subproblems.
|
||||
|
||||
Previous sections "different definitions with different solutions" and "throwing eggs in high building throwing (advanced)" showed how to transform the problem. Different optimal substructures may lead to different solutions and efficiency.
|
||||
|
||||
Here is another common but very simple example: find the maximum value of a binary tree (for simplicity, assume that all the values in the nodes are non-negative):
|
||||
|
||||
```java
|
||||
int maxVal(TreeNode root) {
|
||||
if (root == null)
|
||||
return -1;
|
||||
int left = maxVal(root.left);
|
||||
int right = maxVal(root.right);
|
||||
return max(root.val, left, right);
|
||||
}
|
||||
```
|
||||
|
||||
You can observe that this problem also exhibits optimal substructure. The maximum value of the tree rooted at "root" node can be derived from the maximum value of the subtrees (subproblem) at left and right side. Based on the the example of the school and class before, it should be easy to understand.
|
||||
|
||||
Of course, this is not a dynamic programming problem. It is intended to show that the optimal substructure is not a unique property of dynamic programming. Most of the problems with optimal value have this property. **However, the optimal substructure is a necessary condition for dynamic programming problems.** So in the future, if you encounter the problem of optimal value. The dynamic programming is the right idea. This is the trick.
|
||||
|
||||
Dynamic programming is to induce the optimal solution starting from simplest base case. It can be viewed as a chain reaction. However, only the problems with optimal substructure have the chain reaction.
|
||||
|
||||
The process of finding the optimal substructure is actually the process of verifying the correctness of the state transition equation. There is a brute-force solution if the state transition exhibits the optimal substructure. Next, you can check if there are overlapping subproblems. If so, you can do the optimization. This is also a trick.
|
||||
|
||||
We are not giving the examples of non-classical dynamic programming here. Readers can find out how state transition follows the optimal substructure from previous articles. Next, let ’s look at another confusing issue with dynamic programming.
|
||||
|
||||
### 2. Traversal order of the `dp` array
|
||||
|
||||
I believe that some of the readers will definitely be confused with the traversal order of the `dp` arrays when they do dynamic programming problems . Let's take a two-dimensional `dp` array as an example. Sometimes we traverse forward:
|
||||
|
||||
```java
|
||||
int[][] dp = new int[m][n];
|
||||
for (int i = 0; i < m; i++)
|
||||
for (int j = 0; j < n; j++)
|
||||
// Calculate dp[i][j]
|
||||
```
|
||||
|
||||
Sometimes we traverse backward:
|
||||
|
||||
```java
|
||||
for (int i = m - 1; i >= 0; i--)
|
||||
for (int j = n - 1; j >= 0; j--)
|
||||
// Calculate dp[i][j]
|
||||
```
|
||||
|
||||
Sometimes it may traverse diagonally:
|
||||
|
||||
```java
|
||||
// Traverse the array diagonally
|
||||
for (int l = 2; l <= n; l++) {
|
||||
for (int i = 0; i <= n - l; i++) {
|
||||
int j = l + i - 1;
|
||||
// Calculate dp[i][j]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And even more confusing, the correct answer can be obtained by traversing forward or backward sometimes. For example, we can do both the forward and backward in some parts of the problem "best time to buy and sell stock".
|
||||
|
||||
If you look it closely, you can find out the reason. There are two points you should take note:
|
||||
|
||||
**1. During the traversal, all the required state must have been calculated**.
|
||||
|
||||
**2. The final point of the traversal must be the point where the result is stored**.
|
||||
|
||||
Let's explain the two principles above in detail.
|
||||
|
||||
For example, the classic problem of "edit distance" explained in the previous article [Edit Distance](https://github.com/labuladong/fucking-algorithm/blob/english/dynamic_programming/EditDistance.md). From the definition of `dp`, we know the base case is `dp[..][0]` and `dp[0][..]`. The final answer is `dp[m][n]`; and we know from the state transition equation that `dp[i][j]` is derived from `dp[i-1][j]`, `dp[i] [j-1]`, `dp [i-1] [j-1]`, as shown below:
|
||||
|
||||

|
||||
|
||||
So, referring to the two principles just mentioned, how would you traverse the `dp` array? It should be a forward traversal:
|
||||
|
||||
```java
|
||||
for (int i = 1; i < m; i++)
|
||||
for (int j = 1; j < n; j++)
|
||||
// First calculate dp[i-1][j], dp[i][j-1], dp[i-1][j-1]
|
||||
// Then calculate dp[i][j]
|
||||
```
|
||||
|
||||
In this way, the left, top, and top left of each iteration will either be base cases or states calculated before, and finally it ends up at the answer we want `dp[m][n]`.
|
||||
|
||||
Another example, the palindrome subsequence problem, refer to [Strategies For Subsequence Problem](https://github.com/labuladong/fucking-algorithm/blob/english/dynamic_programming/StrategiesForSubsequenceProblem.md) for details. From the definition of `dp` array, we know the base case is in the middle of diagonal of the array. `dp[i][j]` is derived from `dp[i+1][j]`, `dp[i][j-1]`, and `dp[i+1][j-1]`, and the final answer to be calculated is `dp[0][n-1]`, as shown below:
|
||||
|
||||

|
||||
|
||||
In this case, there are two correct traversal orders based on the two principles mentioned:
|
||||
|
||||

|
||||
|
||||
Either traverse obliquely from left to right, or traverse from bottom to top, left to right, so that to ensure the left, bottom, and bottom left of `dp[i][j]` have been calculated.
|
||||
|
||||
Now, you should understand these two principles, which is mainly determined by the base case and the location of the final result. You just need to make sure that the intermediate results used in the traversal process have been calculated. Sometimes there are multiple ways to get the correct answer, and you can choose one based on your preference.
|
@ -1,134 +0,0 @@
|
||||
# 动态规划答疑篇
|
||||
|
||||
这篇文章就给你讲明白两个问题:
|
||||
|
||||
1、到底什么才叫「最优子结构」,和动态规划什么关系。
|
||||
|
||||
2、为什么动态规划遍历 `dp` 数组的方式五花八门,有的正着遍历,有的倒着遍历,有的斜着遍历。
|
||||
|
||||
### 一、最优子结构详解
|
||||
|
||||
「最优子结构」是某些问题的一种特定性质,并不是动态规划问题专有的。也就是说,很多问题其实都具有最优子结构,只是其中大部分不具有重叠子问题,所以我们不把它们归为动态规划系列问题而已。
|
||||
|
||||
我先举个很容易理解的例子:假设你们学校有 10 个班,你已经计算出了每个班的最高考试成绩。那么现在我要求你计算全校最高的成绩,你会不会算?当然会,而且你不用重新遍历全校学生的分数进行比较,而是只要在这 10 个最高成绩中取最大的就是全校的最高成绩。
|
||||
|
||||
我给你提出的这个问题就**符合最优子结构**:可以从子问题的最优结果推出更大规模问题的最优结果。让你算**每个班**的最优成绩就是子问题,你知道所有子问题的答案后,就可以借此推出**全校**学生的最优成绩这个规模更大的问题的答案。
|
||||
|
||||
你看,这么简单的问题都有最优子结构性质,只是因为显然没有重叠子问题,所以我们简单地求最值肯定用不出动态规划。
|
||||
|
||||
再举个例子:假设你们学校有 10 个班,你已知每个班的最大分数差(最高分和最低分的差值)。那么现在我让你计算全校学生中的最大分数差,你会不会算?可以想办法算,但是肯定不能通过已知的这 10 个班的最大分数差推到出来。因为这 10 个班的最大分数差不一定就包含全校学生的最大分数差,比如全校的最大分数差可能是 3 班的最高分和 6 班的最低分之差。
|
||||
|
||||
这次我给你提出的问题就**不符合最优子结构**,因为你没办通过每个班的最优值推出全校的最优值,没办法通过子问题的最优值推出规模更大的问题的最优值。前文「动态规划详解」说过,想满足最优子结,子问题之间必须互相独立。全校的最大分数差可能出现在两个班之间,显然子问题不独立,所以这个问题本身不符合最优子结构。
|
||||
|
||||
**那么遇到这种最优子结构失效情况,怎么办?策略是:改造问题**。对于最大分数差这个问题,我们不是没办法利用已知的每个班的分数差吗,那我只能这样写一段暴力代码:
|
||||
|
||||
```java
|
||||
int result = 0;
|
||||
for (Student a : school) {
|
||||
for (Student b : school) {
|
||||
if (a is b) continue;
|
||||
result = max(result, |a.score - b.score|);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
```
|
||||
|
||||
改造问题,也就是把问题等价转化:最大分数差,不就等价于最高分数和最低分数的差么,那不就是要求最高和最低分数么,不就是我们讨论的第一个问题么,不就具有最优子结构了么?那现在改变思路,借助最优子结构解决最值问题,再回过头解决最大分数差问题,是不是就高效多了?
|
||||
|
||||
当然,上面这个例子太简单了,不过请读者回顾一下,我们做动态规划问题,是不是一直在求各种最值,本质跟我们举的例子没啥区别,无非需要处理一下重叠子问题。
|
||||
|
||||
前文「不同定义不同解法」和「高楼扔鸡蛋进阶」就展示了如何改造问题,不同的最优子结构,可能导致不同的解法和效率。
|
||||
|
||||
再举个常见但也十分简单的例子,求一棵二叉树的最大值,不难吧(简单起见,假设节点中的值都是非负数):
|
||||
|
||||
```java
|
||||
int maxVal(TreeNode root) {
|
||||
if (root == null)
|
||||
return -1;
|
||||
int left = maxVal(root.left);
|
||||
int right = maxVal(root.right);
|
||||
return max(root.val, left, right);
|
||||
}
|
||||
```
|
||||
|
||||
你看这个问题也符合最优子结构,以 `root` 为根的树的最大值,可以通过两边子树(子问题)的最大值推导出来,结合刚才学校和班级的例子,很容易理解吧。
|
||||
|
||||
当然这也不是动态规划问题,旨在说明,最优子结构并不是动态规划独有的一种性质,能求最值的问题大部分都具有这个性质;**但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的**,以后碰到那种恶心人的最值题,思路往动态规划想就对了,这就是套路。
|
||||
|
||||
动态规划不就是从最简单的 base case 往后推导吗,可以想象成一个链式反应,以小博大。但只有符合最优子结构的问题,才有发生这种链式反应的性质。
|
||||
|
||||
找最优子结构的过程,其实就是证明状态转移方程正确性的过程,方程符合最优子结构就可以写暴力解了,写出暴力解就可以看出有没有重叠子问题了,有则优化,无则 OK。这也是套路,经常刷题的朋友应该能体会。
|
||||
|
||||
这里就不举那些正宗动态规划的例子了,读者可以翻翻历史文章,看看状态转移是如何遵循最优子结构的,这个话题就聊到这,下面再来看另外个动态规划迷惑行为。
|
||||
|
||||
### 二、dp 数组的遍历方向
|
||||
|
||||
我相信读者做动态规问题时,肯定会对 `dp` 数组的遍历顺序有些头疼。我们拿二维 `dp` 数组来举例,有时候我们是正向遍历:
|
||||
|
||||
```java
|
||||
int[][] dp = new int[m][n];
|
||||
for (int i = 0; i < m; i++)
|
||||
for (int j = 0; j < n; j++)
|
||||
// 计算 dp[i][j]
|
||||
```
|
||||
|
||||
有时候我们反向遍历:
|
||||
|
||||
```java
|
||||
for (int i = m - 1; i >= 0; i--)
|
||||
for (int j = n - 1; j >= 0; j--)
|
||||
// 计算 dp[i][j]
|
||||
```
|
||||
|
||||
有时候可能会斜向遍历:
|
||||
|
||||
```java
|
||||
// 斜着遍历数组
|
||||
for (int l = 2; l <= n; l++) {
|
||||
for (int i = 0; i <= n - l; i++) {
|
||||
int j = l + i - 1;
|
||||
// 计算 dp[i][j]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
甚至更让人迷惑的是,有时候发现正向反向遍历都可以得到正确答案,比如我们在「团灭股票问题」中有的地方就正反皆可。
|
||||
|
||||
那么,如果仔细观察的话可以发现其中的原因的。你只要把住两点就行了:
|
||||
|
||||
**1、遍历的过程中,所需的状态必须是已经计算出来的**。
|
||||
|
||||
**2、遍历的终点必须是存储结果的那个位置**。
|
||||
|
||||
下面来距离解释上面两个原则是什么意思。
|
||||
|
||||
比如编辑距离这个经典的问题,详解见前文「编辑距离详解」,我们通过对 `dp` 数组的定义,确定了 base case 是 `dp[..][0]` 和 `dp[0][..]`,最终答案是 `dp[m][n]`;而且我们通过状态转移方程知道 `dp[i][j]` 需要从 `dp[i-1][j]`, `dp[i][j-1]`, `dp[i-1][j-1]` 转移而来,如下图:
|
||||
|
||||

|
||||
|
||||
那么,参考刚才说的两条原则,你该怎么遍历 `dp` 数组?肯定是正向遍历:
|
||||
|
||||
```java
|
||||
for (int i = 1; i < m; i++)
|
||||
for (int j = 1; j < n; j++)
|
||||
// 通过 dp[i-1][j], dp[i][j - 1], dp[i-1][j-1]
|
||||
// 计算 dp[i][j]
|
||||
```
|
||||
|
||||
因为,这样每一步迭代的左边、上边、左上边的位置都是 base case 或者之前计算过的,而且最终结束在我们想要的答案 `dp[m][n]`。
|
||||
|
||||
再举一例,回文子序列问题,详见前文「子序列问题模板」,我们通过过对 `dp` 数组的定义,确定了 base case 处在中间的对角线,`dp[i][j]` 需要从 `dp[i+1][j]`, `dp[i][j-1]`, `dp[i+1][j-1]` 转移而来,想要求的最终答案是 `dp[0][n-1]`,如下图:
|
||||
|
||||

|
||||
|
||||
这种情况根据刚才的两个原则,就可以有两种正确的遍历方式:
|
||||
|
||||

|
||||
|
||||
要么从左至右斜着遍历,要么从下向上从左到右遍历,这样才能保证每次 `dp[i][j]` 的左边、下边、左下边已经计算完毕,得到正确结果。
|
||||
|
||||
现在,你应该理解了这两个原则,主要就是看 base case 和最终结果的存储位置,保证遍历过程中使用的数据都是计算完毕的就行,有时候确实存在多种方法可以得到正确答案,可根据个人口味自行选择。
|
||||
|
||||
**致力于把算法讲清楚!欢迎关注我的微信公众号 labuladong,查看更多通俗易懂的文章**:
|
||||
|
||||

|
Reference in New Issue
Block a user