1. 动态规划核心概念与训练营定位
这个算法训练营的第36天聚焦于动态规划的中阶内容,特别针对背包问题和零钱兑换这两个经典应用场景。动态规划作为算法领域的核心方法论,其重要性不亚于数据结构中的二叉树或图论中的最短路径算法。在实际工程和面试场景中,动态规划问题出现的频率居高不下,尤其是背包类问题几乎成为大厂笔试的"必考题"。
为什么选择在第36天讲解这部分内容?因为动态规划需要先掌握递归、记忆化搜索等前置知识。经过前35天的训练,学员应该已经具备了:递归思维的能力、状态转移方程的初步理解、以及基础DP问题的解决经验。part05作为动态规划专题的中间章节,起着承上启下的作用——既巩固01背包的基础,又为后续的多维背包、树形DP等复杂变种做准备。
特别提示:很多学员反映在背包问题上容易形成"一看就会,一写就废"的状态。建议在学习本节时,准备好纸笔手动推导2-3个例题的状态转移过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 背包问题系统解析
2.1 01背包的三种实现方式
经典的01背包问题描述为:给定容量为W的背包和N个物品,每个物品有重量w[i]和价值v[i],求不超过背包容量的最大价值总和。
基础版二维DP实现:
python复制def knapsack_01(W, weights, values):
n = len(weights)
dp = [[0]*(W+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(1, W+1):
if weights[i-1] <= j:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i-1]] + values[i-1])
else:
dp[i][j] = dp[i-1][j]
return dp[n][W]
空间优化的一维滚动数组:
python复制def knapsack_01_optimized(W, weights, values):
dp = [0]*(W+1)
for i in range(len(weights)):
for j in range(W, weights[i]-1, -1): # 必须逆向遍历
dp[j] = max(dp[j], dp[j-weights[i]] + values[i])
return dp[W]
记忆化搜索的递归实现:
python复制def knapsack_01_memo(W, weights, values):
memo = {}
def dfs(index, remaining):
if (index, remaining) in memo:
return memo[(index, remaining)]
if index == len(weights):
return 0
not_take = dfs(index+1, remaining)
take = 0
if remaining >= weights[index]:
take = dfs(index+1, remaining-weights[index]) + values[index]
memo[(index, remaining)] = max(not_take, take)
return memo[(index, remaining)]
return dfs(0, W)
三种实现方式各有优劣:
- 二维DP最直观但空间复杂度O(NW)
- 一维优化空间复杂度O(W)但遍历顺序有讲究
- 记忆化搜索思维负担小但递归栈有开销
2.2 完全背包与多重背包变种
完全背包的特点是每种物品可以选无限次,只需将01背包的内层循环改为正向遍历:
python复制def complete_knapsack(W, weights, values):
dp = [0]*(W+1)
for i in range(len(weights)):
for j in range(weights[i], W+1): # 正向遍历
dp[j] = max(dp[j], dp[j-weights[i]] + values[i])
return dp[W]
多重背包则是每种物品有数量限制s[i],可以通过二进制优化转化为01背包:
python复制def multiple_knapsack(W, weights, values, counts):
# 二进制优化拆分
new_weights = []
new_values = []
for w, v, s in zip(weights, values, counts):
k = 1
while k <= s:
new_weights.append(w * k)
new_values.append(v * k)
s -= k
k *= 2
if s > 0:
new_weights.append(w * s)
new_values.append(v * s)
# 01背包解法
dp = [0]*(W+1)
for i in range(len(new_weights)):
for j in range(W, new_weights[i]-1, -1):
dp[j] = max(dp[j], dp[j-new_weights[i]] + new_values[i])
return dp[W]
2.3 背包问题的六个常见变式
- 恰好装满问题:初始化时dp[0]=0,其余设为-∞
- 方案计数问题:将max改为sum,初始化dp[0]=1
- 二维费用背包:增加一维状态表示第二个约束条件
- 分组背包:每组物品只能选一个,外层遍历组,内层遍历容量
- 依赖背包:物品之间存在依赖关系,可转化为树形DP
- 最优方案回溯:通过额外的path数组记录选择路径
3. 零钱兑换专题精讲
3.1 经典零钱问题两种问法
问法一:求最少硬币数(LeetCode 322)
python复制def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount+1):
dp[i] = min(dp[i], dp[i-coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
问法二:求组合数(LeetCode 518)
python复制def change(amount, coins):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for i in range(coin, amount+1):
dp[i] += dp[i-coin]
return dp[amount]
关键区别:
- 最少硬币数是"最值型"DP,采用min操作
- 组合数是"计数型"DP,采用累加操作
- 遍历顺序也会影响结果是排列数还是组合数
3.2 零钱问题的三个进阶变种
- 打印最少硬币的方案:维护一个coin_used数组记录最后使用的硬币
python复制def coinChange_with_path(coins, amount):
dp = [float('inf')] * (amount + 1)
coin_used = [-1] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount+1):
if dp[i-coin] + 1 < dp[i]:
dp[i] = dp[i-coin] + 1
coin_used[i] = coin
if dp[amount] == float('inf'):
return []
path = []
remaining = amount
while remaining > 0:
path.append(coin_used[remaining])
remaining -= coin_used[remaining]
return path
- 限制硬币使用次数:转化为多重背包问题
- 面值包含负数:需要调整状态转移的范围和边界条件
4. 动态规划的调试技巧
4.1 状态转移可视化方法
对于二维DP表格,推荐使用如下打印函数调试:
python复制def print_dp_table(dp):
for row in dp:
print(" ".join(f"{x:3}" for x in row))
print()
示例调试过程:
python复制weights = [2,3,4,5]
values = [3,4,5,6]
W = 8
n = len(weights)
dp = [[0]*(W+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(1, W+1):
if weights[i-1] <= j:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i-1]] + values[i-1])
else:
dp[i][j] = dp[i-1][j]
print(f"After processing item {i}:")
print_dp_table(dp)
4.2 常见错误排查清单
- 数组越界:检查状态转移时的下标是否可能为负
- 初始化错误:特别是边界条件dp[0]的设置
- 遍历顺序错误:一维DP的内层循环方向是否正确
- 状态转移遗漏:所有可能的选择是否都被考虑
- 精度问题:涉及浮点数时的比较要设置epsilon
5. 实战题目解析
5.1 目标和问题(LeetCode 494)
问题转化为背包问题的技巧:
设正数和为x,负数和绝对值为y,则有:
x - y = target
x + y = sum(nums)
=> x = (target + sum(nums)) / 2
转化为求子集和为x的方案数:
python复制def findTargetSumWays(nums, target):
total = sum(nums)
if (target + total) % 2 != 0 or target > total:
return 0
x = (target + total) // 2
dp = [0] * (x + 1)
dp[0] = 1
for num in nums:
for i in range(x, num - 1, -1):
dp[i] += dp[i - num]
return dp[x]
5.2 一和零问题(LeetCode 474)
二维费用背包的典型应用:
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]
6. 动态规划的优化方向
6.1 时间复杂度优化技巧
- 单调队列优化:适用于决策单调性问题
- 斜率优化:处理特定形式的状态转移方程
- 四边形不等式:优化区间DP的决策点
6.2 空间复杂度优化模式
- 滚动数组:将二维DP降为一维
- 状态压缩:用位运算表示状态
- 离散化:当状态值范围很大但实际取值稀疏时
7. 面试常见问题与回答策略
面试官常问的DP相关问题:
- "如何判断一个问题能否用动态规划解决?"
- 回答要点:最优子结构、重叠子问题、无后效性
- "什么时候用记忆化搜索,什么时候用递推?"
- 回答要点:记忆化适合状态转移复杂的情况,递推适合状态规整的情况
- "如何设计状态表示?"
- 回答策略:从问题本身提取关键维度,尽量精简状态
在训练营的教学实践中发现,学员最容易在以下几个方面犯错:
- 混淆背包问题的内外层循环顺序
- 忽视初始化对结果的影响
- 不能准确识别问题中的"物品"和"容量"对应关系
- 对空间优化的条件理解不透彻
建议每个经典题型至少手写3遍实现:第一遍参照模板,第二遍自行实现,第三遍尝试不同的初始条件和遍历顺序。动态规划的本质是"聪明的暴力",需要足够的练习才能培养出状态设计的直觉。
