1. 动态规划专题训练营Day36核心内容解析
今天我们要啃下动态规划中几个经典硬骨头:背包问题、零钱兑换和组合总和。这些题目不仅是算法面试中的常客,更是检验动态规划掌握程度的试金石。我结合自己刷题300+的经验,把容易踩坑的地方和优化技巧都标注出来了。
1.1 为什么这些题目值得专项突破
背包问题被称为动态规划的"必修课",它的变体遍布各大互联网公司的笔试环节。根据力扣2023年企业题库统计,背包类题目在字节跳动出现频率高达37%,而零钱兑换问题则是美团、拼多多等电商相关企业的偏爱题型。
特别提醒:很多同学觉得能写出状态转移方程就算掌握了,实际上在时间复杂度优化和空间复杂度优化上,这些题目都设置了多重考察维度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 背包问题系统精讲
2.1 01背包的两种实现方式对比
先看最基础的01背包问题。给定背包容量W,物品列表items(每个物品有重量w和价值v),求不超过背包容量的最大价值。
二维DP解法(适合理解原理):
python复制def knapsack_01_2d(W, items):
n = len(items)
dp = [[0]*(W+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(1, W+1):
if items[i-1][0] > j:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-items[i-1][0]] + items[i-1][1])
return dp[n][W]
一维优化解法(面试必写):
python复制def knapsack_01_1d(W, items):
dp = [0]*(W+1)
for w, v in items:
for j in range(W, w-1, -1): # 注意这里是倒序遍历
dp[j] = max(dp[j], dp[j-w] + v)
return dp[W]
关键点说明:
- 二维解法中dp[i][j]表示前i件物品在容量j时的最大价值
- 一维优化利用了"当前层只依赖上一层"的特性,将空间复杂度从O(nW)降到O(W)
- 内层循环必须倒序,否则会重复计算(变成完全背包)
2.2 完全背包与多重背包
完全背包(物品无限取用)与01背包的核心区别仅在于遍历顺序:
python复制# 完全背包的一维解法(仅改动遍历方向)
def knapsack_unbounded(W, items):
dp = [0]*(W+1)
for w, v in items:
for j in range(w, W+1): # 正序遍历
dp[j] = max(dp[j], dp[j-w] + v)
return dp[W]
多重背包(物品有限定个数)的二进制优化技巧:
python复制def knapsack_bounded(W, items):
# items格式:(重量, 价值, 最大数量)
dp = [0]*(W+1)
for w, v, cnt in items:
# 二进制拆分优化
k = 1
while k <= cnt:
for j in range(W, w*k-1, -1):
dp[j] = max(dp[j], dp[j-w*k] + v*k)
cnt -= k
k *= 2
if cnt > 0:
for j in range(W, w*cnt-1, -1):
dp[j] = max(dp[j], dp[j-w*cnt] + v*cnt)
return dp[W]
避坑指南:当背包容量极大(比如1e5)时,可以考虑改变DP定义,用dp[v]表示达到价值v所需的最小重量,这在一些变种问题中很有效。
3. 零钱兑换问题的三种变体
3.1 最少硬币数(LeetCode 322)
给定不同面额的硬币coins和总金额amount,计算凑成总金额所需的最少硬币数。
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
易错点:
- 初始值设为无穷大(表示不可达)
- 遍历顺序:先遍历硬币还是先遍历金额?两种写法都可以但含义不同
- 返回值需要处理无法凑出的情况
3.2 组合方式数(LeetCode 518)
计算可以凑成总金额的硬币组合数。这是完全背包的"装满背包有几种方法"问题。
python复制def change(amount, coins):
dp = [0] * (amount + 1)
dp[0] = 1 # 金额为0时有1种方案(什么都不选)
for coin in coins:
for i in range(coin, amount + 1):
dp[i] += dp[i - coin]
return dp[amount]
关键理解:
- dp[0]=1是这类问题的通用初始化方式
- 这里先遍历物品再遍历容量,得到的是组合数
- 如果调换遍历顺序,得到的是排列数(LeetCode 377题)
3.3 最少硬币数的所有方案
如果需要输出具体方案而不仅仅是数量,可以增加path记录:
python复制def coinChangeWithPath(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
path = [[] for _ in range(amount + 1)]
path[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
path[i] = [x + [coin] for x in path[i - coin]]
elif dp[i - coin] + 1 == dp[i]:
path[i].extend([x + [coin] for x in path[i - coin]])
return path[amount] if dp[amount] != float('inf') else []
4. 组合总和问题的高效解法
4.1 组合总和III(LeetCode 216)
找出所有相加之和为n的k个数的组合,组合中只允许1-9的数字且不重复。
python复制def combinationSum3(k, n):
res = []
def backtrack(start, path, remaining_k, target):
if remaining_k == 0 and target == 0:
res.append(path.copy())
return
if remaining_k == 0 or target <= 0:
return
for num in range(start, 10):
path.append(num)
backtrack(num + 1, path, remaining_k - 1, target - num)
path.pop()
backtrack(1, [], k, n)
return res
剪枝优化点:
- target <= 0时提前终止
- 循环范围根据remaining_k调整上限:9 → 9 - remaining_k + 1
- 当剩余数字全部取最大仍不足target时提前返回
4.2 组合总和IV(LeetCode 377)
这是排列问题而非组合问题,需要用动态规划:
python复制def combinationSum4(nums, target):
dp = [0] * (target + 1)
dp[0] = 1
for i in range(1, target + 1):
for num in nums:
if i >= num:
dp[i] += dp[i - num]
return dp[target]
与组合问题的区别:
- 这里先遍历容量再遍历物品,得到的是考虑顺序的解
- 如果nums中有负数会导致无限循环,实际题目通常限制为正整数
5. 动态规划的调试技巧与性能优化
5.1 如何验证DP表的正确性
-
打印DP表:对于二维DP,用pandas.DataFrame更直观
python复制import pandas as pd print(pd.DataFrame(dp)) -
边界检查:特别注意i=0和j=0的初始化是否正确
-
小规模测试:先用题目给的示例验证,再构造边缘用例
5.2 空间优化时的常见错误
- 遍历顺序错误:01背包必须倒序,完全背包必须正序
- 维度压缩时覆盖了还需要使用的值
- 错误地复用DP数组(如多重背包的二进制优化)
5.3 时间复杂度优化策略
- 提前终止:当发现最优解已经不可能被超越时提前返回
- 离散化处理:当数值范围很大但有效状态很少时
- 数学优化:利用问题特性减少状态转移次数
6. 面试实战中的高频考点
根据近半年算法面试反馈,动态规划题目的考察重点集中在:
-
状态定义能力(60%的面试官会追问)
- 如何想到用这个维度定义状态?
- 为什么这个状态可以包含所有必要信息?
-
边界条件处理(35%的扣分点)
- 初始值设置是否合理?
- 数组越界如何处理?
-
空间优化思路(高级岗位必问)
- 能否进一步优化空间复杂度?
- 为什么可以这样优化?
-
变种问题识别(区分度最高的部分)
- 这个问题和哪个经典问题相似?
- 需要做哪些调整来适配新条件?
我在面试候选人时最看重的不是最终代码,而是在白板上推导状态转移方程的过程。建议每天手写推导2-3个DP问题的完整状态转移表,坚持两周会有质的飞跃。
