AI summary
type
status
date
slug
summary
category
tags
icon
password
0-1 背包
给你一个可装载重量为W
的背包和N
个物品,每个物品有重量和价值两个属性。其中第i
个物品的重量为wt[i]
,价值为val[i]
。现在让你用这个背包装物品,每个物品只能用一次,在不超过背包容量的前提下,最多能装的价值是多少?
贪心解法(非最优)
按照单位价值排序,优先选择单位价值高的。
不是最优的例子:背包容量 10,物品价值为 [6,4,4], 物品重量为 [6,5,5],贪心算法会选单位价值最高的物品1,获得价值6,但是放了1就放不下其他的了。最优解应该是放物品2和3,获得价值8。
稍微修改一下就能得到一个 2-近似算法,按单位价值排序放,碰到第一个装不下的物品,如果他的价值比包里的价值大,则倒掉包里的换成他。
证明:
动态规划
dp[i][w]
的定义如下:对于前 i
个物品,当前背包的容量为 w
,这种情况下可以装的最大价值是 dp[i][w]
。根据这个定义,我们想求的最终答案就是
dp[N][W]
。base case 就是 dp[0][..] = dp[..][0] = 0
,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。状态转移就是选择是否装入物品i。
子集背包问题
来看看背包问题的思想能够如何运用到其他算法题目。
分割等和子集
输入一个只包含正整数的非空数组nums
,请你写一个算法,判断这个数组是否可以被分割成两个子集,使得两个子集的元素和相等。
对于这个问题,看起来和背包没有任何关系,为什么说它是背包问题呢?
我们可以先对集合求和,得出
sum
,把问题转化为背包问题:给一个可装载重量为
sum / 2
的背包和 N
个物品,每个物品的重量为 nums[i]
。现在让你装物品,是否存在一种装法,能够恰好将背包装满?你看,这就是背包问题的模型,甚至比我们之前的经典背包问题还要简单一些,下面我们就直接转换成背包问题,开始套前文讲过的背包问题框架即可。
dp[i][j] = x
表示,对于前 i
个物品(i
从 1 开始计数),当前背包的容量为 j
时,若 x
为 true
,则说明可以恰好将背包装满,若 x
为 false
,则说明不能恰好将背包装满。由于
dp
数组定义中的 i
是从 1 开始计数,而数组索引是从 0 开始的,所以第 i
个物品的重量应该是 nums[i-1]
,这一点不要搞混。完全背包问题
力扣第 322 题「零钱兑换 I」在前文作为经典动态规划的例题讲过。本文讲的零钱兑换 II 是另一种典型背包问题的变体。
零钱兑换Ⅱ
给你一个整数数组coins
表示不同面额的硬币,另给一个整数amount
表示总金额。请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回0
。假设每一种面额的硬币有无限个。
我们可以把这个问题转化为背包问题的描述形式:
有一个背包,最大容量为
amount
,有一系列物品 coins
,每个物品的重量为 coins[i]
,每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?这个问题和我们前面讲过的两个背包问题,有一个最大的区别就是,每个物品的数量是无限的,这也就是传说中的「完全背包问题」,没啥高大上的,无非就是状态转移方程有一点变化而已。
dp[i][j]
的定义如下:若只使用前
i
种物品,当背包容量为 j
时,有 dp[i][j]
种方法可以装满背包。base case 为
dp[0][..] = 0, dp[..][0] = 1
。i = 0
代表不使用任何硬币面值,这种情况下显然无法凑出任何金额;j = 0
代表需要凑出的目标金额为 0,那么什么都不做就是唯一的一种凑法。我们最终想得到的答案就是
dp[N][amount]
状态转移还是分两种情况取较优者:
如果你不把这第
i
个物品装入背包,也就是说你不使用 coins[i-1]
这个面值的硬币,那么凑出面额 j
的方法数 dp[i][j]
应该等于 dp[i-1][j]
,继承之前的结果。如果你把这第
i
个物品装入了背包,也就是说你使用 coins[i-1]
这个面值的硬币,那么 dp[i][j]
应该等于 dp[i][j-coins[i-1]]
。有的读者在这里可能会有疑问,不是说可以重复使用硬币吗?那么如果我确定「使用第
i
个面值的硬币」,我怎么确定这个面值的硬币被使用了多少枚?简单的一个 dp[i][j-coins[i-1]]
可以包含重复使用第 i
个硬币的情况吗?对于这个问题,建议你再仔回头细阅读一下我们对
dp
数组的定义,然后把这个定义代入 dp[i][j-coins[i-1]]
看看:若只使用前
i
个物品(可以重复使用),当背包容量为 j-coins[i-1]
时,有 dp[i][j-coins[i-1]]
种方法可以装满背包。看到了吗,
dp[i][j-coins[i-1]]
也是允许你使用第 i
个硬币的,所以说已经包含了重复使用硬币的情况,你一百个放心。变体:目标和
我们前文经常说回溯算法和递归算法有点类似,有的问题如果实在想不出状态转移方程,尝试用回溯算法暴力解决也是一个聪明的策略,总比写不出来解法强。
那么,回溯算法和动态规划到底是啥关系?它俩都涉及递归,算法模板看起来还挺像的,都涉及「状态」以及做「选择」,是否可能互相转化呢?
目标和
给你一个非负整数数组nums
和一个整数target
。向数组中的每个整数前添加'+'
或'-'
,然后串联起所有整数,可以构造一个 表达式 :
- 例如,
nums = [2, 1]
,可以在2
之前添加'+'
,在1
之前添加'-'
,然后串联起来得到表达式"+2-1"
。返回可以通过上述方法构造的、运算结果等于target
的不同 表达式 的数目。
回溯算法
一眼可以用回溯解法,任何算法的核心都是穷举,回溯算法就是一个暴力穷举算法。复习一下回溯算法框架:
关键就是搞清楚什么是「选择」,而对于这道题,「选择」不是明摆着的吗?对于每个数字
nums[i]
,我们可以选择给一个正号 +
或者一个负号 -
,然后利用回溯模板穷举出来所有可能的结果,数一数到底有几种组合能够凑出 target
不就行了嘛?等价于二叉树遍历,时间复杂度为 。
动态规划
上面的解法显然有很多重叠子问题被重复计算了。如果从回溯算遍历多叉树的视角来看,消除重叠子问题有些困难,我们可以换个角度,从分解问题(动态规划)的思路来看,就容易使用备忘录消除重叠子问题了。
定义 dp 函数表示使用 nums[i:] 这些元素,能够组成和为 remain 的方法数量,这样就可以用备忘录技巧进行优化了。
这道题有两个状态
i
和 remain
,一般来说我们可以用二维数组来充当备忘录。但这道题 remain
可能为负数,所以可以用哈希表作为备忘录,把「状态」转化为字符串作为哈希表的键。或者对 remain
加一个偏移量,使其非负,这样就可以用二维数组了。当然,Python 可以直接用元组作 key,很方便。这个解法通过备忘录消除了很多重叠子问题,效率有一定的提升,但是这就结束了吗?
背包问题
其实,这个问题可以转化为一个子集划分问题,而子集划分问题又是一个典型的背包问题。动态规划总是这么玄学,让人摸不着头脑…
首先,如果我们把
nums
划分成两个子集 A
和 B
,分别代表分配 +
的数和分配 -
的数,那么他们和 target
存在如下关系:综上,可以推出
sum(A) = (target + sum(nums)) / 2
,也就是把原问题转化成:nums
中存在几个子集 A
,使得 A
中元素的和为 (target + sum(nums)) / 2
?现在实现这么一个函数:
然后,可以这样调用这个函数:
好的,变成背包问题的标准形式:
有一个背包,容量为
sum
,现在给你 N
个物品,第 i
个物品的重量为 nums[i - 1]
(注意 1 <= i <= N
),每个物品只有一个,请问你有几种不同的方法能够恰好装满这个背包?现在,这就是一个正宗的动态规划问题了,下面按照我们一直强调的动态规划套路走流程:
第一步要明确两点,「状态」和「选择」。
对于背包问题,这个都是一样的,状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。
第二步要明确
dp
数组的定义。按照背包问题的套路,可以给出如下定义:
dp[i][j] = x
表示,若只在前 i
个物品中选择,若当前背包的容量为 j
,则最多有 x
种方法可以恰好装满背包。翻译成我们探讨的子集问题就是,若只在
nums
的前 i
个元素中选择,若目标和为 j
,则最多有 x
种方法划分子集。根据这个定义,显然
dp[0][..] = 0
,因为没有物品的话,根本没办法装背包;但是 dp[0][0]
应该是个例外,因为如果背包的最大载重为 0,「什么都不装」也算是一种装法,即 dp[0][0] = 1
。为什么 base case 不是像之前的背包问题一样取dp[..][0] = 1
呢?即背包容量为 0 时,只有「什么都不装」这一种装法。这里不能这样初始化,是因为本题nums
数组中的元素是可能为 0 的,那么背包容量为 0 时,「什么都不装」可能就不是唯一的装法了,而需要在状态转移的过程中具体去计算。
我们所求的答案就是
dp[N][sum]
,即使用所有 N
个物品,有几种方法可以装满容量为 sum
的背包。第三步,根据「选择」,思考状态转移的逻辑。
回想刚才的
dp
数组含义,可以根据「选择」对 dp[i][j]
得到以下状态转移:如果不把
nums[i]
算入子集,或者说你不把这第 i
个物品装入背包,那么恰好装满背包的方法数就取决于上一个状态 dp[i-1][j]
,继承之前的结果。如果把
nums[i]
算入子集,或者说你把这第 i
个物品装入了背包,那么只要看前 i - 1
个物品有几种方法可以装满 j - nums[i-1]
的重量就行了,所以取决于状态 dp[i-1][j-nums[i-1]]
。由于
dp[i][j]
为装满背包的总方法数,所以应该以上两种选择的结果求和。代码如下:
然后,发现这个
dp[i][j]
只和前一行 dp[i-1][..]
有关,那么肯定可以优化成一维。注意 j 要从后往前遍历,不然 dp[i-1][j-1] 就被覆盖为 dp[i][j-1] 了。