diff --git a/chapter_backtracking/backtracking_algorithm/index.html b/chapter_backtracking/backtracking_algorithm/index.html index 817130577..fc100173f 100644 --- a/chapter_backtracking/backtracking_algorithm/index.html +++ b/chapter_backtracking/backtracking_algorithm/index.html @@ -4414,23 +4414,22 @@ if isSolution(state) { // 记录解 recordSolution(state, res) - return - } - // 遍历所有选择 - for _, choice := range *choices { - // 剪枝:检查选择是否合法 - if isValid(state, choice) { - // 尝试:做出选择,更新状态 - makeChoice(state, choice) - // 进行下一轮选择 - temp := make([]*TreeNode, 0) - temp = append(temp, choice.Left, choice.Right) - backtrackIII(state, &temp, res) - // 回退:撤销选择,恢复到之前的状态 - undoChoice(state, choice) - } - } -} + } + // 遍历所有选择 + for _, choice := range *choices { + // 剪枝:检查选择是否合法 + if isValid(state, choice) { + // 尝试:做出选择,更新状态 + makeChoice(state, choice) + // 进行下一轮选择 + temp := make([]*TreeNode, 0) + temp = append(temp, choice.Left, choice.Right) + backtrackIII(state, &temp, res) + // 回退:撤销选择,恢复到之前的状态 + undoChoice(state, choice) + } + } +}
在上节中,我们学习了动态规划问题的暴力解法,从递归树中观察到海量的重叠子问题,以及了解到动态规划是如何通过记录解来优化时间复杂度的。
-总的看来,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点:
+在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点:
实际上,动态规划最常用来求解最优化问题。这类问题不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
+实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念。
这便可以引出「最优子结构」的含义:原问题的最优解是从子问题的最优解构建得来的。本题显然具有最优子结构:我们从两个子问题最优解 \(dp[i-1]\) , \(dp[i-2]\) 中挑选出较优的那一个,并用它构建出原问题 \(dp[i]\) 的最优解。
-那么,上节的爬楼梯题目有没有最优子结构呢?它要求解的是方案数量,看似是一个计数问题,但如果换一种问法:求解最大方案数量。我们意外地发现,虽然题目修改前后是等价的,但最优子结构浮现出来了:第 \(n\) 阶最大方案数量等于第 \(n-1\) 阶和第 \(n-2\) 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
-根据以上状态转移方程,以及初始状态 \(dp[1] = cost[1]\) , \(dp[2] = cost[2]\) ,我们可以得出动态规划解题代码。
+这便可以引出「最优子结构」的含义:原问题的最优解是从子问题的最优解构建得来的。
+本题显然具有最优子结构:我们从两个子问题最优解 \(dp[i-1]\) , \(dp[i-2]\) 中挑选出较优的那一个,并用它构建出原问题 \(dp[i]\) 的最优解。
+那么,上节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,虽然题目修改前后是等价的,但最优子结构浮现出来了:第 \(n\) 阶最大方案数量等于第 \(n-1\) 阶和第 \(n-2\) 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。
+根据状态转移方程,以及初始状态 \(dp[1] = cost[1]\) , \(dp[2] = cost[2]\) ,可以得出动态规划代码。
Fig. 爬楼梯最小代价的动态规划过程
-这道题同样也可以进行状态压缩,将一维压缩至零维,使得空间复杂度从 \(O(n)\) 降低至 \(O(1)\) 。
+本题也可以进行状态压缩,将一维压缩至零维,使得空间复杂度从 \(O(n)\) 降低至 \(O(1)\) 。
「无后效性」是动态规划能够有效解决问题的重要特性之一,定义为:给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关。
-以爬楼梯问题为例,给定状态 \(i\) ,它会发展出状态 \(i+1\) 和状态 \(i+2\) ,分别对应跳 \(1\) 步和跳 \(2\) 步。在做出这两种选择时,我们无需考虑状态 \(i\) 之前的状态,即它们对状态 \(i\) 的未来没有影响。
+以爬楼梯问题为例,给定状态 \(i\) ,它会发展出状态 \(i+1\) 和状态 \(i+2\) ,分别对应跳 \(1\) 步和跳 \(2\) 步。在做出这两种选择时,我们无需考虑状态 \(i\) 之前的状态,它们对状态 \(i\) 的未来没有影响。
然而,如果我们向爬楼梯问题添加一个约束,情况就不一样了。
带约束爬楼梯
@@ -3709,14 +3709,14 @@ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]Fig. 带约束爬到第 3 阶的方案数量
-在该问题中,下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关。如果上一轮是跳 \(1\) 阶上来的,那么下一轮就必须跳 \(2\) 阶。
-不难发现,此问题已不满足无后效性,状态转移方程 \(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\) 阶,我们可以据此来决定下一步该怎么跳:
+在该问题中,如果上一轮是跳 \(1\) 阶上来的,那么下一轮就必须跳 \(2\) 阶。这意味着,下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关。
+不难发现,此问题已不满足无后效性,状态转移方程 \(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\) 阶,我们可以据此来决定下一步该怎么跳:
在该定义下,\(dp[i, j]\) 表示状态 \([i, j]\) 对应的方案数。由此,我们便能推导出以下的状态转移方程:
+在该定义下,\(dp[i, j]\) 表示状态 \([i, j]\) 对应的方案数。在该定义下的状态转移方程为:
爬楼梯与障碍生成
给定一个共有 \(n\) 阶的楼梯,你每步可以上 \(1\) 阶或者 \(2\) 阶。规定当爬到第 \(i\) 阶时,系统自动会给第 \(2i\) 阶上放上障碍物,之后所有轮都不允许跳到第 \(2i\) 阶上。例如,前两轮分别跳到了第 \(2, 3\) 阶上,则之后就不能跳到第 \(4, 6\) 阶上。请问有多少种方案可以爬到楼顶。
在这个问题中,下次跳跃依赖于过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决,或是因为计算复杂度过高而难以应用。
-实际上,许多复杂的组合优化问题(例如著名的旅行商问题)都不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而降低时间复杂度,在有限时间内得到能够接受的局部最优解。
+在这个问题中,下次跳跃依赖于过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍,并影响未来的跳跃。对于这类问题,动态规划往往难以解决。
+实际上,许多复杂的组合优化问题(例如旅行商问题)都不满足无后效性。对于这类问题,我们通常会选择使用其他方法,例如启发式搜索、遗传算法、强化学习等,从而在有限时间内得到可用的局部最优解。
diff --git a/chapter_dynamic_programming/dp_solution_pipeline/index.html b/chapter_dynamic_programming/dp_solution_pipeline/index.html index 6a54ac264..061aea2a5 100644 --- a/chapter_dynamic_programming/dp_solution_pipeline/index.html +++ b/chapter_dynamic_programming/dp_solution_pipeline/index.html @@ -3456,22 +3456,22 @@总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解,但我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。
+总的来说,如果一个问题包含重叠子问题、最优子结构,并满足无后效性,那么它通常就适合用动态规划求解。然而,我们很难从问题描述上直接提取出这些特性。因此我们通常会放宽条件,先观察问题是否适合使用回溯(穷举)解决。
适合用回溯解决的问题通常满足“决策树模型”,这种问题可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列。
换句话说,如果问题包含明确的决策概念,并且解是通过一系列决策产生的,那么它就满足决策树模型,通常可以使用回溯来解决。
-在此基础上,还有一些判断问题是动态规划问题的“加分项”,包括:
+在此基础上,还有一些动态规划问题的“加分项”,包括:
而相应的“减分项”包括:
如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并尝试求解它。
+如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。
动态规划的解题流程可能会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 \(dp\) 表,推导状态转移方程,确定边界条件等。
+动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 \(dp\) 表,推导状态转移方程,确定边界条件等。
为了更形象地展示解题步骤,我们使用一个经典问题「最小路径和」来举例。
Question
@@ -3490,8 +3490,8 @@Note
-动态规划和回溯通常都会被描述为一个决策序列,而状态通常由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
-每个状态都对应一个子问题,我们会定义一个 \(dp\) 表来存储所有子问题的解,状态的每个独立变量都是 \(dp\) 表的一个维度。本质上看,\(dp\) 表是子问题的解和状态之间的映射。
+动态规划和回溯过程可以被描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。
+每个状态都对应一个子问题,我们会定义一个 \(dp\) 表来存储所有子问题的解,状态的每个独立变量都是 \(dp\) 表的一个维度。本质上看,\(dp\) 表是状态和子问题的解之间的映射。
第二步:找出最优子结构,进而推导出状态转移方程
对于状态 \([i, j]\) ,它只能从上边格子 \([i-1, j]\) 和左边格子 \([i, j-1]\) 转移而来。因此最优子结构为:到达 \([i, j]\) 的最小路径和由 \([i, j-1]\) 的最小路径和与 \([i-1, j]\) 的最小路径和,这两者较小的那一个决定。
@@ -3504,24 +3504,26 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]Note
-基于定义好的 \(dp\) 表,我们思考原问题和子问题的关系,找出如何通过子问题的解来构造原问题的解。
-最优子结构揭示了原问题和子问题的递推关系,一旦我们找到了最优子结构,就可以使用它来构建出状态转移方程。
+根据定义好的 \(dp\) 表,思考原问题和子问题的关系,找出通过子问题的最优解来构造原问题的最优解的方法,即最优子结构。
+一旦我们找到了最优子结构,就可以使用它来构建出状态转移方程。
第三步:确定边界条件和状态转移顺序
-在本题中,当 \(i=0\) 或 \(j=0\) 时只有一种可能的路径,即只能向右移动或只能向下移动,因此首行和首列是边界条件。
-每个格子是由其左方格子和上方格子转移而来,因此我们使用两层循环来遍历矩阵即可,即外循环正序遍历各行、内循环正序遍历各列。
+在本题中,处在首行的状态只能向右转移,首列状态只能向下转移,因此首行 \(i = 0\) 和首列 \(j = 0\) 是边界条件。
+每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。
Fig. 边界条件与状态转移顺序
Note
-边界条件即初始状态,在搜索中用于剪枝,在动态规划中用于初始化 \(dp\) 表。状态转移顺序的核心是要保证在计算当前问题时,所有它依赖的更小子问题都已经被正确地计算出来。
+边界条件在动态规划中用于初始化 \(dp\) 表,在搜索中用于剪枝。
+状态转移顺序的核心是要保证在计算当前问题的解时,所有它依赖的更小子问题的解都已经被正确地计算出来。
接下来,我们就可以实现动态规划代码了。然而,由于子问题分解是一种从顶至底的思想,因此按照“暴力搜索 \(\rightarrow\) 记忆化搜索 \(\rightarrow\) 动态规划”的顺序实现更加符合思维习惯。
+根据以上分析,我们已经可以直接写出动态规划代码。然而子问题分解是一种从顶至底的思想,因此按照“暴力搜索 \(\rightarrow\) 记忆化搜索 \(\rightarrow\) 动态规划”的顺序实现更加符合思维习惯。
从状态 \([i, j]\) 开始搜索,不断分解为更小的状态 \([i-1, j]\) 和 \([i, j-1]\) ,包括以下递归要素:
我们尝试画出以 \(dp[2, 1]\) 为根节点的递归树。观察下图,递归树包含一些重叠子问题,其数量会随着网格 grid
的尺寸变大而急剧增多。
直观上看,存在多条路径可以从左上角到达同一单元格,这便是该问题存在重叠子问题的内在原因。
+下图给出了以 \(dp[2, 1]\) 为根节点的递归树,其中包含一些重叠子问题,其数量会随着网格 grid
的尺寸变大而急剧增多。
本质上看,造成重叠子问题的原因为:存在多条路径可以从左上角到达某一单元格。
Fig. 暴力搜索递归树
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 \(m + n - 2\) 步,所以最差时间复杂度为 \(O(2^{m + n})\) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
为了避免重复计算重叠子问题,我们引入一个和网格 grid
相同尺寸的记忆列表 mem
,用于记录各个子问题的解,提升搜索效率。
我们引入一个和网格 grid
相同尺寸的记忆列表 mem
,用于记录各个子问题的解,并将重叠子问题进行剪枝。
如下图所示,引入记忆化可以消除所有重复计算,时间复杂度取决于状态总数,即网格尺寸 \(O(nm)\) 。
+引入记忆化后,所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 \(O(nm)\) 。
Fig. 记忆化搜索递归树
动态规划代码是从底至顶的,仅需循环即可实现。
+基于迭代实现动态规划解法。
下图展示了最小路径和的状态转移过程。该过程遍历了整个网格,因此时间复杂度为 \(O(nm)\) ;数组 dp
使用 \(O(nm)\) 空间。
下图展示了最小路径和的状态转移过程,其遍历了整个网格,因此时间复杂度为 \(O(nm)\) 。
+数组 dp
大小为 \(n \times m\) ,因此空间复杂度为 \(O(nm)\) 。
如果希望进一步节省空间使用,可以考虑进行状态压缩。每个格子只与左边和上边的格子有关,因此我们可以只用一个单行数组来实现 \(dp\) 表。
-由于数组 dp
只能表示一行的状态,因此我们无法提前初始化首列状态,而是在遍历每行中更新它。
由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 \(dp\) 表。
+请注意,因为数组 dp
只能表示一行的状态,所以我们无法提前初始化首列状态,而是在遍历每行中更新它。
编辑距离,也被称为 Levenshtein 距离,是两个字符串之间互相转换的最小修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
+编辑距离,也被称为 Levenshtein 距离,指两个字符串之间互相转换的最小修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
Question
输入两个字符串 \(s\) 和 \(t\) ,返回将 \(s\) 转换为 \(t\) 所需的最少编辑步数。
@@ -3393,7 +3393,8 @@Fig. 编辑距离的示例数据
编辑距离问题可以很自然地用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树的一条边。
-如下图所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作。实际上,从 hello
转换到 algo
有许多种可能的路径,下图展示的是最短路径。从决策树的角度看,本题目标是求解节点 hello
和节点 algo
之间的最短路径。
如下图所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 hello
转换到 algo
有许多种可能的路径。
从决策树的角度看,本题的目标是求解节点 hello
和节点 algo
之间的最短路径。
Fig. 基于决策树模型表示编辑距离问题
@@ -3401,12 +3402,12 @@每一轮的决策是对字符串 \(s\) 进行一次编辑操作。
我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 \(s\) 和 \(t\) 的长度分别为 \(n\) 和 \(m\) ,我们先考虑两字符串尾部的字符 \(s[n-1]\) 和 \(t[m-1]\) :
也就是说,我们在字符串 \(s\) 中进行的每一轮决策(编辑操作),都会使得 \(s\) 和 \(t\) 中剩余的待匹配字符发生变化。因此,状态为当前在 \(s\) , \(t\) 中考虑的第 \(i\) , \(j\) 个字符,记为 \([i, j]\) 。
状态 \([i, j]\) 对应的子问题:将 \(s\) 的前 \(i\) 个字符更改为 \(t\) 的前 \(j\) 个字符所需的最少编辑步数。
-至此得到一个尺寸为 \((i+1) \times (j+1)\) 的二维 \(dp\) 表。
+至此,得到一个尺寸为 \((i+1) \times (j+1)\) 的二维 \(dp\) 表。
第二步:找出最优子结构,进而推导出状态转移方程
考虑子问题 \(dp[i, j]\) ,其对应的两个字符串的尾部字符为 \(s[i-1]\) 和 \(t[j-1]\) ,可根据不同编辑操作分为三种情况:
Fig. 编辑距离的状态转移
-根据以上分析,可得最优子结构:\(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\) 。对应的状态转移方程为:
请注意,当 \(s[i-1]\) 和 \(t[j-1]\) 相同时,无需编辑当前字符,此时状态转移方程为:
+请注意,当 \(s[i-1]\) 和 \(t[j-1]\) 相同时,无需编辑当前字符,这种情况下的状态转移方程为:
第三步:确定边界条件和状态转移顺序
-当两字符串都为空时,编辑步数为 \(0\) ,即 \(dp[0, 0] = 0\) 。当 \(s\) 为空但 \(t\) 不为空时,最少编辑步数等于 \(t\) 的长度,即 \(dp[0, j] = j\) 。当 \(s\) 不为空但 \(t\) 为空时,等于 \(s\) 的长度,即 \(dp[i, 0] = i\) 。
+当两字符串都为空时,编辑步数为 \(0\) ,即 \(dp[0, 0] = 0\) 。当 \(s\) 为空但 \(t\) 不为空时,最少编辑步数等于 \(t\) 的长度,即首行 \(dp[0, j] = j\) 。当 \(s\) 不为空但 \(t\) 为空时,等于 \(s\) 的长度,即首列 \(dp[i, 0] = i\) 。
观察状态转移方程,解 \(dp[i, j]\) 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 \(dp\) 表即可。
下面考虑状态压缩,将 \(dp\) 表的第一维删除。由于 \(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]\) ,这样便只用考虑左方和上方的解,与完全背包问题的情况相同,可使用正序遍历。
由于 \(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]\) ,从而只需考虑左方和上方的解。此时的情况与完全背包问题相同,可使用正序遍历。
「动态规划 Dynamic Programming」是一种通过将复杂问题分解为更简单的子问题的方式来求解问题的方法。它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
+「动态规划 Dynamic Programming」是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出更高效的动态规划解法。
爬楼梯
@@ -3649,20 +3649,24 @@回溯算法通常并不显式地对问题进行拆解,而是将问题看作一系列决策步骤,通过试探和剪枝,搜索所有可能的解。
-对于本题,我们可以尝试将问题拆解为更小的子问题。设爬到第 \(i\) 阶共有 \(dp[i]\) 种方案,那么 \(dp[i]\) 就是原问题,其子问题包括:
+我们可以尝试从问题分解的角度分析这道题。设爬到第 \(i\) 阶共有 \(dp[i]\) 种方案,那么 \(dp[i]\) 就是原问题,其子问题包括:
由于每轮只能上 \(1\) 阶或 \(2\) 阶,因此当我们站在第 \(i\) 阶楼梯上时,上一轮只可能站在第 \(i - 1\) 阶或第 \(i - 2\) 阶上,换句话说,我们只能从第 \(i -1\) 阶或第 \(i - 2\) 阶前往第 \(i\) 阶。因此,爬到第 \(i - 1\) 阶的方案数加上爬到第 \(i - 2\) 阶的方案数就等于爬到第 \(i\) 阶的方案数,即:
+由于每轮只能上 \(1\) 阶或 \(2\) 阶,因此当我们站在第 \(i\) 阶楼梯上时,上一轮只可能站在第 \(i - 1\) 阶或第 \(i - 2\) 阶上。换句话说,我们只能从第 \(i -1\) 阶或第 \(i - 2\) 阶前往第 \(i\) 阶。
+由此便可得出一个重要推论:爬到第 \(i - 1\) 阶的方案数加上爬到第 \(i - 2\) 阶的方案数就等于爬到第 \(i\) 阶的方案数。公式如下:
这意味着在爬楼梯问题中,各个子问题之间不是相互独立的,原问题的解可以从子问题的解构建得来。
Fig. 方案数量递推关系
-也就是说,在爬楼梯问题中,各个子问题之间不是相互独立的,原问题的解可以由子问题的解构成。
-我们可以基于此递推公式写出暴力搜索代码:以 \(dp[n]\) 为起始点,从顶至底地将一个较大问题拆解为两个较小问题的和,直至到达最小子问题 \(dp[1]\) 和 \(dp[2]\) 时返回。
-请注意,最小子问题的解 \(dp[1] = 1\) , \(dp[2] = 2\) 是已知的,代表爬到第 \(1\) , \(2\) 阶分别有 \(1\) , \(2\) 种方案。
+我们可以根据递推公式得到暴力搜索解法:
+观察以下代码,它和标准回溯代码都属于深度优先搜索,但更加简洁。
下图展示了暴力搜索形成的递归树。对于问题 \(dp[n]\) ,其递归树的深度为 \(n\) ,时间复杂度为 \(O(2^n)\) 。指数阶的运行时间增长地非常快,如果我们输入一个比较大的 \(n\) ,则会陷入漫长的等待之中。
+下图展示了暴力搜索形成的递归树。对于问题 \(dp[n]\) ,其递归树的深度为 \(n\) ,时间复杂度为 \(O(2^n)\) 。指数阶属于爆炸式增长,如果我们输入一个比较大的 \(n\) ,则会陷入漫长的等待之中。
Fig. 爬楼梯对应递归树
-实际上,指数阶的时间复杂度是由于「重叠子问题」导致的。例如,问题 \(dp[9]\) 被分解为子问题 \(dp[8]\) 和 \(dp[7]\) ,问题 \(dp[8]\) 被分解为子问题 \(dp[7]\) 和 \(dp[6]\) ,两者都包含子问题 \(dp[7]\) ,而子问题中又包含更小的重叠子问题,子子孙孙无穷尽也,绝大部分计算资源都浪费在这些重叠的问题上。
+观察上图发现,指数阶的时间复杂度是由于「重叠子问题」导致的。例如:\(dp[9]\) 被分解为 \(dp[8]\) 和 \(dp[7]\) ,\(dp[8]\) 被分解为 \(dp[7]\) 和 \(dp[6]\) ,两者都包含子问题 \(dp[7]\) 。
+以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。
为了提升算法效率,我们希望所有的重叠子问题都只被计算一次。具体来说,考虑借助一个数组 mem
来记录每个子问题的解,并在搜索过程中这样做:
为了提升算法效率,我们希望所有的重叠子问题都只被计算一次。为此,我们声明一个数组 mem
来记录每个子问题的解,并在搜索过程中这样做:
mem[i]
,以便之后使用;mem[i]
中获取结果,从而将重叠子问题剪枝;观察下图,经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 \(O(n)\) ,这是一个巨大的飞跃。实际上,如果不考虑递归带来的额外开销,记忆化搜索解法已经几乎等同于动态规划解法的时间效率。
+观察下图,经过记忆化处理后,所有重叠子问题都只需被计算一次,时间复杂度被优化至 \(O(n)\) ,这是一个巨大的飞跃。
Fig. 记忆化搜索对应递归树
记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点);最终通过回溯将子问题的解逐层收集,得到原问题的解。
-我们也可以直接“从底至顶”进行求解,得到标准的动态规划解法:从最小子问题开始,迭代地求解较大子问题,直至得到原问题的解。
-由于动态规划不包含回溯过程,因此无需使用递归,而可以直接基于递推实现。我们初始化一个数组 dp
来存储子问题的解,从最小子问题开始,逐步求解较大子问题。在以下代码中,数组 dp
起到了记忆化搜索中数组 mem
相同的记录作用。
记忆化搜索是一种“从顶至底”的方法:我们从原问题(根节点)开始,递归地将较大子问题分解为较小子问题,直至解已知的最小子问题(叶节点)。之后,通过回溯将子问题的解逐层收集,构建出原问题的解。
+与之相反,动态规划是一种“从底至顶”的方法:从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。
+由于动态规划不包含回溯过程,因此只需使用循环迭代实现,无需使用递归。在以下代码中,我们初始化一个数组 dp
来存储子问题的解,它起到了记忆化搜索中数组 mem
相同的记录作用。
与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如对于爬楼梯问题,状态定义为当前所在楼梯阶数 \(i\) 。动态规划的常用术语包括:
+与回溯算法一样,动态规划也使用“状态”概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解。例如,爬楼梯问题的状态定义为当前所在楼梯阶数 \(i\) 。
+总结以上,动态规划的常用术语包括:
dp
称为「\(dp\) 表」,\(dp[i]\) 表示状态 \(i\) 对应子问题的解;Fig. 爬楼梯的动态规划过程
细心的你可能发现,由于 \(dp[i]\) 只与 \(dp[i-1]\) 和 \(dp[i-2]\) 有关,因此我们无需使用一个数组 dp
来存储所有子问题的解,而只需两个变量滚动前进即可。如以下代码所示,由于省去了数组 dp
占用的空间,因此空间复杂度从 \(O(n)\) 降低至 \(O(1)\) 。
细心的你可能发现,由于 \(dp[i]\) 只与 \(dp[i-1]\) 和 \(dp[i-2]\) 有关,因此我们无需使用一个数组 dp
来存储所有子问题的解,而只需两个变量滚动前进即可。
我们将这种空间优化技巧称为「状态压缩」。在许多动态规划问题中,当前状态仅与前面有限个状态有关,不必保存所有的历史状态,这时我们可以应用状态压缩,只保留必要的状态,通过“降维”来节省内存空间。
+观察以上代码,由于省去了数组 dp
占用的空间,因此空间复杂度从 \(O(n)\) 降低至 \(O(1)\) 。
这种空间优化技巧被称为「状态压缩」。在常见的动态规划问题中,当前状态仅与前面有限个状态有关,这时我们可以应用状态压缩,只保留必要的状态,通过“降维”来节省内存空间。
diff --git a/chapter_dynamic_programming/knapsack_problem/index.html b/chapter_dynamic_programming/knapsack_problem/index.html index 91692d6ee..bc4ff9a7d 100644 --- a/chapter_dynamic_programming/knapsack_problem/index.html +++ b/chapter_dynamic_programming/knapsack_problem/index.html @@ -3411,21 +3411,21 @@背包问题是一个非常好的动态规划入门题目,是动态规划中最常见的问题形式。其具有很多变种,例如 0-1 背包问题、完全背包问题、多重背包问题等。
-在本节中,我们先来学习基础的的 0-1 背包问题。
+在本节中,我们先来求解最常见的 0-1 背包问题。
Question
-给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\) 、价值为 \(val[i-1]\) ,现在有个容量为 \(cap\) 的背包,每个物品只能选择一次,问在不超过背包容量下背包中物品的最大价值。
-请注意,物品编号 \(i\) 从 \(1\) 开始计数,数组索引从 \(0\) 开始计数,因此物品 \(i\) 对应重量 \(wgt[i-1]\) 和价值 \(val[i-1]\) 。
+给定 \(n\) 个物品,第 \(i\) 个物品的重量为 \(wgt[i-1]\) 、价值为 \(val[i-1]\) ,和一个容量为 \(cap\) 的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
下图给出了一个 0-1 背包的示例数据,背包内的最大价值为 \(220\) 。
+请注意,物品编号 \(i\) 从 \(1\) 开始计数,数组索引从 \(0\) 开始计数,因此物品 \(i\) 对应重量 \(wgt[i-1]\) 和价值 \(val[i-1]\) 。
Fig. 0-1 背包的示例数据
-我们可以将 0-1 背包问题看作是一个由 \(n\) 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。此外,该问题的目标是求解“在限定背包容量下的最大价值”,因此较大概率是个动态规划问题。我们接下来尝试求解它。
+我们可以将 0-1 背包问题看作是一个由 \(n\) 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是满足决策树模型的。
+该问题的目标是求解“在限定背包容量下的最大价值”,因此较大概率是个动态规划问题。
第一步:思考每轮的决策,定义状态,从而得到 \(dp\) 表
-在 0-1 背包问题中,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品编号 \(i\) 和剩余背包容量 \(c\) ,记为 \([i, c]\) 。
+对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品编号 \(i\) 和剩余背包容量 \(c\) ,记为 \([i, c]\) 。
状态 \([i, c]\) 对应的子问题为:前 \(i\) 个物品在剩余容量为 \(c\) 的背包中的最大价值,记为 \(dp[i, c]\) 。
-需要求解的是 \(dp[n, cap]\) ,因此需要一个尺寸为 \((n+1) \times (cap+1)\) 的二维 \(dp\) 表。
+待求解的是 \(dp[n, cap]\) ,因此需要一个尺寸为 \((n+1) \times (cap+1)\) 的二维 \(dp\) 表。
第二步:找出最优子结构,进而推导出状态转移方程
当我们做出物品 \(i\) 的决策后,剩余的是前 \(i-1\) 个物品的决策。因此,状态转移分为两种情况:
需要注意的是,若当前物品重量 \(wgt[i - 1]\) 超出剩余背包容量 \(c\) ,则只能选择不放入背包。
第三步:确定边界条件和状态转移顺序
-当无物品或无剩余背包容量时最大价值为 \(0\) ,即所有 \(dp[i, 0]\) 和 \(dp[0, c]\) 都等于 \(0\) 。
+当无物品或无剩余背包容量时最大价值为 \(0\) ,即首列 \(dp[i, 0]\) 和首行 \(dp[0, c]\) 都等于 \(0\) 。
当前状态 \([i, c]\) 从上方的状态 \([i-1, c]\) 和左上方的状态 \([i-1, c-wgt[i-1]]\) 转移而来,因此通过两层循环正序遍历整个 \(dp\) 表即可。
-Tip
-完成以上三步后,我们可以直接实现从底至顶的动态规划解法。而为了展示本题包含的重叠子问题,本文也同时给出从顶至底的暴力搜索和记忆化搜索解法。
-根据以上分析,我们接下来按顺序实现暴力搜索、记忆化搜索、动态规划解法。
搜索代码包含以下要素:
如下图所示,由于每个物品都会产生不选和选两条搜索分支,因此最差时间复杂度为 \(O(2^n)\) 。
-观察递归树,容易发现其中存在一些「重叠子问题」,例如 \(dp[1, 10]\) 等。而当物品较多、背包容量较大,尤其是当相同重量的物品较多时,重叠子问题的数量将会大幅增多。
+如下图所示,由于每个物品都会产生不选和选两条搜索分支,因此时间复杂度为 \(O(2^n)\) 。
+观察递归树,容易发现其中存在重叠子问题,例如 \(dp[1, 10]\) 等。而当物品较多、背包容量较大,尤其是相同重量的物品较多时,重叠子问题的数量将会大幅增多。
Fig. 0-1 背包的暴力搜索递归树
为了防止重复求解重叠子问题,我们借助一个记忆列表 mem
来记录子问题的解,其中 mem[i][c]
对应解 \(dp[i, c]\) 。
为了保证重叠子问题只被计算一次,我们借助记忆列表 mem
来记录子问题的解,其中 mem[i][c]
对应 \(dp[i, c]\) 。
引入记忆化之后,时间复杂度取决于子问题数量,也就是 \(O(n \times cap)\) 。
引入记忆化之后,所有子问题都只被计算一次,因此时间复杂度取决于子问题数量,也就是 \(O(n \times cap)\) 。
Fig. 0-1 背包的记忆化搜索递归树
动态规划解法本质上就是在状态转移中填充 \(dp\) 表的过程,代码如下所示。
+动态规划实质上就是在状态转移中填充 \(dp\) 表的过程,代码如下所示。
如下图所示,时间复杂度由数组 dp
大小决定,为 \(O(n \times cap)\) 。
如下图所示,时间复杂度和空间复杂度都由数组 dp
大小决定,即 \(O(n \times cap)\) 。
最后考虑状态压缩。以上代码中的数组 dp
占用 \(O(n \times cap)\) 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 \(O(n^2)\) 将低至 \(O(n)\) 。代码省略,有兴趣的同学可以自行实现。
那么,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当遍历到第 \(i\) 行时,该数组存储的仍然是第 \(i-1\) 行的状态,为了避免左方区域的格子在状态转移中被覆盖,应该采取倒序遍历。
-以下动画展示了在单个数组下从第 \(i=1\) 行转换至第 \(i=2\) 行的过程。建议你思考一下正序遍历和倒序遍历的区别。
+由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 \(O(n^2)\) 将低至 \(O(n)\) 。
+进一步思考,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当开始遍历第 \(i\) 行时,该数组存储的仍然是第 \(i-1\) 行的状态。
+以下动画展示了在单个数组下从第 \(i = 1\) 行转换至第 \(i = 2\) 行的过程。请思考正序遍历和倒序遍历的区别。
如以下代码所示,我们仅需将数组 dp
的第一维 \(i\) 直接删除,并且将内循环修改为倒序遍历即可。
在代码实现中,我们仅需将数组 dp
的第一维 \(i\) 直接删除,并且把内循环更改为倒序遍历即可。
背包问题
编辑距离问题