1. 动态规划专题:从背包问题到多维约束
今天我们来深入探讨三个经典的动态规划问题:1049最后一块石头的重量II、494目标和、474一和零。这三个问题看似不同,实则都源于背包问题的变种,非常适合用来理解动态规划的核心思想。
动态规划(Dynamic Programming)作为算法设计中的重要方法,特别适合解决具有最优子结构和重叠子问题特性的问题。在解决这类问题时,我们通常需要:
- 定义dp数组及其下标的含义
- 确定递推公式
- 初始化dp数组
- 确定遍历顺序
- 举例推导dp数组
提示:理解背包问题的本质是解决这类问题的关键。背包问题本质上是在有限容量的约束下,如何选择物品以达到某种最优目标。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 1049最后一块石头的重量II:背包问题的巧妙转化
2.1 问题重述与理解
题目描述:有一堆石头,用整数数组stones表示,其中stones[i]表示第i块石头的重量。每一回合,从中选出任意两块石头,将它们一起粉碎。假设石头的重量分别为x和y,且x<=y,那么粉碎的可能结果如下:
- 如果x==y,那么两块石头都会被完全粉碎
- 如果x!=y,那么重量为x的石头将会完全粉碎,而重量为y的石头新重量为y-x
最后,最多只会剩下一块石头。返回此石头最小的可能重量。如果没有石头剩下,就返回0。
2.2 问题分析与转化
这个问题看似与背包问题无关,但经过分析可以发现其本质是一个背包问题。关键在于理解:
- 每次粉碎石头相当于给石头分配正负号
- 我们需要将这些石头分成两堆,使得两堆的重量差最小
- 这等价于在总重量为sum的石头中,选择一些石头,使得它们的总重量尽可能接近sum/2
因此,问题转化为:从stones数组中选择若干石头,使得它们的总重量不超过sum/2且尽可能大。这正是经典的0-1背包问题。
2.3 动态规划解法实现
python复制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]
这个解法的时间复杂度是O(n×sum),空间复杂度是O(sum),其中n是石头的数量,sum是所有石头的总重量。
注意:这里的内层循环需要从大到小遍历,这是0-1背包问题的典型特征,确保每个物品只被使用一次。
3. 494目标和:背包问题的计数变种
3.1 问题描述与初步分析
题目描述:给定一个非负整数数组nums和一个整数target。向数组中的每个整数前添加'+'或'-',然后串联起所有整数,可以构造一个表达式。返回可以通过上述方法构造的、运算结果等于target的不同表达式的数目。
示例:
输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3
3.2 数学转化与背包模型
这个问题可以转化为:
设添加'+'的数字和为pos,添加'-'的数字和为neg,则有:
pos - neg = target
pos + neg = sum(nums)
由此可得:
pos = (target + sum(nums)) / 2
因此,问题转化为:在nums数组中找出若干数字,使它们的和等于(target + sum(nums))/2,计算这样的组合数。
3.3 动态规划实现
python复制def findTargetSumWays(nums, target):
total = sum(nums)
if (target + total) % 2 != 0 or abs(target) > total:
return 0
pos = (target + total) // 2
dp = [0] * (pos + 1)
dp[0] = 1
for num in nums:
for j in range(pos, num - 1, -1):
dp[j] += dp[j - num]
return dp[pos]
这个解法的时间复杂度是O(n×pos),空间复杂度是O(pos)。注意这里dp数组初始化为0,但dp[0]初始化为1,表示和为0的组合数为1(即不选任何数字)。
4. 474一和零:二维约束的背包问题
4.1 问题描述与特点
题目描述:给你一个二进制字符串数组strs和两个整数m和n。请你找出并返回strs的最大子集的大小,该子集中最多有m个0和n个1。
示例:
输入:strs = ["10","0001","111001","1","0"], m = 5, n = 3
输出:4
解释:最多有5个0和3个1的最大子集是{"10","0001","1","0"},因此答案是4。
4.2 二维背包问题分析
这是一个典型的二维背包问题,与标准背包问题不同的是,这里有两个约束条件(0的数量和1的数量),而不是一个。每个字符串相当于一个物品,具有两个"重量"维度(0的个数和1的个数),价值都是1(即计数)。
4.3 动态规划解法
python复制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]
这个解法的时间复杂度是O(l×m×n),其中l是字符串数组的长度,空间复杂度是O(m×n)。这里使用了三维降二维的技巧,通过逆序遍历来优化空间。
5. 三个问题的对比与总结
5.1 问题共性与差异
这三个问题虽然表面不同,但都是背包问题的变种:
- 1049题:标准0-1背包问题的转化应用
- 494题:背包问题的计数变种
- 474题:二维约束的背包问题
它们的共同点是都需要:
- 将原问题转化为背包问题的形式
- 定义适当的dp数组
- 确定状态转移方程
- 考虑遍历顺序和初始化
5.2 动态规划解题的通用思路
通过这三个问题,我们可以总结出解决动态规划问题的一般步骤:
- 确定dp数组及其下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组(用于验证)
5.3 实际编码中的注意事项
在实际编写代码时,有几个常见的陷阱需要注意:
- 遍历顺序:0-1背包问题通常需要逆序遍历,完全背包则需要正序遍历
- 初始化:根据问题需求正确初始化dp数组,特别是边界条件
- 空间优化:考虑是否可以使用滚动数组或降维技巧
- 问题转化:有些问题需要巧妙的数学转化才能看出背包问题的本质
6. 扩展思考与练习建议
6.1 相关题目推荐
为了加深对这类问题的理解,建议练习以下题目:
- 416.分割等和子集
- 518.零钱兑换II
- 377.组合总和IV
- 322.零钱兑换
6.2 性能优化思考
对于背包问题,当sum很大时,传统的动态规划方法可能会遇到性能问题。可以考虑:
- 提前终止:当找到最优解时可以提前结束
- 剪枝策略:根据问题特性设计剪枝条件
- 记忆化搜索:有时递归+记忆化的方式更直观
6.3 从背包问题到更复杂的动态规划
掌握了背包问题后,可以进一步学习:
- 树形DP:在树结构上应用动态规划
- 状态压缩DP:使用位运算等技巧表示状态
- 区间DP:解决区间划分类问题
- 数位DP:解决数字位操作相关问题
在实际工程中,动态规划思想也有广泛应用,如资源分配、路径规划、序列比对等领域。理解这些基础问题的解法,有助于我们在面对更复杂问题时能够举一反三。
