mirror of
https://github.com/krahets/hello-algo.git
synced 2026-04-14 02:10:37 +08:00
Replace ":" with "。"
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 动态规划问题特性
|
||||
|
||||
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同:
|
||||
在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同。
|
||||
|
||||
- 分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
|
||||
- 动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||

|
||||
|
||||
设 $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]
|
||||
@@ -204,7 +204,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$ 阶。
|
||||
@@ -294,7 +294,7 @@ $$
|
||||
[class]{}-[func]{climbing_stairs_constraint_dp}
|
||||
```
|
||||
|
||||
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题恢复无后效性。然而,许多问题具有非常严重的“有后效性”,例如:
|
||||
在上面的案例中,由于仅需多考虑前面一个状态,我们仍然可以通过扩展状态定义,使得问题重新满足无后效性。然而,某些问题具有非常严重的“有后效性”。
|
||||
|
||||
!!! question "爬楼梯与障碍生成"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 动态规划解题思路
|
||||
|
||||
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题:
|
||||
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题。
|
||||
|
||||
1. 如何判断一个问题是不是动态规划问题?
|
||||
2. 求解动态规划问题该从何处入手,完整步骤是什么?
|
||||
@@ -13,12 +13,12 @@
|
||||
|
||||
换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。
|
||||
|
||||
在此基础上,还有一些动态规划问题的“加分项”,包括:
|
||||
在此基础上,动态规划问题还有一些判断的“加分项”。
|
||||
|
||||
- 问题包含最大(小)或最多(少)等最优化描述。
|
||||
- 问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。
|
||||
|
||||
而相应的“减分项”包括:
|
||||
相应地,也存在一些“减分项”。
|
||||
|
||||
- 问题的目标是找出所有可能的解决方案,而不是找出最优解。
|
||||
- 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。
|
||||
@@ -91,7 +91,7 @@ $$
|
||||
|
||||
### 方法一:暴力搜索
|
||||
|
||||
从状态 $[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]$ 。
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
每一轮的决策是对字符串 $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$ 进行一次编辑(插入、删除、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
**第二步:找出最优子结构,进而推导出状态转移方程**
|
||||
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为下图所示的三种情况:
|
||||
考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为下图所示的三种情况。
|
||||
|
||||
1. 在 $s[i-1]$ 之后添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$ 。
|
||||
2. 删除 $s[i-1]$ ,则剩余子问题 $dp[i-1, j]$ 。
|
||||
|
||||
@@ -132,10 +132,7 @@ $$
|
||||
|
||||

|
||||
|
||||
我们可以根据递推公式得到暴力搜索解法:
|
||||
|
||||
- 以 $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$ 种方案。
|
||||
|
||||
观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁。
|
||||
|
||||
@@ -245,10 +242,10 @@ $$
|
||||
|
||||
## 方法二:记忆化搜索
|
||||
|
||||
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。为此,我们声明一个数组 `mem` 来记录每个子问题的解,并在搜索过程中这样做:
|
||||
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。为此,我们声明一个数组 `mem` 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝。
|
||||
|
||||
1. 当首次计算 $dp[i]$ 时,我们将其记录至 `mem[i]` ,以便之后使用。
|
||||
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而将重叠子问题剪枝。
|
||||
2. 当再次需要计算 $dp[i]$ 时,我们便可直接从 `mem[i]` 中获取结果,从而避免重复计算该子问题。
|
||||
|
||||
=== "Java"
|
||||
|
||||
@@ -436,7 +433,7 @@ $$
|
||||
|
||||
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 $i$ 。
|
||||
|
||||
总结以上,动态规划的常用术语包括:
|
||||
根据以上内容,我们可以总结出动态规划的常用术语。
|
||||
|
||||
- 将数组 `dp` 称为「$dp$ 表」,$dp[i]$ 表示状态 $i$ 对应子问题的解。
|
||||
- 将最小子问题对应的状态(即第 $1$ , $2$ 阶楼梯)称为「初始状态」。
|
||||
|
||||
@@ -49,7 +49,7 @@ $$
|
||||
|
||||
### 方法一:暴力搜索
|
||||
|
||||
搜索代码包含以下要素:
|
||||
搜索代码包含以下要素。
|
||||
|
||||
- **递归参数**:状态 $[i, c]$ 。
|
||||
- **返回值**:子问题的解 $dp[i, c]$ 。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 在 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]]$ 。
|
||||
@@ -214,7 +214,7 @@ $$
|
||||
|
||||
### 动态规划思路
|
||||
|
||||
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点:
|
||||
**零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点。
|
||||
|
||||
- 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”。
|
||||
- 优化目标相反,背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
|
||||
@@ -228,7 +228,7 @@ $$
|
||||
|
||||
**第二步:找出最优子结构,进而推导出状态转移方程**
|
||||
|
||||
与完全背包的状态转移方程基本相同,不同点在于:
|
||||
本题与完全背包的状态转移方程存在以下两个差异。
|
||||
|
||||
- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。
|
||||
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。
|
||||
|
||||
Reference in New Issue
Block a user