1. 动态规划进阶之路:背包问题的实战精解
作为一名算法工程师,我经常遇到这样的困惑:为什么看似简单的背包问题能成为大厂面试的常客?直到我在LeetCode上刷了上百道动态规划题目后才明白,背包问题考察的不仅是解题能力,更是对动态规划本质的理解深度。今天我们就来拆解动态规划训练中最关键的阶段——背包问题,这是从算法新手到熟手的重要分水岭。
背包问题之所以重要,是因为它完美体现了动态规划"状态转移"的核心思想。不同于常规的一维DP,背包问题引入了"容量"这个第二维度,让状态转移变得更加立体。在实际工程中,从资源分配到任务调度,很多问题都可以抽象为背包模型的变种。掌握这类问题,算法能力至少能提升30%的实战水平。
2. 背包问题核心框架解析
2.1 0-1背包的基础模型
标准的0-1背包问题描述为:给定容量为W的背包和N个物品,每个物品有重量weight[i]和价值value[i],如何选择物品使得背包总价值最大?每个物品只能选或不选(0或1)。
关键点在于DP数组的定义:
- dp[i][j] 表示从下标[0-i]的物品里任意取,放进容量为j的背包的最大价值
- 状态转移方程: dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
这个方程理解起来有技巧:当前状态只可能从两个方向转移而来:
- 不选当前物品:直接继承上一行同列的结果
- 选当前物品:取上一行减去当前物品重量位置的值加上当前价值
def knapsack_01(weights, values, capacity):
n = len(weights)
dp = [[0]*(capacity+1) for _ in range(n)]
# 初始化第一行
for j in range(capacity+1):
if j >= weights[0]:
dp[0][j] = values[0]
for i in range(1, n):
for j in range(1, capacity+1):
if j < weights[i]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i]] + values[i])
return dp[-1][-1]
关键技巧:在实际编码时,内层循环建议从大到小遍历(滚动数组优化时会解释原因),这样能避免同一物品被重复选取。
2.2 滚动数组的空间优化
二维DP表会消耗O(N*W)的空间,当容量较大时可能内存不足。观察状态转移方程发现,当前行只依赖上一行的数据,因此可以用一维数组优化:
def knapsack_01_optimized(weights, values, capacity):
dp = [0]*(capacity+1)
for i in range(len(weights)):
for j in range(capacity, weights[i]-1, -1): # 必须倒序遍历
dp[j] = max(dp[j], dp[j-weights[i]] + values[i])
return dp[capacity]
为什么必须倒序遍历?假设物品重量为1,正序遍历时:
- 计算dp[1]时会用到dp[0]
- 计算dp[2]时会用到已经更新过的dp[1]
- 这相当于同一物品被多次选取,变成了完全背包问题
3. 完全背包与多重背包变种
3.1 完全背包问题解析
完全背包与0-1背包的唯一区别是:每种物品可以选无限次。这个变化导致状态转移方程出现微妙差异:
def complete_knapsack(weights, values, capacity):
dp = [0]*(capacity+1)
for i in range(len(weights)):
for j in range(weights[i], capacity+1): # 正序遍历
dp[j] = max(dp[j], dp[j-weights[i]] + values[i])
return dp[capacity]
注意两个关键变化:
- 内层循环改为正序(允许重复选取)
- 遍历起点直接从weights[i]开始(小于时无法选取)
3.2 多重背包的二进制优化
多重背包限制每种物品最多选s[i]次。最直观的思路是把每种物品拆分成s[i]个独立物品,转为0-1背包。但当s[i]很大时效率低下。这时可以采用二进制优化:
def multiple_knapsack(weights, values, counts, capacity):
# 二进制拆分
new_weights = []
new_values = []
for i in range(len(weights)):
s = counts[i]
k = 1
while k <= s:
new_weights.append(weights[i] * k)
new_values.append(values[i] * k)
s -= k
k *= 2
if s > 0:
new_weights.append(weights[i] * s)
new_values.append(values[i] * s)
# 0-1背包求解
dp = [0]*(capacity+1)
for i in range(len(new_weights)):
for j in range(capacity, new_weights[i]-1, -1):
dp[j] = max(dp[j], dp[j-new_weights[i]] + new_values[i])
return dp[capacity]
这种优化将O(N S)的时间复杂度降为O(N logS),在处理大规模数据时优势明显。
4. 背包问题的经典变式实战
4.1 分割等和子集(LeetCode 416)
问题转化为:能否从数组中选出若干数,使其和等于总和的一半。这是典型的0-1背包问题,背包容量为sum/2,物品重量和价值都是nums[i]。
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]
4.2 最后一块石头的重量II(LeetCode 1049)
该问题可以转化为:将石头分成两堆,使两堆重量差最小。即背包容量为sum/2时能装的最大重量。
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]
max_weight = 0
for j in range(target, -1, -1):
if dp[j]:
max_weight = j
break
return total - 2*max_weight
4.3 目标和(LeetCode 494)
设加法组合为left,减法组合为right,则有: left - right = target left + right = sum => left = (target + sum) / 2
问题转化为:在nums中找出和为left的组合数,是典型的背包计数问题。
def findTargetSumWays(nums, target):
total = sum(nums)
if (target + total) % 2 != 0 or abs(target) > total:
return 0
bag_size = (target + total) // 2
dp = [0]*(bag_size+1)
dp[0] = 1
for num in nums:
for j in range(bag_size, num-1, -1):
dp[j] += dp[j-num]
return dp[bag_size]
5. 背包问题的调试技巧与常见陷阱
5.1 初始化陷阱
- 当背包必须装满时:dp[0]=0,其余初始化为-∞
- 当背包可以不装满时:全部初始化为0
# 必须装满的情况
def must_fill_knapsack(weights, values, capacity):
dp = [float('-inf')]*(capacity+1)
dp[0] = 0
for i in range(len(weights)):
for j in range(capacity, weights[i]-1, -1):
dp[j] = max(dp[j], dp[j-weights[i]] + values[i])
return dp[capacity] if dp[capacity] > 0 else 0
5.2 遍历顺序的玄机
- 组合问题(不考虑顺序):外层遍历物品,内层遍历容量
- 排列问题(考虑顺序):外层遍历容量,内层遍历物品
以零钱兑换II(组合)和零钱兑换(排列)为例:
# 518. 零钱兑换II(组合数)
def change(amount, coins):
dp = [0]*(amount+1)
dp[0] = 1
for coin in coins:
for j in range(coin, amount+1):
dp[j] += dp[j-coin]
return dp[amount]
# 322. 零钱兑换(最少硬币数)
def coinChange(coins, amount):
dp = [float('inf')]*(amount+1)
dp[0] = 0
for j in range(1, amount+1):
for coin in coins:
if j >= coin:
dp[j] = min(dp[j], dp[j-coin]+1)
return dp[amount] if dp[amount] != float('inf') else -1
5.3 打印DP表的调试技巧
当状态转移出现问题时,打印DP表是最直接的调试方法:
def debug_knapsack(weights, values, capacity):
dp = [[0]*(capacity+1) for _ in range(len(weights)+1)]
for i in range(1, len(weights)+1):
for j in range(1, capacity+1):
if j < weights[i-1]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i-1]]+values[i-1])
print(f"After item {i}: {dp[i]}") # 打印每行状态
return dp[-1][-1]
6. 工程中的背包问题应用实例
6.1 资源分配优化
假设服务器有8核CPU,需要部署多个服务,每个服务需要不同核数并产生不同收益:
services = [
{"name": "web", "cores": 2, "profit": 5},
{"name": "db", "cores": 4, "profit": 8},
{"name": "cache", "cores": 3, "profit": 6}
]
def deploy_services(services, total_cores):
dp = [0]*(total_cores+1)
service_choices = [[] for _ in range(total_cores+1)]
for service in services:
for j in range(total_cores, service["cores"]-1, -1):
if dp[j] < dp[j-service["cores"]] + service["profit"]:
dp[j] = dp[j-service["cores"]] + service["profit"]
service_choices[j] = service_choices[j-service["cores"]] + [service["name"]]
return dp[total_cores], service_choices[total_cores]
max_profit, selected = deploy_services(services, 8)
print(f"Max profit: {max_profit}, Services: {selected}")
6.2 任务调度策略
在有限的时间窗口内安排任务,每个任务有耗时和优先级:
tasks = [
{"id": 1, "duration": 2, "priority": 3},
{"id": 2, "duration": 1, "priority": 2},
{"id": 3, "duration": 3, "priority": 5}
]
def schedule_tasks(tasks, total_time):
dp = [0]*(total_time+1)
task_selection = [[] for _ in range(total_time+1)]
for task in tasks:
for j in range(total_time, task["duration"]-1, -1):
if dp[j] < dp[j-task["duration"]] + task["priority"]:
dp[j] = dp[j-task["duration"]] + task["priority"]
task_selection[j] = task_selection[j-task["duration"]] + [task["id"]]
max_priority = max(dp)
best_time = dp.index(max_priority)
return max_priority, task_selection[best_time]
priority, scheduled = schedule_tasks(tasks, 4)
print(f"Max priority: {priority}, Tasks: {scheduled}")
7. 背包问题的进阶训练建议
- 从二维DP开始理解 :先写出二维DP的完整解法,确保完全理解状态转移逻辑后再优化
- 手工模拟小例子 :用纸笔模拟DP表的填充过程,加深直观理解
- 对比不同变种 :将0-1背包、完全背包、多重背包的代码并列比较,观察差异点
-
尝试变形题目
:
- 求具体方案(而不仅是最大值)
- 存在依赖关系的背包(选A必须选B)
- 多维费用的背包问题(限制重量和体积)
- 参加周赛实战 :LeetCode周赛经常出现背包变形题,是检验学习成果的好机会
我个人的训练经验是:当你能在10分钟内正确写出背包问题的标准解法,并能解释清楚每个循环的意图时,就真正掌握了这类问题的精髓。建议至少完成20道相关题目,形成肌肉记忆。
本文系统解析0-1背包、完全背包和多重背包三大模型,重点阐述状态定义、转移方程及滚动数组空间优化原理;深入剖析遍历顺序(正序/倒序)对解法性质的影响;覆盖LeetCode 416、1049、494等经典变式,并指出初始化陷阱、DP表调试技巧;最后延伸至资源分配与任务调度等工程应用场景,强化动态规划本质理解。
1377

被折叠的 条评论
为什么被折叠?



