This commit is contained in:
krahets
2023-07-16 04:18:52 +08:00
parent c342ba3ced
commit edcd1e5c10
21 changed files with 475 additions and 67 deletions

View File

@@ -2,13 +2,13 @@
comments: true
---
# 13.2.   动态规划问题特性
# 14.2.   动态规划问题特性
在上节中,我们学习了动态规划问题的暴力解法,从递归树中观察到海量的重叠子问题,以及了解到动态规划是如何通过记录解来优化时间复杂度的。
实际上,动态规划最常用来求解最优方案问题,例如寻找最短路径、最大利润、最少时间等。**这类问题不仅包含重叠子问题,往往还具有另外两大特性:最优子结构、无后效性**。
## 13.2.1.   最优子结构
## 14.2.1.   最优子结构
我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念。
@@ -273,7 +273,7 @@ $$
[class]{}-[func]{minCostClimbingStairsDPComp}
```
## 13.2.2.   无后效性
## 14.2.2.   无后效性
「无后效性」是动态规划能够有效解决问题的重要特性之一,定义为:**给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关**。

View File

@@ -2,14 +2,14 @@
comments: true
---
# 13.3.   动态规划解题思路
# 14.3.   动态规划解题思路
上两节介绍了动态规划问题的主要特征,接下来我们一起探究两个更加实用的问题:
1. 如何判断一个问题是不是动态规划问题?
2. 求解动态规划问题该从何处入手,完整步骤是什么?
## 13.3.1.   问题判断
## 14.3.1.   问题判断
总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解,但我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,**先观察问题是否适合使用回溯(穷举)解决**。
@@ -29,7 +29,7 @@ comments: true
如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并尝试求解它。
## 13.3.2.   问题求解
## 14.3.2.   问题求解
动态规划的解题流程可能会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 $dp$ 表,推导状态转移方程,确定边界条件等。
@@ -99,7 +99,7 @@ $$
接下来,我们就可以实现动态规划代码了。然而,由于子问题分解是一种从顶至底的思想,因此按照“暴力搜索 $\rightarrow$ 记忆化搜索 $\rightarrow$ 动态规划”的顺序实现更加符合思维习惯。
## 13.3.3.   方法一:暴力搜索
## 14.3.3.   方法一:暴力搜索
从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,包括以下递归要素:
@@ -240,7 +240,7 @@ $$
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
## 13.3.4.   方法二:记忆化搜索
## 14.3.4.   方法二:记忆化搜索
为了避免重复计算重叠子问题,我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,提升搜索效率。
@@ -394,7 +394,7 @@ $$
<p align="center"> Fig. 记忆化搜索递归树 </p>
## 13.3.5. &nbsp; 方法三:动态规划
## 14.3.5. &nbsp; 方法三:动态规划
动态规划代码是从底至顶的,仅需循环即可实现。

View File

@@ -2,7 +2,7 @@
comments: true
---
# 13.6. &nbsp; 编辑距离问题
# 14.6. &nbsp; 编辑距离问题
编辑距离,也被称为 Levenshtein 距离,是两个字符串之间互相转换的最小修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
@@ -182,7 +182,31 @@ $$
=== "C#"
```csharp title="edit_distance.cs"
[class]{edit_distance}-[func]{editDistanceDP}
/* 编辑距离:动态规划 */
int editDistanceDP(string s, string t) {
int n = s.Length, m = t.Length;
int[,] dp = new int[n + 1, m + 1];
// 状态转移:首行首列
for (int i = 1; i <= n; i++) {
dp[i, 0] = i;
}
for (int j = 1; j <= m; j++) {
dp[0, j] = j;
}
// 状态转移:其余行列
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s[i - 1] == t[j - 1]) {
// 若两字符相等,则直接跳过此两字符
dp[i, j] = dp[i - 1, j - 1];
} else {
// 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
dp[i, j] = Math.Min(Math.Min(dp[i, j - 1], dp[i - 1, j]), dp[i - 1, j - 1]) + 1;
}
}
}
return dp[n, m];
}
```
=== "Swift"
@@ -375,7 +399,34 @@ $$
=== "C#"
```csharp title="edit_distance.cs"
[class]{edit_distance}-[func]{editDistanceDPComp}
/* 编辑距离:状态压缩后的动态规划 */
int editDistanceDPComp(string s, string t) {
int n = s.Length, m = t.Length;
int[] dp = new int[m + 1];
// 状态转移:首行
for (int j = 1; j <= m; j++) {
dp[j] = j;
}
// 状态转移:其余行
for (int i = 1; i <= n; i++) {
// 状态转移:首列
int leftup = dp[0]; // 暂存 dp[i-1, j-1]
dp[0] = i;
// 状态转移:其余列
for (int j = 1; j <= m; j++) {
int temp = dp[j];
if (s[i - 1] == t[j - 1]) {
// 若两字符相等,则直接跳过此两字符
dp[j] = leftup;
} else {
// 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
dp[j] = Math.Min(Math.Min(dp[j - 1], dp[j]), leftup) + 1;
}
leftup = temp; // 更新为下一轮的 dp[i-1, j-1]
}
}
return dp[m];
}
```
=== "Swift"

View File

@@ -2,7 +2,7 @@
comments: true
---
# 13. &nbsp; 动态规划
# 14. &nbsp; 动态规划
<div class="center-table" markdown>

View File

@@ -2,7 +2,7 @@
comments: true
---
# 13.1. &nbsp; 初探动态规划
# 14.1. &nbsp; 初探动态规划
「动态规划 Dynamic Programming」是一种通过将复杂问题分解为更简单的子问题的方式来求解问题的方法。它将一个问题分解为一系列更小的子问题并通过存储子问题的解来避免重复计算从而大幅提升时间效率。
@@ -177,7 +177,23 @@ comments: true
=== "Zig"
```zig title="climbing_stairs_backtrack.zig"
[class]{}-[func]{backtrack}
// 回溯
fn backtrack(choices: []i32, state: i32, n: i32, res: std.ArrayList(i32)) void {
// 当爬到第 n 阶时,方案数量加 1
if (state == n) {
res.items[0] = res.items[0] + 1;
}
// 遍历所有选择
for (choices) |choice| {
// 剪枝:不允许越过第 n 阶
if (state + choice > n) {
break;
}
// 尝试:做出选择,更新状态
backtrack(choices, state + choice, n, res);
// 回退
}
}
[class]{}-[func]{climbingStairsBacktrack}
```
@@ -190,7 +206,7 @@ comments: true
[class]{}-[func]{climbingStairsBacktrack}
```
## 13.1.1. &nbsp; 方法一:暴力搜索
## 14.1.1. &nbsp; 方法一:暴力搜索
回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
@@ -335,7 +351,16 @@ $$
=== "Zig"
```zig title="climbing_stairs_dfs.zig"
[class]{}-[func]{dfs}
// 搜索
fn dfs(i: usize) i32 {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 or i == 2) {
return @intCast(i);
}
// dp[i] = dp[i-1] + dp[i-2]
var count = dfs(i - 1) + dfs(i - 2);
return count;
}
[class]{}-[func]{climbingStairsDFS}
```
@@ -356,7 +381,7 @@ $$
实际上,**指数阶的时间复杂度是由于「重叠子问题」导致的**。例如,问题 $dp[9]$ 被分解为子问题 $dp[8]$ 和 $dp[7]$ ,问题 $dp[8]$ 被分解为子问题 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ ,而子问题中又包含更小的重叠子问题,子子孙孙无穷尽也,绝大部分计算资源都浪费在这些重叠的问题上。
## 13.1.2. &nbsp; 方法二:记忆化搜索
## 14.1.2. &nbsp; 方法二:记忆化搜索
为了提升算法效率,**我们希望所有的重叠子问题都只被计算一次**。具体来说,考虑借助一个数组 `mem` 来记录每个子问题的解,并在搜索过程中这样做:
@@ -510,7 +535,22 @@ $$
=== "Zig"
```zig title="climbing_stairs_dfs_mem.zig"
[class]{}-[func]{dfs}
// 记忆化搜索
fn dfs(i: usize, mem: []i32) i32 {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 or i == 2) {
return @intCast(i);
}
// 若存在记录 dp[i] ,则直接返回之
if (mem[i] != -1) {
return mem[i];
}
// dp[i] = dp[i-1] + dp[i-2]
var count = dfs(i - 1, mem) + dfs(i - 2, mem);
// 记录 dp[i]
mem[i] = count;
return count;
}
[class]{}-[func]{climbingStairsDFSMem}
```
@@ -529,7 +569,7 @@ $$
<p align="center"> Fig. 记忆化搜索对应递归树 </p>
## 13.1.3. &nbsp; 方法三:动态规划
## 14.1.3. &nbsp; 方法三:动态规划
**记忆化搜索是一种“从顶至底”的方法**:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点);最终通过回溯将子问题的解逐层收集,得到原问题的解。
@@ -779,5 +819,5 @@ $$
总的看来,**子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点**
- 分治算法将原问题划分为几个独立的子问题,然后递归解决子问题,最后合并子问题的解得到原问题的解。例如,归并排序将长数组不断划分为两个短子数组,再将排序好的子数组合并为排序好的长数组。
- 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,**动态规划中的子问题往往不是相互独立的**,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。因此,动态规划通常会引入记忆化,保存已经解决的子问题的解,避免重复计算。
- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之后的剩余问题看作为一个子问题。
- 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,**动态规划中的子问题往往不是相互独立的**,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。
- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。

View File

@@ -2,7 +2,7 @@
comments: true
---
# 13.4. &nbsp; 0-1 背包问题
# 14.4. &nbsp; 0-1 背包问题
背包问题是一个非常好的动态规划入门题目,是动态规划中最常见的问题形式。其具有很多变种,例如 0-1 背包问题、完全背包问题、多重背包问题等。
@@ -55,7 +55,7 @@ $$
完成以上三步后,我们可以直接实现从底至顶的动态规划解法。而为了展示本题包含的重叠子问题,本文也同时给出从顶至底的暴力搜索和记忆化搜索解法。
## 13.4.1. &nbsp; 方法一:暴力搜索
## 14.4.1. &nbsp; 方法一:暴力搜索
搜索代码包含以下要素:
@@ -194,7 +194,7 @@ $$
<p align="center"> Fig. 0-1 背包的暴力搜索递归树 </p>
## 13.4.2. &nbsp; 方法二:记忆化搜索
## 14.4.2. &nbsp; 方法二:记忆化搜索
为了防止重复求解重叠子问题,我们借助一个记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 对应解 $dp[i, c]$ 。
@@ -348,7 +348,7 @@ $$
<p align="center"> Fig. 0-1 背包的记忆化搜索递归树 </p>
## 13.4.3. &nbsp; 方法三:动态规划
## 14.4.3. &nbsp; 方法三:动态规划
动态规划解法本质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示。

View File

@@ -2,7 +2,7 @@
comments: true
---
# 13.7. &nbsp; 小结
# 14.7. &nbsp; 小结
- 动态规划通过将原问题分解为子问题来求解问题,并通过存储子问题的解来规避重复计算,实现高效的计算效率。子问题分解是一种通用的算法思路,在分治、动态规划、回溯中具有不同的性质。
- 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。

View File

@@ -2,11 +2,11 @@
comments: true
---
# 13.5. &nbsp; 完全背包问题
# 14.5. &nbsp; 完全背包问题
在本节,我们先求解 0-1 背包的一个变种问题:完全背包问题;再了解完全背包的一种特例问题:零钱兑换。
## 13.5.1. &nbsp; 完全背包问题
## 14.5.1. &nbsp; 完全背包问题
!!! question
@@ -325,7 +325,7 @@ $$
[class]{}-[func]{unboundedKnapsackDPComp}
```
## 13.5.2. &nbsp; 零钱兑换问题
## 14.5.2. &nbsp; 零钱兑换问题
背包问题是一大类动态规划问题的代表,其拥有很多的变种,例如零钱兑换问题。
@@ -719,7 +719,7 @@ $$
[class]{}-[func]{coinChangeDPComp}
```
## 13.5.3. &nbsp; 零钱兑换问题 II
## 14.5.3. &nbsp; 零钱兑换问题 II
!!! question