mirror of
https://github.com/krahets/hello-algo.git
synced 2026-04-04 19:20:52 +08:00
build
This commit is contained in:
@@ -5,7 +5,7 @@ status: new
|
||||
|
||||
# 14.2 动态规划问题特性
|
||||
|
||||
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同:
|
||||
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
|
||||
|
||||
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
|
||||
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
|
||||
@@ -21,13 +21,13 @@ status: new
|
||||
|
||||
给定一个楼梯,你每步可以上 $1$ 阶或者 $2$ 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 $cost$ ,其中 $cost[i]$ 表示在第 $i$ 个台阶需要付出的代价,$cost[0]$ 为地面起始点。请计算最少需要付出多少代价才能到达顶部?
|
||||
|
||||
如图 14-6 所示,若第 $1$ , $2$ , $3$ 阶的代价分别为 $1$ , $10$ , $1$ ,则从地面爬到第 $3$ 阶的最小代价为 $2$ 。
|
||||
如图 14-6 所示,若第 $1$、$2$、$3$ 阶的代价分别为 $1$、$10$、$1$ ,则从地面爬到第 $3$ 阶的最小代价为 $2$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 14-6 爬到第 3 阶的最小代价 </p>
|
||||
|
||||
设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价,由于第 $i$ 阶只可能从 $i - 1$ 阶或 $i - 2$ 阶走来,因此 $dp[i]$ 只可能等于 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。为了尽可能减少代价,我们应该选择两者中较小的那一个,即:
|
||||
设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价,由于第 $i$ 阶只可能从 $i - 1$ 阶或 $i - 2$ 阶走来,因此 $dp[i]$ 只可能等于 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。为了尽可能减少代价,我们应该选择两者中较小的那一个:
|
||||
|
||||
$$
|
||||
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
|
||||
@@ -35,11 +35,11 @@ $$
|
||||
|
||||
这便可以引出最优子结构的含义:**原问题的最优解是从子问题的最优解构建得来的**。
|
||||
|
||||
本题显然具有最优子结构:我们从两个子问题最优解 $dp[i-1]$ , $dp[i-2]$ 中挑选出较优的那一个,并用它构建出原问题 $dp[i]$ 的最优解。
|
||||
本题显然具有最优子结构:我们从两个子问题最优解 $dp[i-1]$ 和 $dp[i-2]$ 中挑选出较优的那一个,并用它构建出原问题 $dp[i]$ 的最优解。
|
||||
|
||||
那么,上节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,**虽然题目修改前后是等价的,但最优子结构浮现出来了**:第 $n$ 阶最大方案数量等于第 $n-1$ 阶和第 $n-2$ 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
|
||||
|
||||
根据状态转移方程,以及初始状态 $dp[1] = cost[1]$ , $dp[2] = cost[2]$ ,我们就可以得到动态规划代码。
|
||||
根据状态转移方程,以及初始状态 $dp[1] = cost[1]$ 和 $dp[2] = cost[2]$ ,我们就可以得到动态规划代码。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -455,7 +455,7 @@ $$
|
||||
|
||||
不难发现,此问题已不满足无后效性,状态转移方程 $dp[i] = dp[i-1] + dp[i-2]$ 也失效了,因为 $dp[i-1]$ 代表本轮跳 $1$ 阶,但其中包含了许多“上一轮跳 $1$ 阶上来的”方案,而为了满足约束,我们就不能将 $dp[i-1]$ 直接计入 $dp[i]$ 中。
|
||||
|
||||
为此,我们需要扩展状态定义:**状态 $[i, j]$ 表示处在第 $i$ 阶、并且上一轮跳了 $j$ 阶**,其中 $j \in \{1, 2\}$ 。此状态定义有效地区分了上一轮跳了 $1$ 阶还是 $2$ 阶,我们可以据此来决定下一步该怎么跳:
|
||||
为此,我们需要扩展状态定义:**状态 $[i, j]$ 表示处在第 $i$ 阶、并且上一轮跳了 $j$ 阶**,其中 $j \in \{1, 2\}$ 。此状态定义有效地区分了上一轮跳了 $1$ 阶还是 $2$ 阶,我们可以据此来决定下一步该怎么跳。
|
||||
|
||||
- 当 $j$ 等于 $1$ ,即上一轮跳了 $1$ 阶时,这一轮只能选择跳 $2$ 阶。
|
||||
- 当 $j$ 等于 $2$ ,即上一轮跳了 $2$ 阶时,这一轮可选择跳 $1$ 阶或跳 $2$ 阶。
|
||||
@@ -702,11 +702,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题恢复无后效性。然而,许多问题具有非常严重的“有后效性”,例如:
|
||||
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
|
||||
|
||||
!!! question "爬楼梯与障碍生成"
|
||||
|
||||
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶。**规定当爬到第 $i$ 阶时,系统自动会给第 $2i$ 阶上放上障碍物,之后所有轮都不允许跳到第 $2i$ 阶上**。例如,前两轮分别跳到了第 $2, 3$ 阶上,则之后就不能跳到第 $4, 6$ 阶上。请问有多少种方案可以爬到楼顶。
|
||||
给定一个共有 $n$ 阶的楼梯,你每步可以上 $1$ 阶或者 $2$ 阶。**规定当爬到第 $i$ 阶时,系统自动会给第 $2i$ 阶上放上障碍物,之后所有轮都不允许跳到第 $2i$ 阶上**。例如,前两轮分别跳到了第 $2$、$3$ 阶上,则之后就不能跳到第 $4$、$6$ 阶上。请问有多少种方案可以爬到楼顶。
|
||||
|
||||
在这个问题中,下次跳跃依赖于过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ status: new
|
||||
|
||||
# 14.3 动态规划解题思路
|
||||
|
||||
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题:
|
||||
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题。
|
||||
|
||||
1. 如何判断一个问题是不是动态规划问题?
|
||||
2. 求解动态规划问题该从何处入手,完整步骤是什么?
|
||||
@@ -18,12 +18,12 @@ status: new
|
||||
|
||||
换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。
|
||||
|
||||
在此基础上,还有一些动态规划问题的“加分项”,包括:
|
||||
在此基础上,动态规划问题还有一些判断的“加分项”。
|
||||
|
||||
- 问题包含最大(小)或最多(少)等最优化描述。
|
||||
- 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。
|
||||
|
||||
而相应的“减分项”包括:
|
||||
相应地,也存在一些“减分项”。
|
||||
|
||||
- 问题的目标是找出所有可能的解决方案,而不是找出最优解。
|
||||
- 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。
|
||||
@@ -104,7 +104,7 @@ $$
|
||||
|
||||
### 1. 方法一:暴力搜索
|
||||
|
||||
从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,包括以下递归要素:
|
||||
从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,递归函数包括以下要素。
|
||||
|
||||
- **递归参数**:状态 $[i, j]$ 。
|
||||
- **返回值**:从 $[0, 0]$ 到 $[i, j]$ 的最小路径和 $dp[i, j]$ 。
|
||||
|
||||
@@ -35,12 +35,12 @@ status: new
|
||||
|
||||
每一轮的决策是对字符串 $s$ 进行一次编辑操作。
|
||||
|
||||
我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 $s$ 和 $t$ 的长度分别为 $n$ 和 $m$ ,我们先考虑两字符串尾部的字符 $s[n-1]$ 和 $t[m-1]$ :
|
||||
我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 $s$ 和 $t$ 的长度分别为 $n$ 和 $m$ ,我们先考虑两字符串尾部的字符 $s[n-1]$ 和 $t[m-1]$ 。
|
||||
|
||||
- 若 $s[n-1]$ 和 $t[m-1]$ 相同,我们可以跳过它们,直接考虑 $s[n-2]$ 和 $t[m-2]$ 。
|
||||
- 若 $s[n-1]$ 和 $t[m-1]$ 不同,我们需要对 $s$ 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。
|
||||
|
||||
也就是说,我们在字符串 $s$ 中进行的每一轮决策(编辑操作),都会使得 $s$ 和 $t$ 中剩余的待匹配字符发生变化。因此,状态为当前在 $s$ , $t$ 中考虑的第 $i$ , $j$ 个字符,记为 $[i, j]$ 。
|
||||
也就是说,我们在字符串 $s$ 中进行的每一轮决策(编辑操作),都会使得 $s$ 和 $t$ 中剩余的待匹配字符发生变化。因此,状态为当前在 $s$ 和 $t$ 中考虑的第 $i$ 和 $j$ 个字符,记为 $[i, j]$ 。
|
||||
|
||||
状态 $[i, j]$ 对应的子问题:**将 $s$ 的前 $i$ 个字符更改为 $t$ 的前 $j$ 个字符所需的最少编辑步数**。
|
||||
|
||||
@@ -48,7 +48,7 @@ status: new
|
||||
|
||||
**第二步:找出最优子结构,进而推导出状态转移方程**
|
||||
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为图 14-29 所示的三种情况:
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为图 14-29 所示的三种情况。
|
||||
|
||||
1. 在 $s[i-1]$ 之后添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$ 。
|
||||
2. 删除 $s[i-1]$ ,则剩余子问题 $dp[i-1, j]$ 。
|
||||
@@ -58,7 +58,7 @@ status: new
|
||||
|
||||
<p align="center"> 图 14-29 编辑距离的状态转移 </p>
|
||||
|
||||
根据以上分析,可得最优子结构:$dp[i, j]$ 的最少编辑步数等于 $dp[i, j-1]$ , $dp[i-1, j]$ , $dp[i-1, j-1]$ 三者中的最少编辑步数,再加上本次的编辑步数 $1$ 。对应的状态转移方程为:
|
||||
根据以上分析,可得最优子结构:$dp[i, j]$ 的最少编辑步数等于 $dp[i, j-1]$、$dp[i-1, j]$、$dp[i-1, j-1]$ 三者中的最少编辑步数,再加上本次的编辑步数 $1$ 。对应的状态转移方程为:
|
||||
|
||||
$$
|
||||
dp[i, j] = \min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1
|
||||
@@ -417,7 +417,7 @@ $$
|
||||
|
||||
### 3. 空间优化
|
||||
|
||||
由于 $dp[i,j]$ 是由上方 $dp[i-1, j]$ 、左方 $dp[i, j-1]$ 、左上方状态 $dp[i-1, j-1]$ 转移而来,而正序遍历会丢失左上方 $dp[i-1, j-1]$ ,倒序遍历无法提前构建 $dp[i, j-1]$ ,因此两种遍历顺序都不可取。
|
||||
由于 $dp[i,j]$ 是由上方 $dp[i-1, j]$、左方 $dp[i, j-1]$、左上方状态 $dp[i-1, j-1]$ 转移而来,而正序遍历会丢失左上方 $dp[i-1, j-1]$ ,倒序遍历无法提前构建 $dp[i, j-1]$ ,因此两种遍历顺序都不可取。
|
||||
|
||||
为此,我们可以使用一个变量 `leftup` 来暂存左上方的解 $dp[i-1, j-1]$ ,从而只需考虑左方和上方的解。此时的情况与完全背包问题相同,可使用正序遍历。
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ status: new
|
||||
我们可以尝试从问题分解的角度分析这道题。设爬到第 $i$ 阶共有 $dp[i]$ 种方案,那么 $dp[i]$ 就是原问题,其子问题包括:
|
||||
|
||||
$$
|
||||
dp[i-1] , dp[i-2] , \dots , dp[2] , dp[1]
|
||||
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
|
||||
$$
|
||||
|
||||
由于每轮只能上 $1$ 阶或 $2$ 阶,因此当我们站在第 $i$ 阶楼梯上时,上一轮只可能站在第 $i - 1$ 阶或第 $i - 2$ 阶上。换句话说,我们只能从第 $i -1$ 阶或第 $i - 2$ 阶前往第 $i$ 阶。
|
||||
@@ -384,10 +384,7 @@ $$
|
||||
|
||||
<p align="center"> 图 14-2 方案数量递推关系 </p>
|
||||
|
||||
我们可以根据递推公式得到暴力搜索解法:
|
||||
|
||||
- 以 $dp[n]$ 为起始点,**递归地将一个较大问题拆解为两个较小问题的和**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。
|
||||
- 最小子问题的解 $dp[1] = 1$ , $dp[2] = 2$ 是已知的,代表爬到第 $1$ , $2$ 阶分别有 $1$ , $2$ 种方案。
|
||||
我们可以根据递推公式得到暴力搜索解法。以 $dp[n]$ 为起始点,**递归地将一个较大问题拆解为两个较小问题的和**,直至到达最小子问题 $dp[1]$ 和 $dp[2]$ 时返回。其中,最小子问题的解是已知的,即 $dp[1] = 1$、$dp[2] = 2$ ,表示爬到第 $1$、$2$ 阶分别有 $1$、$2$ 种方案。
|
||||
|
||||
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁。
|
||||
|
||||
@@ -617,10 +614,10 @@ $$
|
||||
|
||||
## 14.1.2 方法二:记忆化搜索
|
||||
|
||||
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。为此,我们声明一个数组 `mem` 来记录每个子问题的解,并在搜索过程中这样做:
|
||||
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。为此,我们声明一个数组 `mem` 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。
|
||||
|
||||
1. 当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ,以便之后使用。
|
||||
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而将重叠子问题剪枝。
|
||||
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而避免重复计算该子问题。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -1163,10 +1160,10 @@ $$
|
||||
|
||||
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。
|
||||
|
||||
总结以上,动态规划的常用术语包括:
|
||||
根据以上内容,我们可以总结出动态规划的常用术语。
|
||||
|
||||
- 将数组 `dp` 称为「$dp$ 表」,$dp[i]$ 表示状态 $i$ 对应子问题的解。
|
||||
- 将最小子问题对应的状态(即第 $1$ , $2$ 阶楼梯)称为「初始状态」。
|
||||
- 将最小子问题对应的状态(即第 $1$ 和 $2$ 阶楼梯)称为「初始状态」。
|
||||
- 将递推公式 $dp[i] = dp[i-1] + dp[i-2]$ 称为「状态转移方程」。
|
||||
|
||||
## 14.1.4 空间优化
|
||||
|
||||
@@ -11,7 +11,7 @@ status: new
|
||||
|
||||
!!! question
|
||||
|
||||
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$ 、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
|
||||
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
|
||||
|
||||
观察图 14-17 ,由于物品编号 $i$ 从 $1$ 开始计数,数组索引从 $0$ 开始计数,因此物品 $i$ 对应重量 $wgt[i-1]$ 和价值 $val[i-1]$ 。
|
||||
|
||||
@@ -56,7 +56,7 @@ $$
|
||||
|
||||
### 1. 方法一:暴力搜索
|
||||
|
||||
搜索代码包含以下要素:
|
||||
搜索代码包含以下要素。
|
||||
|
||||
- **递归参数**:状态 $[i, c]$ 。
|
||||
- **返回值**:子问题的解 $dp[i, c]$ 。
|
||||
|
||||
@@ -11,7 +11,7 @@ status: new
|
||||
|
||||
!!! question
|
||||
|
||||
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$ 、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在不超过背包容量下能放入物品的最大价值。
|
||||
给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在不超过背包容量下能放入物品的最大价值。
|
||||
|
||||

|
||||
|
||||
@@ -24,7 +24,7 @@ status: new
|
||||
- 在 0-1 背包中,每个物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。
|
||||
- 在完全背包中,每个物品有无数个,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**。
|
||||
|
||||
这就导致了状态转移的变化,对于状态 $[i, c]$ 有:
|
||||
在完全背包的规定下,状态 $[i, c]$ 的变化分为两种情况。
|
||||
|
||||
- **不放入物品 $i$** :与 0-1 背包相同,转移至 $[i-1, c]$ 。
|
||||
- **放入物品 $i$** :与 0-1 背包不同,转移至 $[i, c-wgt[i-1]]$ 。
|
||||
@@ -545,7 +545,7 @@ $$
|
||||
|
||||
### 1. 动态规划思路
|
||||
|
||||
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点:
|
||||
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点。
|
||||
|
||||
- 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”。
|
||||
- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
|
||||
@@ -559,7 +559,7 @@ $$
|
||||
|
||||
**第二步:找出最优子结构,进而推导出状态转移方程**
|
||||
|
||||
与完全背包的状态转移方程基本相同,不同点在于:
|
||||
本题与完全背包的状态转移方程存在以下两个差异。
|
||||
|
||||
- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。
|
||||
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。
|
||||
|
||||
Reference in New Issue
Block a user