一篇文章吃透背包问题---python版

一篇文章吃透背包问题—python版

原文来自一篇文章吃透背包问题因为其代码是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];

这样遇到问题将两个模板往上一套大部分问题就可以迎刃而解
下面看一下具体的题目分析:

  1. 零钱兑换
    零钱兑换:给定amount,求用任意数量不同面值的零钱换到amount所用的最少数量
    完全背包最值问题:外循环coins,内循环amount正序,应用状态方程1
'''
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)
  1. 分割等和子集
    分割等和子集:判断是否能将一个数组分割为两个子集,其和相等
    0-1背包存在性问题:是否存在一个子集,其和为target=sum/2,外循环nums,内循环target倒序,应用状态方程2
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]
  1. 目标和
    目标和:给数组里的每个数字添加正负号得到target
    数组和sum,目标和s, 正数和x,负数和y,则x+y=sum,x-y=s,那么x=(s+sum)/2=target
    0-1背包不考虑元素顺序的组合问题:选nums里的数得到target的种数,外循环nums,内循环target倒序,应用状态方程3

这里值得注意,因为该题中的组合问题有正有负,如果使用一维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)
  1. 完全平方数
    完全平方数:对于一个正整数n,找出若干个完全平方数使其和为n,返回完全平方数最少数量
    完全背包的最值问题:完全平方数最小为1,最大为sqrt(n),故题目转换为在nums=[1,2…sqrt(n)]中选任意数平方和为target=n
    外循环nums,内循环target正序,应用转移方程1
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]
  1. 组合总和 Ⅳ
    组合总和IV:在nums中任选一些数,和为target
    考虑顺序的组合问题:外循环target,内循环nums,应用状态方程3

因为要考虑的是有序组合问题,即 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])
  1. 零钱兑换 II
    零钱兑换2:任选硬币凑成指定金额,求组合总数
    完全背包不考虑顺序的组合问题:外循环coins,内循环target正序,应用转移方程3

与上一题基本一致,不同点在于此处考虑的组合 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])
  1. 最后一块石头的重量 II
    这道题看出是背包问题比较有难度
    最后一块石头的重量:从一堆石头中,每次拿两块重量分别为x,y的石头,若x=y,则两块石头均粉碎;若x<y,两块石头变为一块重量为y-x的石头求最后剩下石头的最小重量(若没有剩下返回0)
    问题转化为:把一堆石头分成两堆,求两堆石头重量差最小值
    进一步分析:要让差值小,两堆石头的重量都要接近sum/2;我们假设两堆分别为A,B,A<sum/2,B>sum/2,若A更接近sum/2,B也相应更接近sum/2
    进一步转化:将一堆stone放进最大容量为sum/2的背包,求放进去的石头的最大重量MaxWeight,最终答案即为sum-2*MaxWeight;、
    0/1背包最值问题:外循环stones,内循环target=sum/2倒叙,应用转移方程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[i1][jnums]。 而前两题则是 d p [ i ] [ j ] = d p [ i ] [ j − n u m s ] dp[i][j] = dp[i][j - nums] dp[i][j]=dp[i][jnums],因此本题内层循环逆序,前两题可逆序可正序。本体和目标和那一题时一模一样的。与零钱兑换那两题唯一不同在于前两题没有物品数量限制。

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
  1. 掷骰子的N种方法
    投掷骰子的方法数:d个骰子,每个有f个面(点数为1,2,…f),求骰子点数和为target的方法
    分组0/1背包的组合问题:dp[i][j]表示投掷i个骰子点数和为j的方法数;三层循环:最外层为背包d,然后先遍历target后遍历点数f
    应用二维拓展的转移方程3:dp[i][j]+=dp[i-1][j-f];

这题要注意的是,使用一维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[jnum[i]]这类的变形,刷题熟练掌握这几种基本就够了。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值