1. 动态规划核心思想与应用场景
动态规划(Dynamic Programming)是算法设计中解决最优化问题的重要方法。它通过将复杂问题分解为相互重叠的子问题,并存储子问题的解来避免重复计算,从而显著提高算法效率。
1.1 动态规划的本质特征
动态规划适用于具有以下两个关键特征的问题:
- 最优子结构:问题的最优解包含其子问题的最优解
- 重叠子问题:不同的子问题会重复求解相同的更小子问题
以背包问题为例,当我们考虑是否将当前物品放入背包时,需要基于之前物品的选择结果做出决策,这正是动态规划发挥作用的典型场景。
1.2 动态规划的解题框架
标准动态规划解题包含五个关键步骤:
- 确定dp数组及下标含义
- 确定递推公式
- 初始化dp数组
- 确定遍历顺序
- 举例推导验证
这个框架将贯穿我们后续对三个经典问题的解析。
2. 最后一块石头的重量问题(1049题)
2.1 问题重述与转化
给定一堆石头,每次选择两块粉碎,求最后剩下石头的最小可能重量。这个问题可以转化为经典的背包问题:
如何将石头分成两堆,使两堆的重量差最小?
设总重量为sum,我们需要找到一堆石头,其重量尽可能接近sum/2。这样两堆的重量差(sum - dp[target]) - dp[target] = sum - 2*dp[target]就是最小的剩余重量。
2.2 动态规划解法详解
2.2.1 一维DP实现
python复制class Solution:
def lastStoneWeightII(self, stones):
total_sum = sum(stones)
target = total_sum // 2
dp = [False] * (target + 1)
dp[0] = True # 重量0总是可以达到
for stone in stones:
for j in range(target, stone - 1, -1):
dp[j] = dp[j] or dp[j - stone]
for i in range(target, -1, -1):
if dp[i]:
return total_sum - 2 * i
return 0
关键点解析:
dp[j]表示能否凑出重量j- 内层循环倒序避免重复计算
- 最后从大到小查找最大的可达重量
2.2.2 二维DP实现
python复制class Solution:
def lastStoneWeightII(self, stones: List[int]) -> int:
total_sum = sum(stones)
target = total_sum // 2
dp = [[False] * (target + 1) for _ in range(len(stones) + 1)]
for i in range(len(stones) + 1):
dp[i][0] = True
for i in range(1, len(stones) + 1):
for j in range(1, target + 1):
if stones[i - 1] > j:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = dp[i - 1][j] or dp[i - 1][j - stones[i - 1]]
for i in range(target, -1, -1):
if dp[len(stones)][i]:
return total_sum - 2 * i
return 0
对比分析:
- 一维DP空间复杂度O(target),二维DP空间复杂度O(n*target)
- 一维DP更节省内存但理解难度稍高
- 二维DP更直观体现状态转移过程
2.3 复杂度分析与优化
时间复杂度:O(ntarget)
空间复杂度:一维O(target),二维O(ntarget)
优化技巧:
- 提前计算总重量,排除不可能情况
- 使用位运算优化布尔数组操作
- 对于小规模数据可以优先考虑记忆化搜索
3. 目标和问题(494题)
3.1 问题转化与数学推导
给定数组nums和目标S,通过加减号组合使表达式结果为S。这个问题可以转化为:
找到子集P和N,使得sum(P) - sum(N) = S
又因为sum(P) + sum(N) = sum(nums)
所以sum(P) = (S + sum(nums)) / 2
因此问题转化为:找出和为(S + sum(nums))/2的子集数目。
3.2 动态规划解法实现
3.2.1 二维DP实现
python复制class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
total_sum = sum(nums)
if abs(S) > total_sum or (S + total_sum) % 2 != 0:
return 0
target = (S + total_sum) // 2
n = len(nums)
dp = [[0] * (target + 1) for _ in range(n)]
# 处理第一个数字
if nums[0] <= target:
dp[0][nums[0]] += 1
dp[0][0] += 1 # 不选第一个数字
for i in range(1, n):
for j in range(target + 1):
dp[i][j] = dp[i-1][j]
if j >= nums[i]:
dp[i][j] += dp[i-1][j - nums[i]]
return dp[n-1][target]
3.2.2 一维DP优化
python复制class Solution:
def findTargetSumWays(self, nums: List[int], target: int) -> int:
total_sum = sum(nums)
if abs(target) > total_sum or (target + total_sum) % 2 == 1:
return 0
target_sum = (target + total_sum) // 2
dp = [0] * (target_sum + 1)
dp[0] = 1
for num in nums:
for j in range(target_sum, num - 1, -1):
dp[j] += dp[j - num]
return dp[target_sum]
3.3 边界条件与特殊处理
- 总和检查:若|S| > sum(nums),直接返回0
- 奇偶检查:(S + sum(nums))必须为偶数
- 零的处理:当nums[i]=0时,选与不选都影响结果
特别注意:初始化时dp[0][0]需要特殊处理,因为不选任何元素也是一种方案
4. 一和零问题(474题)
4.1 问题理解与三维转化
给定二进制字符串数组,限制0和1的数量,求最大子集大小。这是典型的二维费用背包问题:
- 每个字符串有两个"费用":0的个数和1的个数
- 背包有两个容量限制:m和n
- 价值统一为1(计数)
4.2 动态规划解法实现
4.2.1 三维DP基础版
python复制class Solution:
def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
dp = [[[0] * (n + 1) for _ in range(m + 1)] for _ in range(len(strs) + 1)]
for i in range(1, len(strs) + 1):
zeros = strs[i-1].count('0')
ones = len(strs[i-1]) - zeros
for j in range(m + 1):
for k in range(n + 1):
if j >= zeros and k >= ones:
dp[i][j][k] = max(dp[i-1][j][k], dp[i-1][j-zeros][k-ones] + 1)
else:
dp[i][j][k] = dp[i-1][j][k]
return dp[len(strs)][m][n]
4.2.2 二维DP空间优化
python复制class Solution:
def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
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]
4.3 关键实现细节
- 预处理计数:提前计算每个字符串的0和1数量
- 倒序遍历:确保每个字符串只被使用一次
- 边界处理:当0或1数量不足时直接继承之前状态
性能对比:
- 三维DP:时间复杂度O(lmn),空间复杂度O(lmn)
- 二维DP:时间复杂度O(lmn),空间复杂度O(m*n)
5. 动态规划问题解题技巧总结
5.1 识别动态规划问题的特征
- 求最值:如最大值、最小值、最优解等
- 可分解性:问题可以分解为相似子问题
- 无后效性:当前决策不影响之前的状态
5.2 状态定义与转移方程设计
- 明确状态变量:如背包问题中的容量、物品索引
- 确定状态表示:dp[i][j]的具体含义
- 建立状态转移:如何从已知状态推导新状态
5.3 空间优化策略
- 滚动数组:交替使用数组空间
- 降维处理:将多维DP压缩到更低维度
- 状态压缩:使用位运算表示状态
5.4 常见错误与调试技巧
- 初始化错误:特别是边界条件的处理
- 遍历顺序错误:如背包问题中内外层循环顺序
- 状态转移遗漏:考虑不完整所有可能性
调试建议:打印dp表格,手动验证前几步的状态转移
6. 动态规划进阶训练建议
6.1 经典问题系列
-
背包问题系列:
- 01背包
- 完全背包
- 多重背包
- 分组背包
-
路径问题:
- 最小路径和
- 不同路径
- 带障碍物的路径
-
序列问题:
- 最长递增子序列
- 编辑距离
- 最大子数组和
6.2 训练方法
- 分类练习:按问题类型集中训练
- 对比练习:相似问题的不同解法对比
- 逐步提升:从记忆化搜索到递推实现
6.3 实战建议
- 画图辅助:绘制状态转移图
- 小规模验证:手动计算简单用例
- 复杂度分析:预估算法性能
在实际编码面试中,建议先明确状态定义和转移方程,再考虑空间优化。清晰的解题思路比直接写优化代码更重要。