@@ -3521,8 +3642,11 @@

Fig. 汉诺塔问题的递归树
-
有趣的是,汉诺塔问题源自一种古老的传说故事。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 \(64\) 个大小不一的金圆盘。僧侣们不断地移动原盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。
+
+
Quote
+
汉诺塔问题源自一种古老的传说故事。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 \(64\) 个大小不一的金圆盘。僧侣们不断地移动原盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。
然而根据以上分析,即使僧侣们每秒钟移动一次,总共需要大约 \(2^{64} \approx 1.84×10^{19}\) 秒,合约 \(5850\) 亿年,远远超过了现在对宇宙年龄的估计。所以,倘若这个传说是真的,我们应该不需要担心世界末日的到来。
+
diff --git a/chapter_divide_and_conquer/index.html b/chapter_divide_and_conquer/index.html
index 61d6da7f0..eeafdc97f 100644
--- a/chapter_divide_and_conquer/index.html
+++ b/chapter_divide_and_conquer/index.html
@@ -2898,6 +2898,8 @@
+
+
@@ -3033,6 +3035,34 @@
+
+
+
+
+
+
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_divide_and_conquer/summary/index.html b/chapter_divide_and_conquer/summary/index.html
index 1d813a0bb..6ecc493eb 100644
--- a/chapter_divide_and_conquer/summary/index.html
+++ b/chapter_divide_and_conquer/summary/index.html
@@ -2908,6 +2908,8 @@
+
+
@@ -3043,6 +3045,34 @@
+
+
+
+
+
+
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_dynamic_programming/dp_problem_features/index.html b/chapter_dynamic_programming/dp_problem_features/index.html
index 3f2703bfc..3f145cc72 100644
--- a/chapter_dynamic_programming/dp_problem_features/index.html
+++ b/chapter_dynamic_programming/dp_problem_features/index.html
@@ -2960,6 +2960,8 @@
+
+
@@ -3095,6 +3097,34 @@
+
+
+
+
+
+
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3323,7 +3353,13 @@
14.2. 动态规划问题特性
在上节中,我们学习了动态规划问题的暴力解法,从递归树中观察到海量的重叠子问题,以及了解到动态规划是如何通过记录解来优化时间复杂度的。
-
实际上,动态规划最常用来求解最优方案问题,例如寻找最短路径、最大利润、最少时间等。这类问题不仅包含重叠子问题,往往还具有另外两大特性:最优子结构、无后效性。
+
总的看来,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中各有特点:
+
+- 分治算法将原问题划分为几个独立的子问题,然后递归解决子问题,最后合并子问题的解得到原问题的解。
+- 动态规划也是将原问题分解为多个子问题,但与分治算法的主要区别是,动态规划中的子问题往往不是相互独立的,原问题的解依赖于子问题的解,而子问题的解又依赖于更小的子问题的解。
+- 回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。
+
+
实际上,动态规划最常用来求解最优化问题。这类问题不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
14.2.1. 最优子结构
我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念。
diff --git a/chapter_dynamic_programming/dp_solution_pipeline/index.html b/chapter_dynamic_programming/dp_solution_pipeline/index.html
index 7b647791d..b46059428 100644
--- a/chapter_dynamic_programming/dp_solution_pipeline/index.html
+++ b/chapter_dynamic_programming/dp_solution_pipeline/index.html
@@ -2812,30 +2812,43 @@
- 14.3.2. 问题求解
+ 14.3.2. 问题求解步骤
+
+
+
+
@@ -2981,6 +2994,8 @@
+
+
@@ -3116,6 +3131,34 @@
+
+
+
+
+
+
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3314,30 +3357,43 @@
- 14.3.2. 问题求解
+ 14.3.2. 问题求解步骤
+
+
+
+
@@ -3384,7 +3440,7 @@
问题描述中有明显的排列组合的特征,需要返回具体的多个方案。
如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并尝试求解它。
-
14.3.2. 问题求解
+
14.3.2. 问题求解步骤
动态规划的解题流程可能会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 \(dp\) 表,推导状态转移方程,确定边界条件等。
为了更形象地展示解题步骤,我们使用一个经典问题「最小路径和」来举例。
@@ -3432,7 +3488,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
边界条件即初始状态,在搜索中用于剪枝,在动态规划中用于初始化 \(dp\) 表。状态转移顺序的核心是要保证在计算当前问题时,所有它依赖的更小子问题都已经被正确地计算出来。
接下来,我们就可以实现动态规划代码了。然而,由于子问题分解是一种从顶至底的思想,因此按照“暴力搜索 \(\rightarrow\) 记忆化搜索 \(\rightarrow\) 动态规划”的顺序实现更加符合思维习惯。
-
14.3.3. 方法一:暴力搜索
+
方法一:暴力搜索
从状态 \([i, j]\) 开始搜索,不断分解为更小的状态 \([i-1, j]\) 和 \([i, j-1]\) ,包括以下递归要素:
- 递归参数:状态 \([i, j]\) ;返回值:从 \([0, 0]\) 到 \([i, j]\) 的最小路径和 \(dp[i, j]\) ;
@@ -3580,7 +3636,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
Fig. 暴力搜索递归树
每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 \(m + n - 2\) 步,所以最差时间复杂度为 \(O(2^{m + n})\) 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。
-14.3.4. 方法二:记忆化搜索
+方法二:记忆化搜索
为了避免重复计算重叠子问题,我们引入一个和网格 grid 相同尺寸的记忆列表 mem ,用于记录各个子问题的解,提升搜索效率。
@@ -3753,7 +3809,7 @@ dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]

Fig. 记忆化搜索递归树
-
14.3.5. 方法三:动态规划
+
方法三:动态规划
动态规划代码是从底至顶的,仅需循环即可实现。
+
状态压缩
如果希望进一步节省空间使用,可以考虑进行状态压缩。每个格子只与左边和上边的格子有关,因此我们可以只用一个单行数组来实现 \(dp\) 表。
由于数组 dp 只能表示一行的状态,因此我们无法提前初始化首列状态,而是在遍历每行中更新它。
diff --git a/chapter_dynamic_programming/edit_distance_problem/index.html b/chapter_dynamic_programming/edit_distance_problem/index.html
index 7d246a52d..19a4de533 100644
--- a/chapter_dynamic_programming/edit_distance_problem/index.html
+++ b/chapter_dynamic_programming/edit_distance_problem/index.html
@@ -2835,6 +2835,25 @@
+
+
@@ -2853,6 +2872,39 @@
+
+
+
+
@@ -2908,6 +2960,8 @@
+
+
@@ -3043,6 +3097,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3226,6 +3308,28 @@
+
+
+
@@ -3294,6 +3398,7 @@ dp[i, j] = dp[i-1, j-1]
第三步:确定边界条件和状态转移顺序
当两字符串都为空时,编辑步数为 \(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]\) ,这样便只用考虑左方和上方的解,与完全背包问题的情况相同,可使用正序遍历。
diff --git a/chapter_dynamic_programming/index.html b/chapter_dynamic_programming/index.html
index 697f33672..dc973ee5b 100644
--- a/chapter_dynamic_programming/index.html
+++ b/chapter_dynamic_programming/index.html
@@ -2898,6 +2898,8 @@
+
+
@@ -3033,6 +3035,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html
index 81103069b..c75d5cb51 100644
--- a/chapter_dynamic_programming/intro_to_dynamic_programming/index.html
+++ b/chapter_dynamic_programming/intro_to_dynamic_programming/index.html
@@ -2766,6 +2766,13 @@
14.1.3. 方法三:动态规划
+
+
+
-
+
+ 14.1.4. 状态压缩
+
+
@@ -2967,6 +2974,8 @@
+
+
@@ -3102,6 +3111,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3310,6 +3347,13 @@
14.1.3. 方法三:动态规划
+
+
+
-
+
+ 14.1.4. 状态压缩
+
+
@@ -4041,6 +4085,7 @@ dp[i] = dp[i-1] + dp[i-2]

Fig. 爬楼梯的动态规划过程
+
14.1.4. 状态压缩
细心的你可能发现,由于 \(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 b22452a69..5232888a6 100644
--- a/chapter_dynamic_programming/knapsack_problem/index.html
+++ b/chapter_dynamic_programming/knapsack_problem/index.html
@@ -2832,22 +2832,29 @@
@@ -3292,22 +3329,29 @@
-14.4.1. 方法一:暴力搜索
+方法一:暴力搜索
搜索代码包含以下要素:
- 递归参数:状态 \([i, c]\) ;返回值:子问题的解 \(dp[i, c]\) 。
@@ -3517,7 +3561,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])

Fig. 0-1 背包的暴力搜索递归树
-14.4.2. 方法二:记忆化搜索
+方法二:记忆化搜索
为了防止重复求解重叠子问题,我们借助一个记忆列表 mem 来记录子问题的解,其中 mem[i][c] 对应解 \(dp[i, c]\) 。
@@ -3689,7 +3733,7 @@ dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])

Fig. 0-1 背包的记忆化搜索递归树
-
14.4.3. 方法三:动态规划
+
方法三:动态规划
动态规划解法本质上就是在状态转移中填充 \(dp\) 表的过程,代码如下所示。
-
最后考虑状态压缩。以上代码中的数组 dp 占用 \(O(n \times cap)\) 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 \(O(n^2)\) 将低至 \(O(n)\) 。代码省略,有兴趣的同学可以自行实现。
+
状态压缩
+
最后考虑状态压缩。以上代码中的数组 dp 占用 \(O(n \times cap)\) 空间。由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 \(O(n^2)\) 将低至 \(O(n)\) 。代码省略,有兴趣的同学可以自行实现。
那么,我们是否可以仅用一个数组实现状态压缩呢?观察可知,每个状态都是由正上方或左上方的格子转移过来的。假设只有一个数组,当遍历到第 \(i\) 行时,该数组存储的仍然是第 \(i-1\) 行的状态,为了避免左方区域的格子在状态转移中被覆盖,应该采取倒序遍历。
以下动画展示了在单个数组下从第 \(i=1\) 行转换至第 \(i=2\) 行的过程。建议你思考一下正序遍历和倒序遍历的区别。
diff --git a/chapter_dynamic_programming/summary/index.html b/chapter_dynamic_programming/summary/index.html
index d49e693fe..fc9a7e0b0 100644
--- a/chapter_dynamic_programming/summary/index.html
+++ b/chapter_dynamic_programming/summary/index.html
@@ -2908,6 +2908,8 @@
+
+
@@ -3043,6 +3045,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3253,11 +3283,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/chapter_dynamic_programming/unbounded_knapsack_problem/index.html b/chapter_dynamic_programming/unbounded_knapsack_problem/index.html
index 6e8fd4847..9f6b27130 100644
--- a/chapter_dynamic_programming/unbounded_knapsack_problem/index.html
+++ b/chapter_dynamic_programming/unbounded_knapsack_problem/index.html
@@ -2864,6 +2864,26 @@
14.5.1. 完全背包问题
+
+
-
@@ -2871,6 +2891,26 @@
14.5.2. 零钱兑换问题
+
+
-
@@ -2878,6 +2918,26 @@
14.5.3. 零钱兑换问题 II
+
+
@@ -2967,6 +3027,8 @@
+
+
@@ -3102,6 +3164,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3296,6 +3386,26 @@
14.5.1. 完全背包问题
+
+
-
@@ -3303,6 +3413,26 @@
14.5.2. 零钱兑换问题
+
+
-
@@ -3310,6 +3440,26 @@
14.5.3. 零钱兑换问题 II
+
+
@@ -3359,6 +3509,7 @@
\[
dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])
\]
+
代码实现
对比两道题目的动态规划代码,状态转移中有一处从 \(i-1\) 变为 \(i\) ,其余完全一致。
+
状态压缩
由于当前状态是从左边和上边的状态转移而来,因此状态压缩后应该对 \(dp\) 表中的每一行采取正序遍历,这个遍历顺序与 0-1 背包正好相反。请通过以下动画来理解为什么要改为正序遍历。
@@ -3719,7 +3871,8 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
\]
第三步:确定边界条件和状态转移顺序
当目标金额为 \(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\) 个。
在最后返回前,判断 \(dp[n, amt]\) 是否等于 \(amt + 1\) ,若是则返回 \(-1\) ,代表无法凑出目标金额。
+状态压缩
由于零钱兑换和完全背包的状态转移方程如出一辙,因此状态压缩方式也相同。
@@ -4144,6 +4298,7 @@ dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]]
\]
当目标金额为 \(0\) 时,无需选择任何硬币即可凑出目标金额,因此应将所有 \(dp[i, 0]\) 都初始化为 \(1\) 。当无硬币时,无法凑出任何 \(>0\) 的目标金额,因此所有 \(dp[0, a]\) 都等于 \(0\) 。
+
代码实现
+
状态压缩
状态压缩处理方式相同,删除硬币维度即可。
diff --git a/chapter_graph/graph/index.html b/chapter_graph/graph/index.html
index d5fbdf38b..04df15f2a 100644
--- a/chapter_graph/graph/index.html
+++ b/chapter_graph/graph/index.html
@@ -2986,6 +2986,8 @@
+
+
@@ -3121,6 +3123,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_graph/graph_operations/index.html b/chapter_graph/graph_operations/index.html
index c21373f5f..c4409a122 100644
--- a/chapter_graph/graph_operations/index.html
+++ b/chapter_graph/graph_operations/index.html
@@ -2959,6 +2959,8 @@
+
+
@@ -3094,6 +3096,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_graph/graph_traversal/index.html b/chapter_graph/graph_traversal/index.html
index ace40082f..237aad392 100644
--- a/chapter_graph/graph_traversal/index.html
+++ b/chapter_graph/graph_traversal/index.html
@@ -2992,6 +2992,8 @@
+
+
@@ -3127,6 +3129,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_graph/index.html b/chapter_graph/index.html
index 0e588f7f6..c1c286731 100644
--- a/chapter_graph/index.html
+++ b/chapter_graph/index.html
@@ -2898,6 +2898,8 @@
+
+
@@ -3033,6 +3035,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_graph/summary/index.html b/chapter_graph/summary/index.html
index 10a99907a..c4e4fae0e 100644
--- a/chapter_graph/summary/index.html
+++ b/chapter_graph/summary/index.html
@@ -2945,6 +2945,8 @@
+
+
@@ -3080,6 +3082,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_greedy/fractional_knapsack_problem/index.html b/chapter_greedy/fractional_knapsack_problem/index.html
index 93fdce973..de4637643 100644
--- a/chapter_greedy/fractional_knapsack_problem/index.html
+++ b/chapter_greedy/fractional_knapsack_problem/index.html
@@ -2898,6 +2898,8 @@
+
+
@@ -2992,6 +2994,25 @@
+
+
@@ -3010,6 +3031,53 @@
+
+
+
+
@@ -3043,6 +3111,34 @@
+
+
+
+
+
+
-
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3226,6 +3322,42 @@
+
+
+
@@ -3256,7 +3388,7 @@

Fig. 分数背包问题的示例数据
-
第一步:问题分析
+
第一步:问题分析
本题和 0-1 背包整体上非常相似,状态包含当前物品 \(i\) 和容量 \(c\) ,目标是求不超过背包容量下的最大价值。
不同点在于,本题允许只选择物品的一部分,我们可以对物品任意地进行切分,并按照重量比例来计算物品价值,因此有:
@@ -3266,7 +3398,7 @@

Fig. 物品在单位重量下的价值
-第二步:贪心策略确定
+第二步:贪心策略确定
最大化背包内物品总价值,本质上是要最大化单位重量下的物品价值。由此便可推出本题的贪心策略:
- 将物品按照单位价值从高到低进行排序。
@@ -3276,6 +3408,7 @@

Fig. 分数背包的贪心策略
+代码实现
我们构建了一个物品类 Item ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解。
最差情况下,需要遍历整个物品列表,因此时间复杂度为 \(O(n)\) ,其中 \(n\) 为物品数量。由于初始化了一个 Item 对象列表,因此空间复杂度为 \(O(n)\) 。
-第三步:正确性证明
+第三步:正确性证明
采用反证法。假设物品 \(x\) 是单位价值最高的物品,使用某算法求得最大价值为 \(res\) ,但该解中不包含物品 \(x\) 。
现在从背包中拿出单位重量的任意物品,并替换为单位重量的物品 \(x\) 。由于物品 \(x\) 的单位价值最高,因此替换后的总价值一定大于 \(res\) 。这与 \(res\) 是最优解矛盾,说明最优解中必须包含物品 \(x\) 。
对于该解中的其他物品,我们也可以构建出上述矛盾。总而言之,单位价值更大的物品总是更优选择,这说明贪心策略是有效的。
diff --git a/chapter_greedy/greedy_algorithm/index.html b/chapter_greedy/greedy_algorithm/index.html
index 1071eb79e..c45a9f4ce 100644
--- a/chapter_greedy/greedy_algorithm/index.html
+++ b/chapter_greedy/greedy_algorithm/index.html
@@ -2898,6 +2898,8 @@
+
+
@@ -3109,6 +3111,34 @@
+
+
+
+
+
+ -
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chapter_greedy/index.html b/chapter_greedy/index.html
index 78fcc96ff..4541e67ef 100644
--- a/chapter_greedy/index.html
+++ b/chapter_greedy/index.html
@@ -2898,6 +2898,8 @@
+
+
@@ -3033,6 +3035,34 @@
+
+
+
+
+
+ -
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3261,6 +3291,7 @@
- 15.1 贪心算法
- 15.2 分数背包问题
- 15.3 最大容量问题
+- 15.4 最大切分乘积问题
diff --git a/chapter_greedy/max_capacity_problem/index.html b/chapter_greedy/max_capacity_problem/index.html
index 776b33949..da0ee5a9b 100644
--- a/chapter_greedy/max_capacity_problem/index.html
+++ b/chapter_greedy/max_capacity_problem/index.html
@@ -18,7 +18,7 @@
-
+
@@ -2898,6 +2898,8 @@
+
+
@@ -3020,6 +3022,25 @@
+
+
@@ -3038,6 +3059,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+ 15.4. 最大切分乘积问题
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3226,6 +3322,42 @@
+
+
+
@@ -3256,14 +3388,14 @@

Fig. 最大容量问题的示例数据
-
第一步:问题分析
+
第一步:问题分析
容器由任意两个隔板围成,因此本题的状态为两个隔板的索引,记为 \([i, j]\) 。
根据定义,容量等于高度乘以宽度,其中高度由短板决定,宽度是两隔板的索引之差。设容量为 \(cap[i, j]\) ,可得计算公式:
\[
cap[i, j] = \min(ht[i], ht[j]) \times (j - i)
\]
设数组长度为 \(n\) ,两个隔板的组合数量(即状态总数)为 \(C_n^2 = \frac{n(n - 1)}{2}\) 个。最直接地,我们可以穷举所有状态,从而求得最大容量,时间复杂度为 \(O(n^2)\) 。
-
第二步:贪心策略确定
+
第二步:贪心策略确定
当然,这道题还有更高效率的解法。如下图所示,现选取一个状态 \([i, j]\) ,其满足索引 \(i < j\) 且高度 \(ht[i] < ht[j]\) ,即 \(i\) 为短板、 \(j\) 为长板。

Fig. 初始状态
@@ -3318,7 +3450,8 @@ cap[i, j] = \min(ht[i], ht[j]) \times (j - i)