1. 动态规划专题训练概述
今天我们要啃下动态规划这块硬骨头的第四部分内容。作为算法训练营第35天的课程,这部分内容在动态规划知识体系中扮演着承上启下的关键角色。如果你已经掌握了前三天的基础背包问题,那么今天我们将进入更富挑战性的应用阶段。
动态规划part04通常会聚焦两个核心方向:一是背包问题的变种与扩展,二是状态转移方程的优化技巧。根据我的教学经验,这是许多学员从"理解算法"到"灵活运用"的关键转折点。我们会遇到一些看似简单但暗藏玄机的问题,比如"分割等和子集"、"最后一块石头的重量"这类经典题目。
重要提示:动态规划的学习切忌贪多求快,每个问题都要吃透状态定义和转移方程的内在逻辑。今天的课程结束后,你应该能够独立分析中等难度的DP问题,并写出至少两种不同时间复杂度的解法。
2. 核心问题解析与状态定义
2.1 分割等和子集问题
这是动态规划中经典的"背包问题"变种。给定一个只包含正整数的非空数组,判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
状态定义技巧:
- 首先计算数组总和sum,如果sum为奇数直接返回false
- 目标转化为:能否从数组中选出若干数,使其和为sum/2
- dp[i][j]表示前i个物品中能否选出和为j的子集
python复制def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
优化点:
- 空间优化:使用一维数组替代二维
- 提前终止:当发现某个j满足条件时可立即返回
- 排序优化:先排序可以提前排除不可能情况
2.2 最后一块石头的重量II
这个问题看似与动态规划无关,实则暗藏背包问题的本质。题目描述:有一堆石头,每块石头的重量都是正整数。每次任意选两块石头相撞,粉碎后剩下重量差。求最后剩下的最小可能重量。
问题转化思路:
- 将石头分成两堆,使两堆重量差最小
- 转化为背包问题:背包容量为sum/2,尽可能装满
- 最终结果为sum - 2*dp[target]
python复制def lastStoneWeightII(stones):
total = sum(stones)
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for stone in stones:
for j in range(target, stone - 1, -1):
dp[j] = dp[j] or dp[j - stone]
for j in range(target, -1, -1):
if dp[j]:
return total - 2*j
return total
3. 状态转移方程深度剖析
3.1 目标和问题
给定一个非负整数数组和一个目标数S,你有两种符号+和-,对于每个整数可以选择任意符号,求有多少种组合方式使最终结果为S。
状态定义突破点:
- 设正数和为x,负数和为y,则x - y = S
- 又x + y = sum(nums) => x = (S + sum) / 2
- 问题转化为:找出和为(S+sum)/2的子集数
python复制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]
关键细节:
- 边界条件检查:(S+sum)必须为偶数
- 初始化dp[0]=1表示空集方案数为1
- 遍历顺序必须从后往前避免重复计算
3.2 一和零问题
这是一个二维费用的背包问题。给定一个字符串数组,每个字符串由若干个0和1组成。给定m个0和n个1,求最多能组成多少个字符串。
状态定义创新:
- dp[i][j][k]表示前i个字符串使用j个0和k个1时的最大数量
- 可以优化为二维dp[j][k]
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 j in range(m, zeros - 1, -1):
for k in range(n, ones - 1, -1):
dp[j][k] = max(dp[j][k], dp[j-zeros][k-ones]+1)
return dp[m][n]
性能优化:
- 预处理每个字符串的0和1数量
- 三维降二维的空间优化
- 倒序遍历避免覆盖问题
4. 动态规划优化技巧实战
4.1 滚动数组优化
在背包问题中,我们经常可以使用滚动数组将空间复杂度从O(n^2)降到O(n)。以分割等和子集为例:
python复制# 原始二维DP
dp = [[False]*(target+1) for _ in range(len(nums)+1)]
dp[0][0] = True
# 优化为一维DP
dp = [False]*(target+1)
dp[0] = True
为什么可以这样优化:
观察状态转移方程发现,当前行只依赖上一行的数据。因此只需保留一行数据,从后往前更新即可。
4.2 状态转移剪枝
在某些问题中,我们可以通过预处理或提前终止来优化:
- 排序优化:先对数组排序可以提前终止不必要的计算
- 和值检查:计算总和后立即排除不可能情况
- 中间结果缓存:记忆化递归有时比迭代更直观
4.3 问题转化思维
动态规划最难的部分往往是将实际问题转化为标准模型。例如:
- 将石头问题转化为背包问题
- 将目标和问题转化为子集和问题
- 识别隐藏的背包条件(如0和1的数量限制)
5. 常见错误与调试技巧
5.1 初始化陷阱
典型错误:
- 忘记初始化dp[0]=True或dp[0]=1
- 错误处理边界条件(如sum为奇数)
- 数组大小设置不当(应该是target+1而非target)
调试方法:
- 打印DP表的中间状态
- 从小规模测试用例开始验证
- 检查状态转移方程是否覆盖所有情况
5.2 遍历顺序错误
背包问题遍历顺序规则:
- 0-1背包:内层循环倒序(防止重复选择)
- 完全背包:内层循环正序(允许重复选择)
- 多维背包:从外到内依次考虑每个维度
5.3 状态转移遗漏
检查清单:
- 是否考虑了不选当前物品的情况?
- 是否正确处理了超出容量的情况?
- 多个状态转移时是否用max/min正确合并?
6. 实战训练建议
6.1 推荐练习题目
-
基础巩固:
- LeetCode 416. 分割等和子集
- LeetCode 1049. 最后一块石头的重量II
-
进阶挑战:
- LeetCode 494. 目标和
- LeetCode 474. 一和零
-
综合应用:
- LeetCode 879. 盈利计划
- LeetCode 518. 零钱兑换II
6.2 解题步骤模板
- 分析问题是否具有最优子结构
- 定义状态(明确dp数组的含义)
- 确定状态转移方程
- 考虑初始化条件
- 确定遍历顺序
- 举例推导验证
6.3 个人心得分享
在动态规划训练中,我强烈建议:
- 建立自己的"DP问题-模型"映射表
- 对每道题至少写出两种实现(递归+迭代)
- 用纸笔模拟小规模案例的执行过程
- 整理常见状态定义模板(如背包问题的几种变体)
动态规划的学习曲线虽然陡峭,但一旦突破这个瓶颈,你会发现很多难题都能迎刃而解。今天的四个经典问题已经涵盖了背包问题的主要变种,建议反复练习直到能够独立写出bug-free的代码。