1. 完全背包问题深度解析
动态规划中的背包问题一直是算法学习中的重点和难点。今天我想和大家详细探讨完全背包问题及其几个经典变种,包括零钱兑换II、组合总和IV以及爬楼梯问题的进阶版本。
1.1 完全背包与01背包的本质区别
完全背包问题和01背包问题的核心区别在于物品是否可以重复选择。在01背包中,每个物品只能选择一次(要么选要么不选),而完全背包允许无限次选择同一个物品。
这个区别在代码实现上体现为遍历顺序的不同:
- 01背包:内层循环从大到小遍历(倒序)
- 完全背包:内层循环从小到大遍历(正序)
这种遍历顺序的差异确保了在完全背包中,一个物品可以被多次选择。举个例子,假设背包容量为5,物品重量为2,在完全背包中:
- 第一次循环:dp[2] = max(dp[2], dp[0]+value)
- 第二次循环:dp[4] = max(dp[4], dp[2]+value)
这样同一个物品就被考虑了两次。
1.2 完全背包的标准实现
让我们先看完全背包的标准Python实现:
python复制n, bag_weight = map(int, input().split())
weight = []
value = []
for _ in range(n):
w, v = map(int, input().split())
weight.append(w)
value.append(v)
dp = [0] * (bag_weight+1)
for i in range(n):
for j in range(weight[i], bag_weight+1):
dp[j] = max(dp[j], dp[j-weight[i]] + value[i])
print(dp[bag_weight])
这段代码有几个关键点需要注意:
- dp数组表示背包容量为j时的最大价值
- 外层循环遍历物品,内层循环遍历背包容量
- 内层循环从weight[i]开始,可以避免不必要的判断
- 状态转移方程:dp[j] = max(dp[j], dp[j-weight[i]] + value[i])
提示:在实际编码中,可以将内层循环的起始点设为当前物品的重量,这样既提高了效率,又避免了额外的if判断。
2. 零钱兑换II问题详解
2.1 问题描述与dp定义
零钱兑换II问题(LeetCode 518)是这样的:给定不同面额的硬币和一个总金额,计算可以凑成总金额的硬币组合数。假设每种面额的硬币有无限个。
这里dp数组的定义很关键:
- dp[j]:凑成金额j的组合数
初始化时,dp[0] = 1,因为金额为0时有一种组合方式(什么硬币都不选)。
2.2 状态转移与遍历顺序
状态转移方程为:
dp[j] += dp[j - coins[i]]
这个方程的含义是:凑成金额j的组合数等于不使用当前硬币的组合数(dp[j])加上使用当前硬币的组合数(dp[j - coins[i]])。
遍历顺序也很重要:
- 外层循环遍历硬币(物品)
- 内层循环遍历金额(背包容量)
这种顺序确保了组合数不会因为硬币顺序不同而被重复计算(即1+2和2+1被视为同一种组合)。
2.3 完整代码实现
python复制class Solution:
def change(self, amount: int, coins: List[int]) -> int:
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]
注意:如果内外层循环的顺序颠倒,得到的就是排列数而不是组合数了。这是完全背包问题中一个非常重要的细节。
3. 组合总和IV问题解析
3.1 问题描述与dp定义
组合总和IV问题(LeetCode 377)与零钱兑换II类似,但求的是排列数而非组合数。即顺序不同的序列被视为不同的组合。
dp数组定义:
- dp[j]:凑成目标j的排列数
初始化同样为dp[0] = 1。
3.2 状态转移与遍历顺序
状态转移方程形式上与零钱兑换相同:
dp[j] += dp[j - nums[i]]
但遍历顺序有本质区别:
- 外层循环遍历目标(背包容量)
- 内层循环遍历数字(物品)
这种顺序确保了不同顺序的排列都会被计入结果。例如,对于目标3和数字[1,2]:
- 先计算dp[1],再dp[2],最后dp[3]
- 在计算dp[3]时,会考虑1+2和2+1两种不同的顺序
3.3 完整代码实现
python复制class Solution:
def combinationSum4(self, nums: List[int], target: int) -> int:
dp = [0] * (target + 1)
dp[0] = 1
for j in range(1, target + 1):
for num in nums:
if j >= num:
dp[j] += dp[j - num]
return dp[target]
实操心得:在实际面试中,面试官可能会故意混淆组合数和排列数的概念。一定要先明确题目要求的是组合还是排列,再决定遍历顺序。
4. 爬楼梯问题的进阶版本
4.1 问题描述与dp定义
爬楼梯问题的进阶版本是这样的:假设你正在爬楼梯,每次可以爬1到m个台阶(而不是原来的1或2个)。问有多少种不同的方法可以爬到楼顶。
dp数组定义:
- dp[j]:爬到第j阶楼梯的方法数
初始化dp[0] = 1,表示在地面有一种方法(不爬)。
4.2 状态转移与遍历顺序
状态转移方程:
dp[j] += dp[j - i] (i从1到m)
遍历顺序:
- 外层循环遍历楼梯阶数(背包容量)
- 内层循环遍历步数(物品)
这与组合总和IV问题的思路完全一致,实际上就是求排列数的问题。
4.3 完整代码实现
python复制n, m = map(int, input().split())
dp = [0] * (n + 1)
dp[0] = 1
for j in range(1, n + 1):
for i in range(1, m + 1):
if i <= j:
dp[j] += dp[j - i]
print(dp[n])
常见错误:初学者容易混淆爬楼梯的基础版本和进阶版本。基础版本每次只能爬1或2阶,可以用斐波那契数列解决;而进阶版本每次可以爬1到m阶,需要当作完全背包问题来处理。
5. 完全背包问题的常见变种与解题技巧
5.1 完全背包问题的三种基本问法
完全背包问题通常有以下三种问法:
- 最大价值问题(标准完全背包)
- 组合数问题(零钱兑换II)
- 排列数问题(组合总和IV)
这三种问法的核心区别在于:
- 最大价值问题:求max
- 组合数问题:外层物品,内层容量,求和
- 排列数问题:外层容量,内层物品,求和
5.2 解题步骤总结
解决完全背包问题的通用步骤:
- 确定dp数组及其下标的含义
- 确定递推公式(状态转移方程)
- 初始化dp数组(通常dp[0]需要特殊初始化)
- 确定遍历顺序(组合数or排列数)
- 举例推导dp数组(用于验证)
5.3 调试技巧与常见错误
在实际编码中,有几个常见的调试技巧:
- 打印dp数组:这是最直接的调试方法,可以验证每一步的计算是否符合预期
- 小规模测试:先用小的输入测试,确保基本逻辑正确
- 边界检查:特别注意j - weight[i]或j - coins[i]是否为负的情况
常见错误包括:
- 遍历顺序错误(组合/排列混淆)
- 初始化不正确(特别是dp[0])
- 数组越界(没有检查j - weight[i]是否>=0)
6. 实际应用中的优化技巧
6.1 空间优化技巧
虽然我们使用了一维dp数组来解决问题,但实际上可以进一步优化空间。不过在一维情况下,空间复杂度已经是O(n)了,很难再有大的提升。
对于某些特殊问题,如果物品的重量和价值有特殊规律,可能可以找到数学规律来优化,但这需要具体问题具体分析。
6.2 时间优化技巧
- 预处理物品:可以先对物品进行排序或过滤,去除明显不会使用的物品
- 提前终止:在某些情况下,可以提前终止循环(如当dp[target]已经不为0时)
- 记忆化搜索:对于递归实现的dp,可以使用记忆化来避免重复计算
6.3 代码模板化
对于完全背包问题,可以总结出以下代码模板:
python复制# 最大价值问题模板
def maxValue(weight, value, bag_weight):
dp = [0] * (bag_weight + 1)
for i in range(len(weight)):
for j in range(weight[i], bag_weight + 1):
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
return dp[bag_weight]
# 组合数问题模板
def combination(coins, amount):
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]
# 排列数问题模板
def permutation(nums, target):
dp = [0] * (target + 1)
dp[0] = 1
for j in range(1, target + 1):
for num in nums:
if j >= num:
dp[j] += dp[j - num]
return dp[target]
掌握这三个模板,就能解决大多数完全背包类的问题。在实际应用中,关键还是要准确理解题目要求,选择正确的dp定义和遍历顺序。