原文来自一篇文章吃透背包问题因为其代码是c++写的,这里我重新整理并用python重写了一遍,供大家参考
当然,想要了解更多可以看看背包九讲。
给定一个背包容量target,再给定一个数组nums(物品),能否按一定方式选取nums中的元素得到target
注意:
1、背包容量target和物品nums的类型可能是数,也可能是字符串
2、target可能题目已经给出(显式),也可能是需要我们从题目的信息中挖掘出来(非显式)(常见的非显式target比如sum/2等)
3、选取方式有常见的一下几种:每个元素选一次/每个元素选多次/选元素进行排列组合
那么对应的背包问题就是下面我们要讲的背包分类
常见的背包类型主要有以下几种:
1、0/1背包问题:每个元素最多选取一次
2、完全背包问题:每个元素可以重复选择
3、组合背包问题:背包中的物品要考虑顺序
4、分组背包问题:不止一个背包,需要遍历每个背包
而每个背包问题要求的也是不同的,按照所求问题分类,又可以分为以下几种:
1、最值问题:要求最大值/最小值
2、存在问题:是否存在…………,满足…………
3、组合问题:求所有满足……的排列组合
因此把背包类型和问题类型结合起来就会出现以下细分的题目类型:
1、0/1背包最值问题
2、0/1背包存在问题
3、0/1背包组合问题
4、完全背包最值问题
5、完全背包存在问题
6、完全背包组合问题
7、分组背包最值问题
8、分组背包存在问题
9、分组背包组合问题
这九类问题我认为几乎可以涵盖力扣上所有的背包问题
所有dp类问题离不开五大步骤:
1、确定dp矩阵维度,横坐标表示什么,纵坐标表示什么
2、初始化dp
3、推导dp状态转移方程
4、确定遍历循环顺序,先循环什么,正序逆序?后循环什么
5、确定输出内容在dp中的位置,常见是dp[-1]或者max(dp)
如果觉得dp转移方程很绕的话,你应该在dp矩阵输出之前手推出dp矩阵,如此反复多练习几次基本就不会再范迷糊了。
'''
背包问题
基础二维背包
'''
def bags():
weight= [1,3,4]
value = [15,20,30]
bagweight = 4
#1, dp设置为二维矩阵,横坐标表示选择0-i的物品,纵坐标表示背包容量为j
dp = [[0 for _ in range(bagweight + 1)] for _ in range(len(weight))]
#2, dp初始化
for j in range(bagweight+1):
if j>=weight[0]:
dp[0][j] = value[0]
#3,4, 确定状态转移方程以及循环次序
for i in range(1,len(weight)):
for j in range(bagweight+1):
if j>= weight[i]:
dp[i][j] = max(dp[i-1][j],dp[i-1][j-weight[i]] + value[i])
else:
dp[i][j] = dp[i-1][j]
print(dp)
return dp[-1][-1]
print(bags())
可以看到上面的dp矩阵中,转移方程没有用到dp[i]相关的数据,因此对于dp[i]来说是冗余的,我们可以将其转化为一个一维dp
'''
二维背包简化为一维
'''
def bags():
weight= [1,3,4]
value = [15,20,30]
bagweight = 4
#1, dp设置为一维矩阵 dp[i]表示容量为i的背包能装下的最大价值
dp = [0 for _ in range(bagweight + 1)]
#2, dp初始化
for j in range(bagweight+1):
if j>=weight[0]:
dp[j] = value[0]
#3,4, 确定状态转移方程以及循环次序
for i in range(1,len(weight)):
for j in range(bagweight+1):
if j>= weight[i]:
dp[j] = max(dp[j],dp[j-weight[i]] + value[i])
else:
dp[j] = dp[j]
print(dp)
return dp[-1]
print(bags())
为了和之前二维的统一形式,这里就不做简化了,方便大家对比。
可以看到,对于一个通用的dp问题来说,难点主要在转移方程的迭代上,如果你不想动脑子的话,可以尝试理解一下下面这类问题的模板。
背包问题大体的解题模板是两层循环,分别遍历物品nums和背包容量target,然后写转移方程,
根据背包的分类我们确定物品和容量遍历的先后顺序,根据问题的分类我们确定状态转移方程的写法
首先是背包分类的模板:
1、0/1背包:外循环nums,内循环target,target倒序且target>=nums[i];
2、完全背包:外循环nums,内循环target,target正序且target>=nums[i];
3、组合背包(考虑顺序):外循环target,内循环nums,target正序且target>=nums[i];
4、分组背包:这个比较特殊,需要三重循环:外循环背包bags,内部两层循环根据题目的要求转化为1,2,3三种背包类型的模板
这里内循环和外循环总结的不太完美,除非是排序问题,否则没有内循环和外循环的分别
对于排序问题来说,只能外循环是 nums, 内循环是 sum,这样子才能保证顺序 比如说 nums是 1和 5,sum 是 6, 如果外循环是 nums,内循环是 sum, 保证了当 sum = 6 的时候 只能是 6=1+5 存在顺序是,先1 后 5 如果 外循环是 sum, 内循环是 nums, 当sum = 6 的时候 有 6 = 1+5 和 6 = 5+1 两种
具体例子 有5种硬币,面值分别为1,5,10,20,25。给你一个总额s,问有多少种找零方式?
注意比如总额为6,则[1,5],[5,1]显然是同一种找零方式,这两种只能算作一种 所以只能采用 外循环是 nums(1,5) ,内循环是sum(6)
然后是问题分类的模板:
1、最值问题: dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+nums);
2、存在问题(bool):dp[i]=dp[i]||dp[i-num];
3、组合问题:dp[i]+=dp[i-num];
这样遇到问题将两个模板往上一套大部分问题就可以迎刃而解
下面看一下具体的题目分析:
'''
322. 零钱兑换
'''
coins = [1,2,5]
amount = 11
dp = [float('inf')] * (amount + 1)
dp[0]= 0
for i in range(len(coins)):
for j in range(amount+1):
if j >= coins[i]:
dp[j] = min(dp[j],dp[j-coins[i]]+1)
print(dp)
def canPartition(self, nums: List[int]) -> bool:
if sum(nums)%2 !=0: return False
target = sum(nums)//2
dp = [[False for _ in range(target+1)] for _ in range(len(nums))]
for i in range(len(nums)):
dp[i][0] = True
for i in range(len(nums)):
for j in range(target+1):
if j >= nums[i]:
dp[i][j] = dp[i-1][j-nums[i]] or dp[i-1][j]
else:
dp[i][j] = dp[i-1][j]
return dp[-1][-1]
这里值得注意,因为该题中的组合问题有正有负,如果使用一维dp进行求解时需注意遍历的方向,内层的target应该逆序遍历防止需要使用的数组被生成结果覆盖
如果正常建dp表的话,由于有正负数的存在,我们的列数可能为2*target-1,这样处理起来会比较麻烦,因此,可以将其转化为一个背包问题: 正数和x,负数和y,则x+y=sum,x-y=s,那么x=(s+sum)/2=target,同样的,我们也可以将其转化为一维dp
neg = (sum(nums)-target)//2
if (sum(nums)-target)%2 != 0: return 0
dp = [0]*(abs(neg) + 1)
dp[0] = 1
for i in range(len(nums)):
for j in range(neg,-1,-1):
if j >= nums[i]:
dp[j] += dp[j-nums[i]]
print(dp)
dp = [0]*(n+1)
dp[0] = 0
for i in range(1,n+1):
min_num = float('inf')
for j in range(1,int(math.sqrt(i))+1):
min_num = min(min_num, dp[i - j**2])
dp[i] = min_num+1
return dp[-1]
因为要考虑的是有序组合问题,即 121和112 算作两种不同的组合,因此,我们外层遍历target,内层遍历nums可以保证遍历出所有组合的可能
dp = [0]*(target+1)
dp[0] = 1
for i in range(1,target+1):
for j in range(len(nums)):
if i > nums[j]:
dp[i] += dp[i-nums[j]
print(dp[-1])
与上一题基本一致,不同点在于此处考虑的组合 121和112算作一种组合,因此,我们采用传统的策略即可。 即外层nums内层target,每次遍历不会搜索到比当前coin大的数值。与零钱兑换1不同的地方在于1中是求最小组合的coins数,因此转移方程略有不同是 min(dp[j-nums[i]] + 1, dp[j])
dp = [0]*(target+1)
dp[0] = 1
for i in range(len(coins)):
for j in range(targets+1):
if j >= nums[i]:
dp[j] += dp[j-coins[i]]
print(dp[-1])
问题的关键在于将其转化为target = sums/2的0-1背包问题。不过值得注意的是,在使用一维dp进行求解时由于转移方程使用了id比当前更小的内容,为了让更新不会覆盖掉内容,内层循环应该倒序。换成二维dp就容易理解了, d p [ i ] [ j ] = d p [ i − 1 ] [ j − n u m s ] dp[i][j] = dp[i-1][j - nums] dp[i][j]=dp[i−1][j−nums]。 而前两题则是 d p [ i ] [ j ] = d p [ i ] [ j − n u m s ] dp[i][j] = dp[i][j - nums] dp[i][j]=dp[i][j−nums],因此本题内层循环逆序,前两题可逆序可正序。本体和目标和那一题时一模一样的。与零钱兑换那两题唯一不同在于前两题没有物品数量限制。
target = sum(stones)//2
dp = [0]*(target+1)
dp[0]=1
for i in range(len(stones,-1,-1)):
for j in range(target,-1,-1):
if j >= nums[i]:
dp[j] = max(dp[j],dp[j-stones[i]])
for j in range(target,-1,-1):
if dp[j]: return sum(stones) -2*j
return 0
这题要注意的是,使用一维dp时要注意内层循环 d p [ i ] = 0 dp[i] = 0 dp[i]=0,因为当前骰子最小值为1,不可能骰出0
dp = [0]* (target+1)
dp[0] = 1
mod = 10**9+7
for i in range(n):
if i>= 1: dp[0] = 0
for j in range(target,0,-1):
dp[j] = 0
for k in range(1,min(f,j)+1):
dp[j] = (dp[j] + dp[j-k])%mod
return dp[-1]%mod
力扣常见的dp相关内容target要么直接给出,要么简单转化一下比如sum/2,而对于转移方程,则多是 d p [ j − n u m [ i ] ] dp[j-num[i]] dp[j−num[i]]这类的变形,刷题熟练掌握这几种基本就够了。