diff --git a/chapter_dynamic_programming/dp_problem_features.md b/chapter_dynamic_programming/dp_problem_features.md index ed19db5a0..45cf52b83 100644 --- a/chapter_dynamic_programming/dp_problem_features.md +++ b/chapter_dynamic_programming/dp_problem_features.md @@ -28,11 +28,9 @@ $$ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i] $$ -这便可以引出「最优子结构」的含义:**原问题的最优解是从子问题的最优解构建得来的**。对于本题,我们从两个子问题最优解 $dp[i-1]$ , $dp[i-2]$ 中挑选出较优的那一个,并用它构建出原问题 $dp[i]$ 的最优解。 +这便可以引出「最优子结构」的含义:**原问题的最优解是从子问题的最优解构建得来的**。本题显然具有最优子结构:我们从两个子问题最优解 $dp[i-1]$ , $dp[i-2]$ 中挑选出较优的那一个,并用它构建出原问题 $dp[i]$ 的最优解。 -相较于分治问题,动态规划问题的解也是由其子问题的解构成的。不同的是,**动态规划中子问题的解不仅揭示了问题的局部最优解,而且还通过特定的递推关系链接起来,共同构建出原问题的全局最优解**。 - -那么,上节的爬楼梯题目有没有最优子结构呢?它要求解的是方案数量,看似是一个计数问题,但如果换一种问法:求解最大方案数量。我们意外地发现,**虽然题目修改前后是等价的,但最优子结构浮现出来了**:第 $n$ 阶最大方案数量等于第 $n-1$ 阶和第 $n-2$ 阶最大方案数量之和。所以说,最优子结构的是一个比较宽泛的概念,在不同问题中会有不同的含义。 +那么,上节的爬楼梯题目有没有最优子结构呢?它要求解的是方案数量,看似是一个计数问题,但如果换一种问法:求解最大方案数量。我们意外地发现,**虽然题目修改前后是等价的,但最优子结构浮现出来了**:第 $n$ 阶最大方案数量等于第 $n-1$ 阶和第 $n-2$ 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。 根据以上状态转移方程,以及初始状态 $dp[1] = cost[1]$ , $dp[2] = cost[2]$ ,我们可以得出动态规划解题代码。 @@ -458,4 +456,4 @@ $$ 在这个问题中,下次跳跃依赖于过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决,或是因为计算复杂度过高而难以应用。 -实际上,许多组合优化问题(例如著名的旅行商问题)都不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而降低时间复杂度,在有限时间内得到能够接受的局部最优解。 +实际上,许多复杂的组合优化问题(例如著名的旅行商问题)都不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而降低时间复杂度,在有限时间内得到能够接受的局部最优解。 diff --git a/chapter_dynamic_programming/edit_distance_problem.md b/chapter_dynamic_programming/edit_distance_problem.md index 610481ee8..5ee238e9a 100644 --- a/chapter_dynamic_programming/edit_distance_problem.md +++ b/chapter_dynamic_programming/edit_distance_problem.md @@ -35,7 +35,7 @@ comments: true - 若 $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$ 个字符所需的最少编辑步数**。 @@ -45,7 +45,7 @@ comments: true 考虑子问题 $dp[i, j]$ ,其对应的两个字符串的尾部字符为 $s[i-1]$ 和 $t[j-1]$ ,可根据不同编辑操作分为三种情况: -1. 在 $s$ 尾部添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$ ; +1. 在 $s[i-1]$ 之后添加 $t[j-1]$ ,则剩余子问题 $dp[i, j-1]$ ; 2. 删除 $s[i-1]$ ,则剩余子问题 $dp[i-1, j]$ ; 3. 将 $s[i-1]$ 替换为 $t[j-1]$ ,则剩余子问题 $dp[i-1, j-1]$ ; diff --git a/chapter_dynamic_programming/intro_to_dynamic_programming.md b/chapter_dynamic_programming/intro_to_dynamic_programming.md index 8285e77a2..2eaba21b2 100644 --- a/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -212,7 +212,9 @@ $$ 也就是说,在爬楼梯问题中,**各个子问题之间不是相互独立的,原问题的解可以由子问题的解构成**。 -我们可以基于此递推公式写出暴力搜索代码:以 $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$ 种方案。 观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁。 @@ -774,7 +776,7 @@ $$ **我们将这种空间优化技巧称为「状态压缩」**。在许多动态规划问题中,当前状态仅与前面有限个状态有关,不必保存所有的历史状态,这时我们可以应用状态压缩,只保留必要的状态,通过“降维”来节省内存空间。 -总的看来,子问题分解是一种通用的算法思路,在分治算法、动态规划、回溯算法中各有特点: +总的看来,**子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点**: - 分治算法将原问题划分为几个独立的子问题,然后递归解决子问题,最后合并子问题的解得到原问题的解。例如,归并排序将长数组不断划分为两个短子数组,再将排序好的子数组合并为排序好的长数组。 - 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,**动态规划中的子问题往往不是相互独立的**,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。因此,动态规划通常会引入记忆化,保存已经解决的子问题的解,避免重复计算。 diff --git a/chapter_dynamic_programming/knapsack_problem.md b/chapter_dynamic_programming/knapsack_problem.md index 0c7bbc22e..848d5847a 100644 --- a/chapter_dynamic_programming/knapsack_problem.md +++ b/chapter_dynamic_programming/knapsack_problem.md @@ -532,7 +532,7 @@ $$ **最后考虑状态压缩**。以上代码中的数组 `dp` 占用 $O(n \times cap)$ 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。代码省略,有兴趣的同学可以自行实现。 -那么,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由左上方或正上方的格子转移过来的。假设只有一个数组,当遍历到第 $i$ 行时,该数组存储的仍然是第 $i-1$ 行的状态,**为了避免左方区域的格子在状态转移中被覆盖,应该采取倒序遍历**。 +那么,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当遍历到第 $i$ 行时,该数组存储的仍然是第 $i-1$ 行的状态,**为了避免左方区域的格子在状态转移中被覆盖,应该采取倒序遍历**。 以下动画展示了在单个数组下从第 $i=1$ 行转换至第 $i=2$ 行的过程。建议你思考一下正序遍历和倒序遍历的区别。 diff --git a/chapter_dynamic_programming/summary.md b/chapter_dynamic_programming/summary.md new file mode 100644 index 000000000..e39435436 --- /dev/null +++ b/chapter_dynamic_programming/summary.md @@ -0,0 +1,26 @@ +--- +comments: true +--- + +# 13.7.   小结 + +- 动态规划通过将原问题分解为子问题来求解问题,并通过存储子问题的解来规避重复计算,实现高效的计算效率。 +- 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。 +- 通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。 +- 记忆化递归是一种从顶至底的递归式解法,与之对应的动态规划是一种从底至顶的递推式解法。 +- 由于当前状态仅依赖于某些局部状态,因此我们可以对 $dp$ 表的维度进行压缩,从而降低空间复杂度。 +- 子问题分解是一种通用的算法思路,在分治、动态规划、回溯中具有不同的性质。 +- 动态规划问题的三大特性:重叠子问题、最优子结构、无后效性。 +- 如果原问题的最优解可以从子问题的最优解构建得来,则此问题就具有最优子结构。 +- 无后效性指对于一个状态,其未来发展只与该状态有关,与其所经历的过去的所有状态无关。许多组合优化问题都不具有无后效性,无法使用动态规划快速求解。 +- 背包问题是最典型的动态规划题目,具有 0-1 背包、完全背包、多重背包等变种问题。 +- 0-1 背包的状态定义为前 $i$ 个物品在剩余容量为 $c$ 的背包中的最大价值。这是一种常见的定义方式。 +- 在 0-1 背包中,不放入物品 $i$ ,状态转移至 $[i-1, c]$ ,放入则转移至 $[i-1, c-wgt[i-1]]$ 。由此便得到最优子结构,并构建出状态转移方程。 +- 在 0-1 背包中,由于每个状态依赖正上方和左上方的状态,因此状态压缩后需要倒序遍历列表,避免左上方状态被覆盖。 +- 完全背包的每种物品有无数个,因此在放置物品 $i$ 后,状态转移至 $[i, c-wgt[i-1]]$ 。由于状态依赖于正上方和正左方的状态,因此状态压缩后应该正序遍历。 +- 零钱兑换问题是完全背包的一个变种。为从求“最大“价值变为求“最小”硬币数量,我们将状态转移方程中的 $\max()$ 改为 $\min$ ,为从求“不超过”背包容量到求“恰好”凑出目标金额,我们使用 $amt + 1$ 来表示“无法凑出目标金额”的无效解。 +- 零钱兑换 II 问题从求“最少硬币数量”改为求“硬币组合数量”,状态转移方程相应地从 $\min()$ 改为求和运算符。 +- 编辑距离(Levenshtein 距离)用于衡量两个字符串之间的相似度,定义为从一个字符串到另一个字符串的最小编辑步数,编辑操作包括添加、删除、替换。 +- 编辑距离的状态定义为将 $s$ 的前 $i$ 个字符更改为 $t$ 的前 $j$ 个字符所需的最少编辑步数。 +- 对于字符 $s[i]$ 和 $t[j]$ ,可以在 $s[i-1]$ 之后添加 $t[j-1]$ 、删除 $s[i-1]$ 、将 $s[i-1]$ 替换为 $t[j-1]$ ,三种操作都有相应的剩余子问题。据此,我们就可以找出最优子结构与构建状态转移方程。值得注意的是,当 $s[i] = t[j]$ 时,无需编辑当前字符,直接跳过即可。 +- 在编辑距离中,状态依赖于其正上方、正左方、左上方的状态,因此状态压缩后正序或倒序遍历都无法正确地进行状态转移。利用一个变量暂存左上方状态,即转化至完全背包地情况,可以在状态压缩后使用正序遍历。 diff --git a/chapter_dynamic_programming/unbounded_knapsack_problem.md b/chapter_dynamic_programming/unbounded_knapsack_problem.md index fb29b7cce..73b5ca822 100644 --- a/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -129,7 +129,25 @@ $$ === "C#" ```csharp title="unbounded_knapsack.cs" - [class]{unbounded_knapsack}-[func]{unboundedKnapsackDP} + /* 完全背包:动态规划 */ + int unboundedKnapsackDP(int[] wgt, int[] val, int cap) { + int n = wgt.Length; + // 初始化 dp 表 + int[,] dp = new int[n + 1, cap + 1]; + // 状态转移 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 若超过背包容量,则不选物品 i + dp[i, c] = dp[i - 1, c]; + } else { + // 不选和选物品 i 这两种方案的较大值 + dp[i, c] = Math.Max(dp[i - 1, c], dp[i, c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[n, cap]; + } ``` === "Swift" @@ -268,7 +286,25 @@ $$ === "C#" ```csharp title="unbounded_knapsack.cs" - [class]{unbounded_knapsack}-[func]{unboundedKnapsackDPComp} + /* 完全背包:状态压缩后的动态规划 */ + int unboundedKnapsackDPComp(int[] wgt, int[] val, int cap) { + int n = wgt.Length; + // 初始化 dp 表 + int[] dp = new int[cap + 1]; + // 状态转移 + for (int i = 1; i <= n; i++) { + for (int c = 1; c <= cap; c++) { + if (wgt[i - 1] > c) { + // 若超过背包容量,则不选物品 i + dp[c] = dp[c]; + } else { + // 不选和选物品 i 这两种方案的较大值 + dp[c] = Math.Max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]); + } + } + } + return dp[cap]; + } ``` === "Swift" @@ -445,7 +481,30 @@ $$ === "C#" ```csharp title="coin_change.cs" - [class]{coin_change}-[func]{coinChangeDP} + /* 零钱兑换:动态规划 */ + int coinChangeDP(int[] coins, int amt) { + int n = coins.Length; + int MAX = amt + 1; + // 初始化 dp 表 + int[,] dp = new int[n + 1, amt + 1]; + // 状态转移:首行首列 + for (int a = 1; a <= amt; a++) { + dp[0, a] = MAX; + } + // 状态转移:其余行列 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 若超过背包容量,则不选硬币 i + dp[i, a] = dp[i - 1, a]; + } else { + // 不选和选硬币 i 这两种方案的较小值 + dp[i, a] = Math.Min(dp[i - 1, a], dp[i, a - coins[i - 1]] + 1); + } + } + } + return dp[n, amt] != MAX ? dp[n, amt] : -1; + } ``` === "Swift" @@ -618,7 +677,28 @@ $$ === "C#" ```csharp title="coin_change.cs" - [class]{coin_change}-[func]{coinChangeDPComp} + /* 零钱兑换:状态压缩后的动态规划 */ + int coinChangeDPComp(int[] coins, int amt) { + int n = coins.Length; + int MAX = amt + 1; + // 初始化 dp 表 + int[] dp = new int[amt + 1]; + Array.Fill(dp, MAX); + dp[0] = 0; + // 状态转移 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 若超过背包容量,则不选硬币 i + dp[a] = dp[a]; + } else { + // 不选和选硬币 i 这两种方案的较小值 + dp[a] = Math.Min(dp[a], dp[a - coins[i - 1]] + 1); + } + } + } + return dp[amt] != MAX ? dp[amt] : -1; + } ``` === "Swift" @@ -765,7 +845,29 @@ $$ === "C#" ```csharp title="coin_change_ii.cs" - [class]{coin_change_ii}-[func]{coinChangeIIDP} + /* 零钱兑换 II:动态规划 */ + int coinChangeIIDP(int[] coins, int amt) { + int n = coins.Length; + // 初始化 dp 表 + int[,] dp = new int[n + 1, amt + 1]; + // 初始化首列 + for (int i = 0; i <= n; i++) { + dp[i, 0] = 1; + } + // 状态转移 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 若超过背包容量,则不选硬币 i + dp[i, a] = dp[i - 1, a]; + } else { + // 不选和选硬币 i 这两种方案之和 + dp[i, a] = dp[i - 1, a] + dp[i, a - coins[i - 1]]; + } + } + } + return dp[n, amt]; + } ``` === "Swift" @@ -887,7 +989,26 @@ $$ === "C#" ```csharp title="coin_change_ii.cs" - [class]{coin_change_ii}-[func]{coinChangeIIDPComp} + /* 零钱兑换 II:状态压缩后的动态规划 */ + int coinChangeIIDPComp(int[] coins, int amt) { + int n = coins.Length; + // 初始化 dp 表 + int[] dp = new int[amt + 1]; + dp[0] = 1; + // 状态转移 + for (int i = 1; i <= n; i++) { + for (int a = 1; a <= amt; a++) { + if (coins[i - 1] > a) { + // 若超过背包容量,则不选硬币 i + dp[a] = dp[a]; + } else { + // 不选和选硬币 i 这两种方案之和 + dp[a] = dp[a] + dp[a - coins[i - 1]]; + } + } + } + return dp[amt]; + } ``` === "Swift"