ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

动态规划三大经典问题解析:背包变种与多维DP实战

动态规划三大经典问题解析:背包变种与多维DP实战

1. 算法训练营核心题目解析

今天要深入探讨的是动态规划领域的三个经典问题:最后一块石头的重量II、目标和、一和零。这三个题目看似独立,实则都体现了动态规划"将复杂问题分解为子问题"的核心思想。作为参加过多次算法竞赛的老手,我发现很多初学者容易在这些题目上陷入"暴力解法"的误区,而忽视了动态规划的精妙之处。

1.1 题目背景与难点分析

1049.最后一块石头的重量II实际上是背包问题的变种,关键在于将石头分成两堆,使两堆的重量差最小。494.目标和则需要我们找到所有能使数字前添加+或-后结果等于目标值的组合数。474.一和零则是一个典型的二维背包问题,需要考虑0和1两个维度的限制。

这三个题目的共同难点在于:

  1. 如何识别问题中的"状态"和"选择"
  2. 如何定义dp数组的含义
  3. 如何推导状态转移方程
  4. 如何初始化dp数组
  5. 如何确定遍历顺序

提示:动态规划问题的解决往往需要从暴力解法开始思考,然后寻找重叠子问题和最优子结构,最后进行优化。

2. 1049.最后一块石头的重量II详解

2.1 问题重述与转化

题目描述:有一堆石头,每块石头的重量都是正整数。每次从中选出两块最重的石头进行粉碎,如果重量相同则都消失,否则留下差值。重复这个过程直到只剩一块或没有石头,返回最后石头的重量。

这道题可以转化为:将石头分成两堆,使两堆的重量差最小。这实际上就是一个背包问题,背包的容量是总重量的一半,我们需要尽可能装满这个背包。

2.2 动态规划解法实现

def lastStoneWeightII(stones): total = sum(stones) target = total // 2 dp = [0] * (target + 1) for stone in stones: for j in range(target, stone - 1, -1): dp[j] = max(dp[j], dp[j - stone] + stone) return total - 2 * dp[target]

关键点解析:

  1. dp[j]表示容量为j的背包能装的最大重量
  2. 外层循环遍历所有石头
  3. 内层循环从大到小遍历背包容量(防止重复计算)
  4. 最终结果是总重量减去两倍的最大装载量

2.3 复杂度分析与优化

时间复杂度:O(n×m),其中n是石头数量,m是总重量的一半 空间复杂度:O(m)

优化技巧:

  1. 可以先对石头进行排序,提前终止循环
  2. 可以使用位运算优化空间

3. 494.目标和深度解析

3.1 问题转化与数学推导

题目要求:给定一个非负整数数组和一个目标数S,可以在每个数字前添加+或-,使得表达式结果等于S,求有多少种添加方法。

这个问题可以转化为:找到子集P和N,使得sum(P) - sum(N) = S。又因为sum(P) + sum(N) = sum(nums),所以可以得到sum(P) = (S + sum(nums)) / 2。这样问题就转化为在nums中找到和为(S + sum(nums))/2的子集数。

3.2 动态规划解法实现

def findTargetSumWays(nums, S): total = sum(nums) if (total + S) % 2 != 0 or total < abs(S): return 0 target = (total + S) // 2 dp = [0] * (target + 1) dp[0] = 1 for num in nums: for j in range(target, num - 1, -1): dp[j] += dp[j - num] return dp[target]

关键点解析:

  1. dp[j]表示和为j的子集数
  2. 初始化dp[0]=1表示空集
  3. 状态转移方程:dp[j] += dp[j - num]
  4. 需要先检查边界条件

3.3 常见错误与调试技巧

常见错误:

  1. 没有检查(S + sum(nums))是否能被2整除
  2. 没有考虑S可能为负数的情况
  3. 初始化不正确

调试技巧:

  1. 打印dp数组的中间状态
  2. 使用小规模测试用例验证
  3. 注意数组越界问题

4. 474.一和零多维背包问题

4.1 问题理解与建模

题目要求:给定一个字符串数组和两个整数m和n,找出并返回该数组的最大子集大小,该子集中最多有m个0和n个1。

这是一个典型的二维背包问题,需要考虑0和1两个维度的限制。每个字符串相当于一个物品,0和1的数量相当于物品的两个维度上的"重量"。

4.2 动态规划解法实现

def findMaxForm(strs, m, n): dp = [[0] * (n + 1) for _ in range(m + 1)] for s in strs: zeros = s.count('0') ones = len(s) - zeros for i in range(m, zeros - 1, -1): for j in range(n, ones - 1, -1): dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1) return dp[m][n]

关键点解析:

  1. dp[i][j]表示使用i个0和j个1能构成的最大子集大小
  2. 需要先统计每个字符串的0和1数量
  3. 两层循环分别从大到小遍历0和1的容量
  4. 状态转移方程:dp[i][j] = max(dp[i][j], dp[i-zeros][j-ones]+1)

4.3 性能优化与空间压缩

优化技巧:

  1. 预处理所有字符串的0和1计数
  2. 可以进一步优化空间复杂度
  3. 根据字符串长度进行排序可能有助于提前终止

5. 动态规划解题通用框架

5.1 四步解题法

根据多年刷题经验,我总结出解决动态规划问题的四步法:

  1. 定义dp数组的含义
  2. 找出状态转移方程
  3. 确定初始条件
  4. 确定遍历顺序

5.2 常见问题类型与对应策略

  1. 背包问题:01背包、完全背包、多重背包
  2. 子序列问题:最长递增子序列、编辑距离
  3. 打家劫舍问题:线性、环形
  4. 股票问题:多种交易限制

5.3 调试与验证技巧

  1. 使用小规模测试用例
  2. 打印dp数组的中间状态
  3. 对比暴力解法的结果
  4. 注意边界条件处理

6. 实战经验与避坑指南

在实际刷题过程中,我积累了一些宝贵的经验:

  1. 对于背包问题,一定要分清是求最大价值还是方案数
  2. 遍历顺序非常重要,01背包通常需要从大到小遍历
  3. 初始化dp数组时要考虑边界条件
  4. 多维dp问题可以画表格帮助理解
  5. 不要过早优化,先保证正确性再考虑效率

注意:动态规划问题往往有多种解法,不要局限于一种思路。多思考不同解法的优缺点,才能融会贯通。

7. 进阶学习路径建议

想要在动态规划领域更进一步,我建议:

  1. 从经典的背包问题开始,掌握01背包、完全背包等基本模型
  2. 学习状态压缩技巧,优化空间复杂度
  3. 研究树形DP、状压DP等高级技巧
  4. 参加算法竞赛,实战中提升能力
  5. 定期复习经典题目,总结解题模板

我个人在刷完LeetCode上所有动态规划题目后,发现很多题目其实都是经典模型的变种。掌握核心思想后,解题效率会大幅提升。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进