动态规划深度指南:从理论到 LeetCode 实践 (Java 版)
第一部分:动态规划的理论基石
1.1 动态规划的本质:概念与核心思想
动态规划(Dynamic Programming,简称 DP)作为一种强大的算法范式,广泛应用于数学、计算机科学、经济学等多个领域,旨在通过将一个复杂的全局问题分解为一系列相对简单的子问题来求解 1。它的核心思想并非简单地“分而治之”,而在于其独特的处理方式:若要解决一个问题,需要先解决其各个子部分。当这些子问题中存在大量相似或重复的情况时,动态规划的优势便体现得淋漓尽致 2。
动态规划方法的核心在于“记忆化”(Memoization)或“查表”(Tabulation)机制。一旦某个子问题的解被计算出来,它就会被存储在一个表格中,以便后续需要时可以直接查找,避免重复计算 2。这种方法特别适用于那些重复子问题数目随输入规模呈指数增长的问题,能够显著地将时间复杂度从指数级降低到多项式级,从而极大地提升计算效率 3。这一思想的起源可以追溯到运筹学和军事物流计划领域,美国数学家理查德·贝尔曼(Richard Bellman)选择了“动态”一词来捕捉问题随时间变化的方面,而“规划”则指的是寻找最佳“程序”或方案 3。这种方法论的名称本身就揭示了它在多阶段决策过程中的本质。
1.2 动态规划的适用场景:三大核心性质
动态规划并非万能解法,它只适用于满足特定结构特性的问题。这些特性共同构成了一个逻辑自洽的闭环,为动态规划的有效性提供了坚实的基础。
-
最优子结构(Optimal Substructure) :该性质指的是问题的最优解包含其子问题的最优解 3。这意味着可以通过求解局部最优解来逐步构建全局最优解。例如,在 0/1 背包问题中,前
i个物品的最优选择方案,一定包含了前i-1个物品在某个特定容量下的最优选择 3。如果一个问题的最优解无法由其子问题的最优解推导得出,那么动态规划便不适用。 -
无后效性(No After-effects) :无后效性要求子问题的解一旦确定,便不再受之后更大问题的决策过程影响 3。换言之,已经确定的子问题解是“稳定”的,可以被放心地存储和复用。以网格路径问题为例,从起点
(0,0)到达(i,j)的路径总数是一个确定的值,它并不会因为后续你如何从(i,j)移动到终点而改变。这一性质是动态规划“记忆化”机制得以成立的根本保障。 -
子问题重叠(Overlapping Subproblems) :这是动态规划与分治法最核心的区别 3。如果一个问题在用递归算法自顶向下求解时,会反复生成并计算同一个子问题,那么该问题就具有子问题重叠性质 3。动态规划正是为了解决这种重复计算而生的。如果子问题是相互独立的,就像在二分查找或归并排序中那样,那么简单的分治法就足够高效,没有必要引入额外的存储空间来记录结果。只有当子问题重叠达到一定规模时,动态规划的“查表”机制才能发挥其将指数时间复杂度降为多项式时间复杂度的巨大价值。
这三大性质共同决定了动态规划的适用范围。问题的最优子结构提供了分解为子问题的可能性;子问题重叠提供了使用动态规划的必要性,因为它揭示了朴素递归的低效;而无后效性则为记忆化存储提供了可靠性,确保了存储的子问题解可以被正确地复用。当且仅当一个问题同时满足这三个性质时,动态规划才能有效地解决它。
1.3 动态规划与其他算法的深度对比
理解动态规划与其他算法的区别,有助于更精确地选择解题策略。
与分治法(Divide and Conquer)的对比:
分治法和动态规划都将问题分解为子问题。但分治法的关键在于将问题分解为相互独立的子问题,然后递归地解决这些子问题并将它们的解合并起来,如归并排序和二分查找 5。相比之下,动态规划则专注于处理
重叠的子问题 5。对于那些子问题不重叠的问题,分治法通常是更简洁且高效的选择,因为它无需额外的存储空间。
与贪心算法(Greedy Algorithm)的对比:
贪心算法在每一步都做出局部最优的选择,希望最终能够得到一个全局最优解 5。它是一种短视的策略,不考虑未来的后果,也无法回溯 6。动态规划则通过系统地探索所有可能的子问题解来保证找到全局最优解 5。一个经典的例子是 0/1 背包问题。贪心法可能会选择单位价值最高的物品,但这并不一定能填满背包并获得最大总价值 7。而动态规划则通过考虑每一步的两种决策(放入或不放入)并选择最优解,最终确保获得全局最优 3。虽然贪心算法通常更快且实现更简单 5,但只有当问题满足特定的“贪心选择性质”时,它才能提供正确的解。在不确定局部最优能否推导出全局最优的情况下,动态规划是更可靠的选择。
第二部分:动态规划的实践方法论
2.1 解决动态规划问题的通用五步法
将理论应用于实践需要一个系统化的方法。以下是解决动态规划问题的通用五步法:
- 明确「状态」的定义:这是动态规划解题中最为关键且最容易出错的一步 8。状态必须充分抽象出子问题中所有必要的、影响后续决策的信息,例如当前处理到第几个物品、背包的剩余容量等 4。一个清晰且完备的状态定义是成功解题的基础。
- 找到「状态转移方程」:状态转移方程是一个递归关系式,它描述了当前状态的解如何通过一个或多个更小规模子问题的解计算得出 4。推导这个方程通常需要思考“最后一步”或“当前决策”是什么,以及这些决策如何将问题简化为更小的子问题。
- 确定「边界条件」:边界条件定义了最小、最简单子问题的解。这些是递归的终止条件或递推的初始值 9。正确地设定边界条件是确保算法正确性的前提。
- 确定「状态转移顺序」:在自底向上(递推)实现中,必须明确计算子问题的顺序,以确保在计算当前状态时,所有依赖的前置子问题都已经计算完毕 4。
- 选择合适的实现方式:根据问题的特点和性能要求,可以选择自顶向下(记忆化搜索)或自底向上(递推填表)来实现 11。
2.2 两种主流实现范式:递推 vs. 记忆化
动态规划问题通常可以通过两种方式实现:
-
自顶向下:记忆化搜索(Top-Down) :该方法从原问题开始,通过递归调用来解决子问题 11。为了避免重复计算,它使用一个查找表(如 Java 中的
int数组)来存储已计算过的子问题结果 12。这种方法的优点是代码更直观,与问题的递归定义紧密相连。它只计算那些实际需要的子问题,这在某些情况下可以节省不必要的计算 11。然而,它存在函数调用栈的开销,如果递归深度过大,可能会导致栈溢出 11。 -
自底向上:递推填表(Bottom-Up) :该方法从最简单的子问题开始,通过迭代循环逐步计算并填充整个 DP 表格,直到得到原问题的解 11。这种方法避免了递归调用的开销,通常在常数时间上更快 12,且没有栈溢出的风险 11。其缺点是必须计算整个 DP 表,即使其中某些子问题对最终解没有贡献 11。
下表总结了两种实现范式的关键区别:
| 对比维度 | 自顶向下(记忆化搜索) | 自底向上(递推填表) |
|---|---|---|
| 实现方式 | 递归 | 迭代(循环) |
| 代码直观性 | 通常更直观,与数学定义相似 | 需仔细定义迭代顺序和边界条件 |
| 性能 | 存在函数调用开销,常数时间上可能较慢 | 无递归开销,常数时间上通常更快 |
| 空间开销 | 占用递归调用栈空间,可能导致栈溢出 | 占用额外的数组空间,无栈溢出风险 |
| 子问题求解 | 只计算必需的子问题 | 必须计算所有子问题 |
选择哪种范式取决于具体问题。对于需要解决所有子问题的问题(例如网格路径),递推填表是自然且高效的选择。但对于一些稀疏的、只有少量子问题被访问的问题,记忆化搜索可能因其“惰性”计算而更优 11。
第三部分:案例精讲:LeetCode 经典问题解析 (Java 实现)
3.1 案例一:网格路径问题(LeetCode 62 & 63)
问题描述:一个机器人位于一个 m x n 网格的左上角 (0,0)。它只能向下或向右移动,目标是到达右下角 (m-1, n-1)。求总共有多少条不同的路径 10。
DP 分析:
- 状态定义:
dp[i][j]表示从(0,0)到达网格位置(i,j)的唯一路径总数 10。 - 状态转移方程:到达
(i,j)的唯一方式是从(i-1,j)向下移动,或者从(i,j-1)向右移动。因此,dp[i][j]等于这两条路径之和:dp[i][j] = dp[i-1][j] + dp[i][j-1]10。 - 边界条件:网格的第一行(
i=0)和第一列(j=0)上的所有位置都只有一条路径可达(只能向右或向下移动),因此dp[i] = 1和dp[j] = 110。 - LeetCode 63:带障碍物的变体:当网格中存在障碍物时,如果
grid[i][j]是一个障碍物(值为 1),那么dp[i][j]的路径数为 0 15。
Java 代码实现 (递推填表法) :
Java
class Solution {
public int uniquePaths(int m, int n) {
int dp = new int[m][n];
// 初始化第一行和第一列
for (int i = 0; i < m; i++) {
dp[i] = 1;
}
for (int j = 0; j < n; j++) {
dp[j] = 1;
}
// 填充DP表
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
}
空间优化(1D 数组):
由于 dp[i][j] 只依赖于 dp[i-1][j] 和 dp[i][j-1],即上一行和当前行的前一个元素,因此可以将二维数组优化为一维滚动数组,将空间复杂度从 O(mn) 降至 O(n) 14。
3.2 案例二:0/1 背包问题
问题描述:给定 N 件物品和一个容量为 W 的背包。每件物品有其重量 wi 和价值 vi。每件物品只能选择放入或不放入(0 或 1),目标是找到一种物品组合,使得在总重量不超过 W 的前提下,总价值达到最大 3。
DP 分析:
-
状态定义:
dp[i][c]表示前 i 个物品在背包容量为 c 时所能获得的最大价值 4。 -
状态转移方程:对于第 i 个物品,有两种决策:
-
不放入第 i 个物品:此时背包容量不变,最大价值等于前 i−1 个物品在容量 c 下的最大价值,即
dp[i-1][c]。 -
放入第 i 个物品:前提是背包容量 c 大于或等于第 i 个物品的重量 wi−1。此时,背包容量减少 wi−1,总价值增加 vi−1。最大价值等于
dp[i-1][c - w_{i-1}] + v_{i-1}。
-
综合两种情况,状态转移方程为:
dp[i][c] = max(dp[i-1][c], dp[i-1][c - w_{i-1}] + v_{i-1}) 4。
-
-
边界条件:
dp[c](没有物品可放)和dp[i](背包容量为0)的值都为 0 4。
Java 代码实现 (递推填表法) :
Java
public int knapSack(int W, int wt, int val, int n) {
int dp = new int[n + 1];
// 初始化,dp[...]和dp[...]都是0
for(int i = 0; i <= n; i++) {
for(int w = 0; w <= W; w++) {
if(i == 0 |
| w == 0) {
dp[i][w] = 0;
} else if (wt[i-1] <= w) {
dp[i][w] = Math.max(val[i-1] + dp[i-1][w-wt[i-1]], dp[i-1][w]);
} else {
dp[i][w] = dp[i-1][w];
}
}
}
return dp[n];
}
1D 空间优化的奥秘:
0/1 背包问题可以通过将二维 dp 表格优化为一维数组,将空间复杂度从 O(NW) 降至 O(W)。然而,这一优化要求内层循环必须从后向前遍历背包容量。这种特殊遍历顺序的逻辑严谨性在于:dp[c](当前物品的解)依赖于 dp[c-wgt](前一物品的解)。如果采用正向遍历,当我们计算 dp[c] 时,dp[c-wgt] 已经被当前循环更新,它代表的是“第 i 个物品”的解,而非“第 i-1 个物品”的解,这会导致同一件物品被多次放入背包,从而破坏了“0/1”的性质。从后向前遍历则可以确保,当我们计算 dp[c] 时,dp[c-wgt] 尚未被更新,它仍然保留着前一物品的解,从而保证了决策的正确性,避免了同一物品被重复选择 7。
3.3 案例三:最长递增子序列(LIS)
问题描述:给定一个整数数组,找到最长递增子序列的长度 17。子序列可以不连续,但必须保持原始顺序 18。
DP 分析:
- 状态定义:
dp[i]表示以nums[i]这个元素结尾的最长递增子序列的长度 17。 - 状态转移方程:要计算
dp[i],需要遍历i之前的所有元素nums[j](其中 0≤j<i)。如果nums[j] < nums[i],则nums[i]可以接在以nums[j]结尾的递增子序列后面,形成一个新的更长的递增子序列。因此,dp[i]的值等于1 + max(dp[j]),其中j满足 0≤j<i 且nums[j] < nums[i]$ 17。如果i之前没有比nums[i]小的元素,那么dp[i]` 的值为 1。 - 边界条件:
dp数组的所有元素初始值都为 1,因为每个元素本身都可以构成一个长度为 1 的递增子序列 17。
Java 代码实现:
Java
public int lengthOfLIS(int nums) {
if (nums == null |
| nums.length == 0) {
return 0;
}
int n = nums.length;
int dp = new int[n];
int max = 0;
// 初始化dp数组
for (int i = 0; i < n; i++) {
dp[i] = 1;
}
// 填充dp表
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
// 找到dp数组中的最大值
for (int i = 0; i < n; i++) {
if (dp[i] > max) {
max = dp[i];
}
}
return max;
}
3.4 案例四:最长公共子序列(LCS)
问题描述:给定两个字符串 text1 和 text2,返回它们最长公共子序列的长度 19。子序列可以不连续,但必须保持原始顺序 19。
DP 分析:
-
状态定义:
dp[i][j]表示text1的前 i 个字符与text2的前 j 个字符的最长公共子序列长度 19。 -
状态转移方程:
- 情况一:
text1的第 i 个字符等于text2的第 j 个字符。这意味着找到了一个公共字符,最长公共子序列的长度可以在text1的前 i−1 个字符和text2的前 j−1 个字符的最长公共子序列的基础上加 1。因此,dp[i][j] = dp[i-1][j-1] + 119。 - 情况二:
text1的第 i 个字符不等于text2的第 j 个字符。此时,最长公共子序列不包含这两个字符。需要考虑两种可能性:要么是text1的前 i−1 个字符与text2的前 j 个字符的 LCS,要么是text1的前 i 个字符与text2的前 j−1 个字符的 LCS。因此,dp[i][j] = max(dp[i-1][j], dp[i][j-1])19。
- 情况一:
-
边界条件:为了简化状态转移方程,通常会构建一个
(m+1) x (n+1)的dp数组。这样,dp数组的第 0 行和第 0 列都可以被初始化为 0,它们代表空字符串的情况 19。
Java 代码实现 (递推填表法) :
Java
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int dp = new int[m + 1][n + 1];
// 填充dp表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
}
最长公共子序列问题的解法模式具有很强的通用性。它展示了处理两个序列的典型动态规划思路:通过比较两个序列的最后一个元素,将问题分解为更小的子问题。这种模式可以推广到其他经典的字符串问题,例如编辑距离(Edit Distance)等 19。
动态规划案例精讲:从线性到树形的深度剖析
解决任何动态规划问题通常遵循以下系统化步骤:
- 定义
dp数组的物理意义:明确dp[i]或dp[i][j]等状态变量所代表的实际含义,这是整个解题过程的基石。一个清晰、严谨的状态定义是正确推导后续步骤的前提。 - 推导状态转移方程:基于状态定义,找出子问题之间的递推关系。这通常涉及对当前状态的“决策”进行穷举,并选择能达到最优结果的那条路径。
- 确定边界条件:为
dp数组的初始状态赋值,为整个递推过程提供正确的起点。 - 确定遍历顺序:确保在计算当前状态时,其所依赖的所有子问题都已经得到解决。
- 空间优化:如果可能,根据状态转移方程的依赖关系,将多维
dp数组压缩至更小的维度,以减少内存占用。
第一章:线性动态规划——串行世界的决策
线性动态规划是处理一维序列或数组问题的基础。其特点是状态通常只与前一个或前几个状态相关。
案例一:经典打家劫舍系列 (House Robber)
这是一个极具代表性的线性DP问题,通过其三个变体,可以深入理解DP思想的灵活性和适应性。
问题 1.1:直线型街区 (House Robber I)
问题描述:给定一个整数数组nums,代表一排房屋中存放的金额。由于相邻房屋的安保系统相连,如果连续抢劫两间相邻房屋就会触发警报。目标是求出在不触动警报的前提下,能抢劫到的最大总金额 2。
核心思想与状态定义:问题的本质是对每间房屋做出“抢”或“不抢”的决策。由于一个决策会影响相邻房屋,我们可以将问题分解为子问题。一个自然的状态定义是dp[i],代表考虑前i间房屋时能够抢到的最大金额。
状态转移方程:对于第i间房屋,有两种互斥的选择:
-
抢劫第
i间房屋:如果选择抢劫当前房屋nums[i],则根据规则,不能抢劫其前一间房屋i-1。因此,最大金额是nums[i]加上在考虑前i-2间房屋时能抢到的最大金额,即nums[i] + dp[i-2]。 -
不抢劫第i间房屋:如果选择不抢劫当前房屋,则最大金额等同于在考虑前i-1间房屋时能抢到的最大金额,即dp[i-1]。
我们的目标是求最大值,因此状态转移方程为dp[i] = max(nums[i] + dp[i-2], dp[i-1]) 3。
解法演进与实现:
-
暴力递归:直接将上述决策逻辑转化为递归函数
helper(i),其时间复杂度为指数级O(2^n)5。因为在计算helper(i)时,会重复计算helper(i-1)和helper(i-2),以及更小的子问题,造成了大量的冗余计算 5。 -
递归备忘录(Top-Down) :为了解决重叠子问题,可以使用一个哈希表或数组来缓存
helper(i)的结果。每次调用函数前先检查结果是否已缓存,若有则直接返回。这种方法将时间复杂度优化到O(n)5。 -
自底向上(Bottom-Up) :这是动态规划更典型的实现方式。创建一个
dp数组,从最小的子问题开始填充。- 边界条件:
dp = nums(只抢第一间);dp[1] = max(nums, nums[1])(抢前两间中的一间) 5。 - 递推:从
i=2开始,循环计算dp[i],直到n-1。最终结果为dp[n-1]3。
- 边界条件:
-
空间优化:观察状态转移方程,
dp[i]只依赖于dp[i-1]和dp[i-2]。这意味着我们无需存储整个dp数组。可以只用两个变量prev和curr来分别代表dp[i-2]和dp[i-1]。在每次迭代中,计算新的最大值newRob = max(prev + nums[i], curr),然后更新prev = curr,curr = newRob。这种优化将空间复杂度从O(n)降至O(1)3。
问题 1.2:环形街区 (House Robber II)
问题描述:与问题1.1类似,但房屋排列成一个环形,即第一间和最后一间房屋相邻 6。
核心洞见:环形结构的核心约束在于首尾房屋不能同时被抢。这使得问题不能直接套用线性的DP解法。然而,这种约束为我们提供了将复杂问题分解为简单子问题的关键线索。如果不能同时抢劫首尾,那就意味着:
- 要么不抢第一间房屋,然后问题就退化为一个在
[1, n-1]范围内求解的线性打家劫舍问题。 - 要么不抢最后一间房屋,然后问题就退化为一个在
[0, n-2]范围内求解的线性打家劫舍问题。
这两个子问题是互斥且涵盖了所有情况的,因为最终结果必然不包含首尾,或者只包含其中之一。因此,我们只需要对这两个线性子问题分别应用问题1.1中的动态规划解法,然后取二者的最大值即可 6。这完美地展示了模块化思维:将一个看似复杂的新问题,通过巧妙的转换,复用已有的、成熟的基本DP算法单元来解决。
第二章:二维动态规划——矩阵与背包的艺术
二维DP通常用于解决涉及两个独立变化的维度的问题,例如网格遍历或背包容量与物品数量。
案例二:0/1 背包问题 (0/1 Knapsack)
问题描述:给定n个物品,每个物品有固定的重量w_i和价值v_i。还有一个容量为W的背包。每个物品只能取一次,目标是在总重量不超过W的前提下,使背包中物品的总价值最大化 9。
核心思想:对于每个物品,我们有两个选择:“放入”或“不放入”。这种决策过程可以通过一个二维表格来记录所有子问题的解。
状态定义:dp[i][w]表示在前i个物品中进行选择,且背包容量为w时,所能获得的最大总价值 14。
状态转移方程:对于第i个物品(索引i-1),其重量为wt[i-1],价值为val[i-1]。
-
不取第
i个物品:此时最大价值等于在前i-1个物品中、容量仍为w时所能获得的最大价值,即dp[i-1][w]。 -
取第i个物品:只有当w >= wt[i-1]时,才能考虑放入。放入后,背包剩余容量为w - wt[i-1]。此时最大价值等于当前物品的价值加上在前i-1个物品中、剩余容量下所能获得的最大价值,即val[i-1] + dp[i-1][w - wt[i-1]]。
综合两种选择,最终的状态转移方程为:
dp[i][w]=max(dp[i−1][w],val[i−1]+dp[i−1][w−wt[i−1]])(当时)
否则,dp[i][w]=dp[i−1][w] 9。
深度洞见:为何空间优化必须逆向循环?
0/1背包问题的空间复杂度可以从O(nW)优化到O(W),但这需要对内层循环的顺序有深刻理解。当我们将二维dp[i][w]压缩为一维dp[w]时,dp[w]的状态代表的是当前正在考虑的物品i在容量w下的最优解。而状态转移方程dp[w] = max(dp[w], val[i-1] + dp[w - wt[i-1]])中的dp[w - wt[i-1]]需要引用上一轮(即物品i-1)的状态。
如果内层循环(w从1到W)是正向的,那么当计算dp[w]时,dp[w - wt[i-1]]可能已经在当前物品i的循环中被更新。这会导致我们重复使用同一个物品,从而违反了“0/1”背包的核心约束,即每个物品只能使用一次 16。
因此,必须使用逆向循环(w从W到wt[i-1])。通过这种方式,当计算dp[w]时,其所依赖的dp[w - wt[i-1]]值尚未被当前物品i的迭代所更新,它依然保留着上一次迭代(即物品i-1)的最优解,从而保证了每个物品只被考虑一次 16。
案例三:完全背包问题 (Complete Knapsack)
问题描述:与0/1背包类似,但每个物品可以被无限次选取 9。
核心思想:区别在于“放入”一个物品后,我们仍然可以再次放入它。这使得状态转移方程的依赖关系发生了根本变化。
状态转移方程:
dp[i][w]=max(dp[i−1][w],val[i−1]+dp[i][w−wt[i−1]])(当时)
核心区别在于第二项,它引用的是当前行的数值dp[i][w - wt[i-1]],而不是上一行的dp[i-1][w - wt[i-1]]。这种依赖关系恰好体现了“可以重复选取”的特性。
深度洞见:为何空间优化必须正向循环?
与0/1背包相反,完全背包在空间优化时必须使用正向循环。当我们将dp[i][w]压缩为一维dp[w],并使用正向循环(w从wt[i-1]到W)时,在计算dp[w] = max(dp[w], val[i-1] + dp[w - wt[i-1]])时,dp[w - wt[i-1]]的值已经是在当前物品i的循环中更新过的最优解。这种更新链条使得我们可以将多个相同的物品放入背包,例如,为了计算容量为10的背包,我们可以使用一个容量为5的背包的最优解,而这个5的背包的最优解可能已经包含了当前物品,这正是完全背包问题所要求的 17。
核心对比表格:背包问题空间优化循环方向深度对比
| 问题类型 | 核心约束 | 空间优化循环方向 | 核心原理 |
|---|---|---|---|
| 0/1 背包 | 每个物品只能用一次 | 逆向循环 (W -> wt[i]) | 确保dp[w-wt[i-1]]引用上一轮状态,避免重复选取。 |
| 完全背包 | 每个物品可重复用 | 正向循环 (wt[i] -> W) | 确保dp[w-wt[i-1]]引用当前轮状态,允许重复选取。 |
第三章:区间动态规划——对弈与区间最优解
区间动态规划是一种解决关于一个序列的连续子区间优化问题的DP方法。它的状态通常定义为dp[i][j],代表子序列[i...j]上的最优解。
案例四:石子游戏系列 (Stone Game)
这类问题将DP与博弈论结合,考验对对手最优决策的预判。
问题 4.1:Stone Game I
问题描述:给定一排偶数堆石子,总数是奇数。玩家轮流从两端取一堆,直到取完,最终石子总数多者胜。假设双方都采取最优策略,判断先手玩家是否必胜 19。
核心洞见:这个看似复杂的博弈问题有一个精妙的数学结论。由于石子总数为奇数且堆数为偶数,石子堆可以被分为两组:奇数索引的堆和偶数索引的堆。先手玩家(Alice)总能通过最优策略控制自己只取奇数索引的堆,或者只取偶数索引的堆。由于总和为奇数,这两组石子总数必然不同。Alice只需比较两组的总和,并选择总和更大的那一组进行取石子,从而确保自己获胜。因此,这是一个不依赖于DP而仅依赖于数学性质的“先手必胜”问题 19。这说明在应用DP前,对问题性质的分析至关重要。
问题 4.2:Stone Game II
问题描述:玩家轮流从最左边取X堆石子,其中1 <= X <= 2M。取完后,M更新为max(M, X)。初始M=1。求先手玩家能获得的最大石子数 21。
核心思想:这是一个真正的区间DP与博弈论结合的问题。游戏状态由剩余石子堆的起始位置和当前的M值共同决定。因此,需要一个二维dp数组来捕捉所有状态。
状态定义:dp[i][M]表示从索引i开始的剩余石子堆中,当前玩家在M值为M的情况下,所能获得的最大石子数。
状态转移:当前玩家(假设是Alice)面临决策:从1到2M中选择一个X值取走X堆。她的目标是最大化自己的收益。当她取走X堆后,下一个玩家(Bob)会面临一个新局面,即从索引i+X开始的石子堆,M值更新为max(M, X)。Bob的目标是最大化他自己的收益。
Alice的收益 = (当前取走的石子) + (Bob取完后,剩余石子中Alice能拿到的部分)。
这可以进一步分解为:
dp[i][M] = max(1 <= X <= 2M) { (piles[i...i+X-1]的总和) + (piles[i+X...n-1]中对方能取到的最小石子数)。
要找到对方能取到的最小石子数,需要知道对方能取到的最大石子数。这需要一个更复杂的状态定义,例如dp[i][j].fir和dp[i][j].sec来分别记录先手和后手玩家在区间[i, j]中能获得的最大分数 20。
深度洞见:
- 区间DP的遍历顺序:为了计算一个长区间
[i, j]的解,需要先知道所有比它短的子区间(如[i+1, j]和[i, j-1])的解。因此,区间DP通常采用自底向上的遍历方式,即从区间长度L=1开始,逐次递增,直到L=n22。 - 博弈论与DP的结合:在双人博弈中,一个玩家的最优选择取决于对对手最优选择的预判。这通常通过递归关系来实现:当前玩家的最大收益等于在所有可能的选择中,
max(我这次的收益+对手在剩余局面能拿到的最小收益) 20。
第四章:树形动态规划——非线性结构的决策树
树形DP是DP在树状数据结构上的应用,其状态转移通常依赖于父子或祖孙节点之间的关系。
案例五:打家劫舍 III (House Robber III)
问题描述:房屋的排列结构构成一棵二叉树,父子节点不能同时抢劫。求在不触发警报的前提下,能抢劫到的最大总金额 2。
核心思想:每个节点(房屋)都面临“抢”或“不抢”的决策。这个决策不仅影响当前节点,还对它的子节点产生直接约束。
解法演进:
-
暴力递归:一个简单的递归函数
rob(node)会尝试两种情况:抢node或不抢node。如果抢node,则递归调用rob(node.left.left)、rob(node.left.right)等孙子节点。如果不抢node,则递归调用rob(node.left)和rob(node.right)。这种方法存在大量重复计算,时间复杂度为O(2^n)25。 -
深度洞见:后序遍历与成对返回:
- 后序遍历的必然性:树形DP的关键在于,要计算一个父节点的最优解,必须首先知道其所有子节点的最优解。这与树的**后序遍历(左-右-根)**的自然顺序完全吻合 26。因此,树形DP通常以这种方式自底向上进行计算。
- 为何成对返回:单一的返回值无法携带足够的信息。例如,父节点在做决策时,需要知道其左、右子树在“被抢”和“不被抢”两种情况下的各自最优解。因此,一个优雅的解决方案是让每个递归调用返回一个包含两个值的数组或元组:
[rob_curr, not_rob_curr],其中rob_curr表示抢劫当前节点时能获得的最大收益,not_rob_curr表示不抢劫当前节点时能获得的最大收益 25。
状态转移方程:
-
抢劫当前节点node:如果抢劫当前节点,其两个直接子节点(left和right)都不能被抢。因此,收益为node.val加上左子节点“不被抢”的收益和右子节点“不被抢”的收益。
rob_curr=node.val+not_rob(left)+not_rob(right) 24
-
不抢劫当前节点node:如果不抢劫当前节点,其子节点可以被抢也可以不被抢。为了最大化收益,我们应选择每个子节点“被抢”和“不被抢”两种情况中较大的那个。
not_rob_curr=max(rob(left),not_rob(left))+max(rob(right),not_rob(right)) 24
这个成对返回的DFS方法,完美地解决了父子节点间的依赖约束,且每个节点只会被访问一次,将时间复杂度降至O(n) 24。
总结与展望
本报告通过详尽的案例分析,系统地展示了动态规划在不同数据结构上的应用。从线性序列到环形变体,从二维表格的背包问题到树形结构的非线性约束,每一种DP类型都遵循着“定义状态、推导方程、确定边界和遍历顺序”这一核心范式。
- 线性DP(如打家劫舍)解决了串行决策问题,其空间优化依赖于对状态依赖关系的敏锐洞察。
- 二维DP(如背包问题)解决了双维度的选择问题,其空间优化循环方向的微妙差异体现了对问题核心约束(0/1 vs. 完全)的深刻理解。
- 区间DP(如石子游戏)解决了序列子区间上的最优解问题,并展示了如何将博弈论的决策逻辑融入DP状态转移。
- 树形DP(如打家劫舍III)解决了非线性结构上的依赖性问题,其成对返回的DFS方法是处理父子节点约束的典范。
动态规划的精髓并不仅仅在于记忆化或制表等具体实现技巧,更在于其分解问题、识别模式的思维模式。它是一种将复杂性化为可管理子问题的强大工具。掌握了这些典型案例和其背后的思想,可以为解决更广泛领域的问题打下坚实的基础,例如生物信息学中的基因序列比对、金融模型中的期权定价以及复杂系统的资源分配等。动态规划不仅仅是一种算法,更是一种解决优化问题的哲学。
附录:动态规划问题类型速查表
| DP 类型 | 典型问题 | 状态定义 | 遍历顺序 | 关键思考点 |
|---|---|---|---|---|
| 线性DP | 打家劫舍 I/II | dp[i] | 正向 | 非相邻选择;环形问题分解为线性子问题。 |
| 二维DP (背包) | 0/1 背包/完全背包 | dp[i][w] | 正向(完全)或逆向(0/1) | 有限/无限物品选择;空间优化循环方向的物理意义。 |
| 区间DP | 石子游戏 II | dp[i][j] | 按区间长度从小到大 | 连续子区间最优解;对弈中对手的最优策略。 |
| 树形DP | 打家劫舍 III | pair<int, int> dfs(node) | 后序遍历 | 父子节点依赖关系;成对返回两种决策状态。 |