From ca5bde2b6c494c352e99d463aba005a784f6bb65 Mon Sep 17 00:00:00 2001 From: krahets Date: Fri, 21 Jul 2023 21:54:51 +0800 Subject: [PATCH] Add subtitles to docs --- docs/chapter_backtracking/n_queens_problem.md | 8 +++++++- .../permutations_problem.md | 18 ++++++++++++++---- .../chapter_backtracking/subset_sum_problem.md | 14 +++++++++++--- .../binary_search_recur.md | 4 +++- .../build_binary_tree_problem.md | 8 ++++++++ .../hanota_problem.md | 14 +++++++++++--- .../dp_problem_features.md | 8 +++++++- .../dp_solution_pipeline.md | 10 ++++++---- .../edit_distance_problem.md | 4 ++++ .../intro_to_dynamic_programming.md | 8 ++------ .../knapsack_problem.md | 10 ++++++---- docs/chapter_dynamic_programming/summary.md | 6 ++++++ .../unbounded_knapsack_problem.md | 14 +++++++++++++- .../fractional_knapsack_problem.md | 8 +++++--- docs/chapter_greedy/max_capacity_problem.md | 10 ++++++---- docs/chapter_greedy/summary.md | 0 16 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 docs/chapter_greedy/summary.md diff --git a/docs/chapter_backtracking/n_queens_problem.md b/docs/chapter_backtracking/n_queens_problem.md index d1fb01db8..e33a66169 100644 --- a/docs/chapter_backtracking/n_queens_problem.md +++ b/docs/chapter_backtracking/n_queens_problem.md @@ -12,12 +12,16 @@ ![n 皇后问题的约束条件](n_queens_problem.assets/n_queens_constraints.png) +### 皇后放置策略 + 皇后的数量和棋盘的行数都为 $n$ ,因此我们容易得到第一个推论:**棋盘每行都允许且只允许放置一个皇后**。这意味着,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。**此策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。 下图展示了 $4$ 皇后问题的逐行放置过程。受篇幅限制,下图仅展开了第一行的一个搜索分支。在搜索过程中,我们将不满足列约束和对角线约束的方案都剪枝了。 ![逐行放置策略](n_queens_problem.assets/n_queens_placing.png) +### 列与对角线剪枝 + 为了实现根据列约束剪枝,我们可以利用一个长度为 $n$ 的布尔型数组 `cols` 记录每一列是否有皇后。在每次决定放置前,我们通过 `cols` 将已有皇后的列剪枝,并在回溯中动态更新 `cols` 的状态。 那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 `(row, col)` ,观察矩阵的某条主对角线,**我们发现该对角线上所有格子的行索引减列索引相等**,即 `row - col` 为恒定值。换句话说,若两个格子满足 `row1 - col1 == row2 - col2` ,则这两个格子一定处在一条主对角线上。 @@ -28,6 +32,8 @@ 同理,**次对角线上的所有格子的 `row + col` 是恒定值**。我们可以使用同样的方法,借助数组 `diag2` 来处理次对角线约束。 +### 代码实现 + 根据以上分析,我们便可以写出 $n$ 皇后的解题代码。 === "Java" @@ -118,7 +124,7 @@ [class]{}-[func]{nQueens} ``` -## 复杂度分析 +### 复杂度分析 逐行放置 $n$ 次,考虑列约束,则从第一行到最后一行分别有 $n, n-1, \cdots, 2, 1$ 个选择,**因此时间复杂度为 $O(n!)$** 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。 diff --git a/docs/chapter_backtracking/permutations_problem.md b/docs/chapter_backtracking/permutations_problem.md index 34f515e80..c0c53c7a9 100644 --- a/docs/chapter_backtracking/permutations_problem.md +++ b/docs/chapter_backtracking/permutations_problem.md @@ -14,7 +14,7 @@ -## 无重复的情况 +## 无相等元素的情况 !!! question @@ -28,6 +28,8 @@ ![全排列的递归树](permutations_problem.assets/permutations_i.png) +### 代码实现 + 想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短代码行数,我们不单独实现框架代码中的各个函数,而是将他们展开在 `backtrack()` 函数中。 === "Java" @@ -118,13 +120,15 @@ [class]{}-[func]{permutationsI} ``` +### 重复选择剪枝 + 需要重点关注的是,我们引入了一个布尔型数组 `selected` ,它的长度与输入数组长度相等,其中 `selected[i]` 表示 `choices[i]` 是否已被选择。我们利用 `selected` 避免某个元素被重复选择,从而实现剪枝。 如下图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1, 3 的分支。**此剪枝操作可将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$** 。 ![全排列剪枝示例](permutations_problem.assets/permutations_i_pruning.png) -## 考虑重复的情况 +## 考虑相等元素的情况 !!! question @@ -138,9 +142,13 @@ 观察发现,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,因为在这两个选择之下生成的所有排列都是重复的。因此,我们应该把 $\hat{1}$ 剪枝掉。同理,在第一轮选择 $2$ 后,第二轮选择中的 $1$ 和 $\hat{1}$ 也会产生重复分支,因此也需要将第二轮的 $\hat{1}$ 剪枝。 +本质上看,**我们的目标是实现在某一轮选择中,多个相等的元素仅被选择一次**。 + ![重复排列剪枝](permutations_problem.assets/permutations_ii_pruning.png) -本质上看,**我们的目标是实现在某一轮选择中,多个相等的元素仅被选择一次**。因此,在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 `duplicated` ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝。 +### 代码实现 + +在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 `duplicated` ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝。 === "Java" @@ -230,6 +238,8 @@ [class]{}-[func]{permutationsII} ``` +### 两种剪枝对比 + 注意,虽然 `selected` 和 `duplicated` 都起到剪枝的作用,但他们剪掉的是不同的分支: - **剪枝条件一**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,作用是避免某个元素在 `state` 中重复出现。 @@ -239,7 +249,7 @@ ![两种剪枝条件的作用范围](permutations_problem.assets/permutations_ii_pruning_summary.png) -## 复杂度分析 +### 复杂度分析 假设元素两两之间互不相同,则 $n$ 个元素共有 $n!$ 种排列(阶乘);在记录结果时,需要复制长度为 $n$ 的列表,使用 $O(n)$ 时间。因此,**时间复杂度为 $O(n!n)$** 。 diff --git a/docs/chapter_backtracking/subset_sum_problem.md b/docs/chapter_backtracking/subset_sum_problem.md index 6bf4250e2..2ec9fcb38 100644 --- a/docs/chapter_backtracking/subset_sum_problem.md +++ b/docs/chapter_backtracking/subset_sum_problem.md @@ -1,12 +1,14 @@ # 子集和问题 +## 无重复元素的情况 + !!! question 给定一个正整数数组 `nums` 和一个目标正整数 `target` ,请找出所有可能的组合,使得组合中的元素和等于 `target` 。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。 例如,输入集合 $\{3, 4, 5\}$ 和目标整数 $9$ ,由于集合中的数字可以被重复选取,因此解为 $\{3, 3, 3\}, \{4, 5\}$ 。请注意,子集是不区分元素顺序的,例如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。 -## 从全排列引出解法 +### 从全排列引出解法 类似于上节全排列问题的解法,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 `target` 时,就将子集记录至结果列表。 @@ -104,7 +106,7 @@ ![子集搜索与越界剪枝](subset_sum_problem.assets/subset_sum_i_naive.png) -## 重复子集剪枝 +### 重复子集剪枝 为了去除重复子集,**一种直接的思路是对结果列表进行去重**。但这个方法效率很低,因为: @@ -121,6 +123,8 @@ 总结来看,给定输入数组 $[x_1, x_2, \cdots, x_n]$ ,设搜索过程中的选择序列为 $[x_{i_1}, x_{i_2}, \cdots , x_{i_m}]$ ,则该选择序列需要满足 $i_1 \leq i_2 \leq \cdots \leq i_m$ 。**不满足该条件的选择序列都是重复子集**。 +### 代码实现 + 为实现该剪枝,我们初始化变量 `start` ,用于指示遍历起点。**当做出选择 $x_{i}$ 后,设定下一轮从索引 $i$ 开始遍历**,从而完成子集去重。 除此之外,我们还对代码进行了两项优化。首先,我们在开启搜索前将数组 `nums` 排序,在搜索过程中,**当子集和超过 `target` 时直接结束循环**,因为后边的元素更大,其子集和都一定会超过 `target` 。其次,**我们通过在 `target` 上执行减法来统计元素和**,当 `target` 等于 $0$ 时记录解,省去了元素和变量 `total` 。 @@ -217,7 +221,7 @@ ![子集和 I 回溯过程](subset_sum_problem.assets/subset_sum_i.png) -## 相等元素剪枝 +## 考虑重复元素的情况 !!! question @@ -227,10 +231,14 @@ ![相等元素导致的重复子集](subset_sum_problem.assets/subset_sum_ii_repeat.png) +### 相等元素剪枝 + 为解决此问题,**我们需要限制相等元素在每一轮中只被选择一次**。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。利用该特性,在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。 与此同时,**本题规定数组元素只能被选择一次**。幸运的是,我们也可以利用变量 `start` 来满足该约束:当做出选择 $x_{i}$ 后,设定下一轮从索引 $i + 1$ 开始向后遍历。这样即能去除重复子集,也能避免重复选择相等元素。 +### 代码实现 + === "Java" ```java title="subset_sum_ii.java" diff --git a/docs/chapter_divide_and_conquer/binary_search_recur.md b/docs/chapter_divide_and_conquer/binary_search_recur.md index 356e8b7cd..56340752a 100644 --- a/docs/chapter_divide_and_conquer/binary_search_recur.md +++ b/docs/chapter_divide_and_conquer/binary_search_recur.md @@ -2,6 +2,8 @@ 我们已经学过,搜索算法分为两大类:暴力搜索、自适应搜索。暴力搜索的时间复杂度为 $O(n)$ 。自适应搜索利用特有的数据组织形式或先验信息,可达到 $O(\log n)$ 甚至 $O(1)$ 的时间复杂度。 +### 基于分治的搜索算法 + 实际上,**$O(\log n)$ 的搜索算法通常都是基于分治策略实现的**,例如: - 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止。 @@ -9,7 +11,7 @@ 分治之所以能够提升搜索效率,是因为暴力搜索每轮只能排除一个选项,**而基于分治的搜索每轮可以排除一半选项**。 -## 基于分治实现二分 +### 基于分治实现二分 接下来,我们尝试从分治策略的角度分析二分查找的性质: diff --git a/docs/chapter_divide_and_conquer/build_binary_tree_problem.md b/docs/chapter_divide_and_conquer/build_binary_tree_problem.md index 872ffeeab..35bf73404 100644 --- a/docs/chapter_divide_and_conquer/build_binary_tree_problem.md +++ b/docs/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -6,12 +6,16 @@ ![构建二叉树的示例数据](build_binary_tree_problem.assets/build_tree_example.png) +### 判断是否为分治问题 + 原问题定义为从 `preorder` 和 `inorder` 构建二叉树。我们首先从分治的角度分析这道题: - **问题可以被分解**:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每个子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。 - **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历或后序遍历中与左子树对应的部分。右子树同理。 - **子问题的解可以合并**:一旦我们得到了左子树和右子树,我们可以将它们链接到根节点上,从而得到原问题的解。 +### 如何划分子树 + 根据以上分析,这道题是可以使用分治来求解的,但问题是:**如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**? 根据定义,`preorder` 和 `inorder` 都可以被划分为三个部分: @@ -27,6 +31,8 @@ ![在前序和中序遍历中划分子树](build_binary_tree_problem.assets/build_tree_preorder_inorder_division.png) +### 使用指针描述子树区间 + 至此,**我们已经推导出根节点、左子树、右子树在 `preorder` 和 `inorder` 中的索引区间**。而为了描述这些索引区间,我们需要借助几个指针变量: - 将当前树的根节点在 `preorder` 中的索引记为 $i$ ; @@ -49,6 +55,8 @@ ![根节点和左右子树的索引区间表示](build_binary_tree_problem.assets/build_tree_division_pointers.png) +### 代码实现 + 接下来就可以实现代码了。为了提升查询 $m$ 的效率,我们借助一个哈希表 `hmap` 来存储 `inorder` 列表元素到索引的映射。 === "Java" diff --git a/docs/chapter_divide_and_conquer/hanota_problem.md b/docs/chapter_divide_and_conquer/hanota_problem.md index 2c7bf9165..6583fb2d0 100644 --- a/docs/chapter_divide_and_conquer/hanota_problem.md +++ b/docs/chapter_divide_and_conquer/hanota_problem.md @@ -14,7 +14,9 @@ 在本文中,**我们将规模为 $i$ 的汉诺塔问题记做 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。 -先考虑最简单的情况:对于问题 $f(1)$ ,即当只有一个圆盘时,则将它直接从 `A` 移动至 `C` 即可。 +### 考虑基本情况 + +对于问题 $f(1)$ ,即当只有一个圆盘时,则将它直接从 `A` 移动至 `C` 即可。 === "<1>" ![规模为 1 问题的解](hanota_problem.assets/hanota_f1_step1.png) @@ -42,6 +44,8 @@ === "<4>" ![hanota_f2_step4](hanota_problem.assets/hanota_f2_step4.png) +### 子问题分解 + 对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。由于已知 $f(1)$ 和 $f(2)$ 的解,我们可以从分治角度思考,**将 `A` 顶部的两个圆盘看做一个整体**,并执行以下步骤: 1. 令 `B` 为目标柱、`C` 为缓冲柱,将两个圆盘从 `A` 移动至 `B` ; @@ -74,6 +78,8 @@ ![汉诺塔问题的分治策略](hanota_problem.assets/hanota_divide_and_conquer.png) +### 代码实现 + 在代码实现中,我们声明一个递归函数 `dfs(i, src, buf, tar)` ,它的作用是将柱 `src` 顶部的 $i$ 个圆盘借助缓冲柱 `buf` 移动至目标柱 `tar` 。 === "Java" @@ -190,6 +196,8 @@ ![汉诺塔问题的递归树](hanota_problem.assets/hanota_recursive_tree.png) -有趣的是,汉诺塔问题源自一种古老的传说故事。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 $64$ 个大小不一的金圆盘。僧侣们不断地移动原盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。 +!!! quote + + 汉诺塔问题源自一种古老的传说故事。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 $64$ 个大小不一的金圆盘。僧侣们不断地移动原盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。 -然而根据以上分析,即使僧侣们每秒钟移动一次,总共需要大约 $2^{64} \approx 1.84×10^{19}$ 秒,合约 $5850$ 亿年,远远超过了现在对宇宙年龄的估计。所以,倘若这个传说是真的,我们应该不需要担心世界末日的到来。 + 然而根据以上分析,即使僧侣们每秒钟移动一次,总共需要大约 $2^{64} \approx 1.84×10^{19}$ 秒,合约 $5850$ 亿年,远远超过了现在对宇宙年龄的估计。所以,倘若这个传说是真的,我们应该不需要担心世界末日的到来。 diff --git a/docs/chapter_dynamic_programming/dp_problem_features.md b/docs/chapter_dynamic_programming/dp_problem_features.md index f3d56ed9e..4e233ced1 100644 --- a/docs/chapter_dynamic_programming/dp_problem_features.md +++ b/docs/chapter_dynamic_programming/dp_problem_features.md @@ -2,7 +2,13 @@ 在上节中,我们学习了动态规划问题的暴力解法,从递归树中观察到海量的重叠子问题,以及了解到动态规划是如何通过记录解来优化时间复杂度的。 -实际上,动态规划最常用来求解最优方案问题,例如寻找最短路径、最大利润、最少时间等。**这类问题不仅包含重叠子问题,往往还具有另外两大特性:最优子结构、无后效性**。 +总的看来,**子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点**: + +- 分治算法将原问题划分为几个独立的子问题,然后递归解决子问题,最后合并子问题的解得到原问题的解。 +- 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,**动态规划中的子问题往往不是相互独立的**,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。 +- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。 + +实际上,动态规划最常用来求解最优化问题。**这类问题不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性**。 ## 最优子结构 diff --git a/docs/chapter_dynamic_programming/dp_solution_pipeline.md b/docs/chapter_dynamic_programming/dp_solution_pipeline.md index 0169bec58..a607ed3f4 100644 --- a/docs/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/docs/chapter_dynamic_programming/dp_solution_pipeline.md @@ -25,7 +25,7 @@ 如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并尝试求解它。 -## 问题求解 +## 问题求解步骤 动态规划的解题流程可能会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 $dp$ 表,推导状态转移方程,确定边界条件等。 @@ -87,7 +87,7 @@ $$ 接下来,我们就可以实现动态规划代码了。然而,由于子问题分解是一种从顶至底的思想,因此按照“暴力搜索 $\rightarrow$ 记忆化搜索 $\rightarrow$ 动态规划”的顺序实现更加符合思维习惯。 -## 方法一:暴力搜索 +### 方法一:暴力搜索 从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,包括以下递归要素: @@ -169,7 +169,7 @@ $$ 每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。 -## 方法二:记忆化搜索 +### 方法二:记忆化搜索 为了避免重复计算重叠子问题,我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,提升搜索效率。 @@ -243,7 +243,7 @@ $$ ![记忆化搜索递归树](dp_solution_pipeline.assets/min_path_sum_dfs_mem.png) -## 方法三:动态规划 +### 方法三:动态规划 动态规划代码是从底至顶的,仅需循环即可实现。 @@ -351,6 +351,8 @@ $$ === "<12>" ![min_path_sum_dp_step12](dp_solution_pipeline.assets/min_path_sum_dp_step12.png) +### 状态压缩 + 如果希望进一步节省空间使用,可以考虑进行状态压缩。每个格子只与左边和上边的格子有关,因此我们可以只用一个单行数组来实现 $dp$ 表。 由于数组 `dp` 只能表示一行的状态,因此我们无法提前初始化首列状态,而是在遍历每行中更新它。 diff --git a/docs/chapter_dynamic_programming/edit_distance_problem.md b/docs/chapter_dynamic_programming/edit_distance_problem.md index 3bdc7260f..f6687c300 100644 --- a/docs/chapter_dynamic_programming/edit_distance_problem.md +++ b/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -61,6 +61,8 @@ $$ 观察状态转移方程,解 $dp[i, j]$ 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 $dp$ 表即可。 +### 代码实现 + === "Java" ```java title="edit_distance.java" @@ -174,6 +176,8 @@ $$ === "<15>" ![edit_distance_dp_step15](edit_distance_problem.assets/edit_distance_dp_step15.png) +### 状态压缩 + 下面考虑状态压缩,将 $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]$ ,这样便只用考虑左方和上方的解,与完全背包问题的情况相同,可使用正序遍历。 diff --git a/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md index 5fbf49d26..9a2a60970 100644 --- a/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md +++ b/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -403,6 +403,8 @@ $$ ![爬楼梯的动态规划过程](intro_to_dynamic_programming.assets/climbing_stairs_dp.png) +## 状态压缩 + 细心的你可能发现,**由于 $dp[i]$ 只与 $dp[i-1]$ 和 $dp[i-2]$ 有关,因此我们无需使用一个数组 `dp` 来存储所有子问题的解**,而只需两个变量滚动前进即可。如以下代码所示,由于省去了数组 `dp` 占用的空间,因此空间复杂度从 $O(n)$ 降低至 $O(1)$ 。 === "Java" @@ -472,9 +474,3 @@ $$ ``` **我们将这种空间优化技巧称为「状态压缩」**。在许多动态规划问题中,当前状态仅与前面有限个状态有关,不必保存所有的历史状态,这时我们可以应用状态压缩,只保留必要的状态,通过“降维”来节省内存空间。 - -总的看来,**子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点**: - -- 分治算法将原问题划分为几个独立的子问题,然后递归解决子问题,最后合并子问题的解得到原问题的解。例如,归并排序将长数组不断划分为两个短子数组,再将排序好的子数组合并为排序好的长数组。 -- 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,**动态规划中的子问题往往不是相互独立的**,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。 -- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。 diff --git a/docs/chapter_dynamic_programming/knapsack_problem.md b/docs/chapter_dynamic_programming/knapsack_problem.md index 7c8eb1474..be2779c49 100644 --- a/docs/chapter_dynamic_programming/knapsack_problem.md +++ b/docs/chapter_dynamic_programming/knapsack_problem.md @@ -49,7 +49,7 @@ $$ 完成以上三步后,我们可以直接实现从底至顶的动态规划解法。而为了展示本题包含的重叠子问题,本文也同时给出从顶至底的暴力搜索和记忆化搜索解法。 -## 方法一:暴力搜索 +### 方法一:暴力搜索 搜索代码包含以下要素: @@ -129,7 +129,7 @@ $$ ![0-1 背包的暴力搜索递归树](knapsack_problem.assets/knapsack_dfs.png) -## 方法二:记忆化搜索 +### 方法二:记忆化搜索 为了防止重复求解重叠子问题,我们借助一个记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 对应解 $dp[i, c]$ 。 @@ -203,7 +203,7 @@ $$ ![0-1 背包的记忆化搜索递归树](knapsack_problem.assets/knapsack_dfs_mem.png) -## 方法三:动态规划 +### 方法三:动态规划 动态规划解法本质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示。 @@ -317,7 +317,9 @@ $$ === "<14>" ![knapsack_dp_step14](knapsack_problem.assets/knapsack_dp_step14.png) -**最后考虑状态压缩**。以上代码中的数组 `dp` 占用 $O(n \times cap)$ 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。代码省略,有兴趣的同学可以自行实现。 +### 状态压缩 + +最后考虑状态压缩。以上代码中的数组 `dp` 占用 $O(n \times cap)$ 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。代码省略,有兴趣的同学可以自行实现。 那么,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当遍历到第 $i$ 行时,该数组存储的仍然是第 $i-1$ 行的状态,**为了避免左方区域的格子在状态转移中被覆盖,应该采取倒序遍历**。 diff --git a/docs/chapter_dynamic_programming/summary.md b/docs/chapter_dynamic_programming/summary.md index 319d1736b..de2f5adf5 100644 --- a/docs/chapter_dynamic_programming/summary.md +++ b/docs/chapter_dynamic_programming/summary.md @@ -4,11 +4,17 @@ - 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。 - 记忆化递归是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,就像是在“填写表格”一样。由于当前状态仅依赖于某些局部状态,因此我们可以消除 $dp$ 表的一个维度,从而降低空间复杂度。 - 动态规划问题的三大特性:重叠子问题、最优子结构、无后效性。如果原问题的最优解可以从子问题的最优解构建得来,则此问题就具有最优子结构。无后效性指对于一个状态,其未来发展只与该状态有关,与其所经历的过去的所有状态无关。许多组合优化问题都不具有无后效性,无法使用动态规划快速求解。 + +**背包问题** + - 背包问题是最典型的动态规划题目,具有 0-1 背包、完全背包、多重背包等变种问题。 - 0-1 背包的状态定义为前 $i$ 个物品在剩余容量为 $c$ 的背包中的最大价值。这是一种常见的定义方式。不放入物品 $i$ ,状态转移至 $[i-1, c]$ ,放入则转移至 $[i-1, c-wgt[i-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/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md b/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md index 674cdd713..e462e99f7 100644 --- a/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/docs/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -26,6 +26,8 @@ $$ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1]) $$ +### 代码实现 + 对比两道题目的动态规划代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致。 === "Java" @@ -94,6 +96,8 @@ $$ [class]{}-[func]{unboundedKnapsackDP} ``` +### 状态压缩 + 由于当前状态是从左边和上边的状态转移而来,**因此状态压缩后应该对 $dp$ 表中的每一行采取正序遍历**,这个遍历顺序与 0-1 背包正好相反。请通过以下动画来理解为什么要改为正序遍历。 === "<1>" @@ -221,7 +225,9 @@ $$ 当目标金额为 $0$ 时,凑出它的最少硬币个数为 $0$ ,即所有 $dp[i, 0]$ 都等于 $0$ 。当无硬币时,**无法凑出任意 $> 0$ 的目标金额**,即是无效解。为使状态转移方程中的 $\min()$ 函数能够识别并过滤无效解,我们考虑使用 $+ \infty$ 来表示它们,即令所有 $dp[0, a]$ 都等于 $+ \infty$ 。 -以上做法仅适用于 Python 语言,因为大多数编程语言并未提供 $+ \infty$ 变量,所以只能使用整型 `int` 的最大值,而这又会导致大数越界:**当 $dp[i, a - coins[i-1]]$ 是无效解时,再执行 $+ 1$ 操作会发生溢出**。 +### 代码实现 + +然而,大多数编程语言并未提供 $+ \infty$ 变量,因此只能使用整型 `int` 的最大值来代替,而这又会导致大数越界:**当 $dp[i, a - coins[i-1]]$ 是无效解时,再执行 $+ 1$ 操作会发生溢出**。 为解决该问题,我们采用一个不可能达到的大数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币个数最多为 $amt$ 个。 @@ -340,6 +346,8 @@ $$ === "<15>" ![coin_change_dp_step15](unbounded_knapsack_problem.assets/coin_change_dp_step15.png) +### 状态压缩 + 由于零钱兑换和完全背包的状态转移方程如出一辙,因此状态压缩方式也相同。 === "Java" @@ -426,6 +434,8 @@ $$ 当目标金额为 $0$ 时,无需选择任何硬币即可凑出目标金额,因此应将所有 $dp[i, 0]$ 都初始化为 $1$ 。当无硬币时,无法凑出任何 $>0$ 的目标金额,因此所有 $dp[0, a]$ 都等于 $0$ 。 +### 代码实现 + === "Java" ```java title="coin_change_ii.java" @@ -492,6 +502,8 @@ $$ [class]{}-[func]{coinChangeIIDP} ``` +### 状态压缩 + 状态压缩处理方式相同,删除硬币维度即可。 === "Java" diff --git a/docs/chapter_greedy/fractional_knapsack_problem.md b/docs/chapter_greedy/fractional_knapsack_problem.md index 143da33af..29c78d40b 100644 --- a/docs/chapter_greedy/fractional_knapsack_problem.md +++ b/docs/chapter_greedy/fractional_knapsack_problem.md @@ -8,7 +8,7 @@ ![分数背包问题的示例数据](fractional_knapsack_problem.assets/fractional_knapsack_example.png) -**第一步:问题分析** +### 第一步:问题分析 本题和 0-1 背包整体上非常相似,状态包含当前物品 $i$ 和容量 $c$ ,目标是求不超过背包容量下的最大价值。 @@ -19,7 +19,7 @@ ![物品在单位重量下的价值](fractional_knapsack_problem.assets/fractional_knapsack_unit_value.png) -**第二步:贪心策略确定** +### 第二步:贪心策略确定 最大化背包内物品总价值,**本质上是要最大化单位重量下的物品价值**。由此便可推出本题的贪心策略: @@ -29,6 +29,8 @@ ![分数背包的贪心策略](fractional_knapsack_problem.assets/fractional_knapsack_greedy_strategy.png) +### 代码实现 + 我们构建了一个物品类 `Item` ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解。 === "Java" @@ -121,7 +123,7 @@ 最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。由于初始化了一个 `Item` 对象列表,**因此空间复杂度为 $O(n)$** 。 -**第三步:正确性证明** +### 第三步:正确性证明 采用反证法。假设物品 $x$ 是单位价值最高的物品,使用某算法求得最大价值为 $res$ ,但该解中不包含物品 $x$ 。 diff --git a/docs/chapter_greedy/max_capacity_problem.md b/docs/chapter_greedy/max_capacity_problem.md index 5219873f1..8ee2fec68 100644 --- a/docs/chapter_greedy/max_capacity_problem.md +++ b/docs/chapter_greedy/max_capacity_problem.md @@ -8,7 +8,7 @@ ![最大容量问题的示例数据](max_capacity_problem.assets/max_capacity_example.png) -**第一步:问题分析** +### 第一步:问题分析 容器由任意两个隔板围成,**因此本题的状态为两个隔板的索引,记为 $[i, j]$** 。 @@ -20,7 +20,7 @@ $$ 设数组长度为 $n$ ,两个隔板的组合数量(即状态总数)为 $C_n^2 = \frac{n(n - 1)}{2}$ 个。最直接地,**我们可以穷举所有状态**,从而求得最大容量,时间复杂度为 $O(n^2)$ 。 -**第二步:贪心策略确定** +### 第二步:贪心策略确定 当然,这道题还有更高效率的解法。如下图所示,现选取一个状态 $[i, j]$ ,其满足索引 $i < j$ 且高度 $ht[i] < ht[j]$ ,即 $i$ 为短板、 $j$ 为长板。 @@ -71,7 +71,9 @@ $$ === "<9>" ![max_capacity_greedy_step9](max_capacity_problem.assets/max_capacity_greedy_step9.png) -代码实现如下所示。最多循环 $n$ 轮,**因此时间复杂度为 $O(n)$** 。变量 $i$ , $j$ , $res$ 使用常数大小额外空间,**因此空间复杂度为 $O(1)$** 。 +### 代码实现 + +如下代码所示,循环最多 $n$ 轮,**因此时间复杂度为 $O(n)$** 。变量 $i$ , $j$ , $res$ 使用常数大小额外空间,**因此空间复杂度为 $O(1)$** 。 === "Java" @@ -139,7 +141,7 @@ $$ [class]{}-[func]{maxCapacity} ``` -**第三步:正确性证明** +### 第三步:正确性证明 之所以贪心比穷举更快,是因为每轮的贪心选择都会“跳过”一些状态。 diff --git a/docs/chapter_greedy/summary.md b/docs/chapter_greedy/summary.md new file mode 100644 index 000000000..e69de29bb