4.2.5 动态规划之背包问题
两个模型:0/1 背包和完全背包
0/1 背包
给定 N 个物品,其中第 i 个物品的体积是 Vi,价值是 Wi。有一个容积为 M 的背包,要求选择一些物品放入背包,使得在总体积不超过 M 的前提下,物品的价值总和最大。
朴素思路
依次考虑每个物品选还是不选——即子集问题,每个结点有 2 个分支,是个 2^n 指数级别的搜索。同时关注已经放入背包里的物品的总重量和总价值。“子集问题”详见《递归》部分。
动态规划
重复子问题:
opt[i][j]表示从前 i 个物品中选出了总体积为 j 的物品放入背包,物品的最大价值总和状态:物品
[i]和总体积[j]最优化目标:
opt[i][j]最大价值总和
决策:对于第 i 个物品,可以做的决策是选或者不选
当不选时,
opt[i][j] = opt[i-1][j]当选择时,
opt[i][j] = opt[i-1][j-Vi] + Wi而
opt[i][j]的值就取以上两个中值最大的那个递推公式:
opt[i][j] = max(opt[i-1][j], opt[i-1][j-Vi] + Wi)
开始递推
默认值:
opt[i][j] = -Infinity起点:
opt[0][0] = 0目标:
max(...opt[N])
动态规划的核心代码:
我们发现,状态 opt[i] 是先从 opt[i-1] 完全转移过来(当不选第 i 个物品时),然后是当 j >= V[i] 时,走“选择第 i 个物品”的决策。所以,在空间上,我们可以省略掉第一维的物品 [i] ,只留总体积 [j] 的维度。优化后的代码,如下:
代码的空间复杂度从 O(N*M) 降到了 O(M)。这就是 0/1 背包算法的通用代码,背包问题是一类特殊的动态规划问题。
看个例子感受下。
例 1:分割等和子集
题目:分割等和子集。给定一个只包含正整数的非空数组 nums,请判断是否可以将该数组分割成两个子集,使得两个子集的元素和相等。
这是一个子集问题:选一个子集,让它的元素之和等于原数组和的一半。
朴素思路
暴力搜素(枚举回溯)的代码实现,如下:
执行结果:超出时间限制
0/1 背包
子集问题在有和限制的前提下,选数就和 0/1 背包一样。每个数就是一个物品,数的值就是物品的体积,此问题就是求:能否选出一些总体积等于 sum/2 的物品?
此时不用考虑“价值”(最优解问题),而是考虑“体积 M”是否可行(可行性问题)。
按照动态规划的思路进行分析,如下:
重复子问题:
opt[i][j]表示从前 i 个数中选出一些数,总和是 j,是否可行?状态:数
[i]和总和[j]代表:
opt[i][j]是否可行
决策:对于第 i 个数,可以选或者不选
当不选时,
opt[i][j] = opt[i-1][j]当选时,
opt[i][j] = opt[i-1][j-nums[i]]而
opt[i][j]的值就是这两个分支的逻辑或,即有一个分支是 true 则值就是 true递推公式:
opt[i][j] = opt[i-1][j] || opt[i-1][j-nums[i]]
开始递推
默认值:
opt[i][j] = false起点:
opt[0][0] = true目标:
opt[N][M]是否可行
代码实现,如下:
执行结果:通过
优化下代码的空间复杂度,将其从 O(nums.length*target) 降为 O(target)。如下:
执行结果:通过
完全背包
理论部分
与 0/1 背包相比,完全背包的物品不限个数。
完全背包:给定 N 种物品,其中第 i 种物品的体积是 Vi,价值是 Wi,并且每种物品都有无数个。有一个容积为 M 的背包,要求选择一些物品放入背包,使得在总体积不超过 M 的前提下,物品的价值总和最大。
完全背包的递推公式,和 0/1 背包的唯一不同就是:当从第 i 种物品中选择的时候是包含 i 的。
背包模型
递推公式 opt[i][j]
0/1 背包
max(opt[i-1][j], opt[i-1][j-Vi] + Wi)
(不选第 i 个物品, 选择第 i 个物品)
完全背包
max(opt[i-1][j], opt[i][j-Vi] + Wi)
(尚未选过第 i 种物品, 从第 i 种物品中选一个)
直接按照递推公式写,完全背包的代码如下:
优化下代码的空间复杂度,将其变为一维,如下:
我们发现,把 0/1 背包算法中的状态 [j] 正序循环了之后,就变成完全背包算法了,因为这样就能很方便地实现重复选择第 i 种物品的逻辑了。
看两个例子感受下。
例 1:最少硬币个数
题目:零钱兑换的最少硬币个数。给定一个整数数组 coins(表示不同面额的硬币)和一个整数 amount(表示总金额),计算并返回可以凑成总金额的最少硬币个数。如果没有任何一种硬币组合能组成总金额,就返回 -1。假设每种硬币的数量是无限的。
零钱兑换的问题其实就符合完全背包模型。零钱=物品,面值=体积,价值=1,求最少兑换个数就是求价值的最小值。
用背包的思想来分析这个动态规划的问题,步骤如下:
重复子问题:
opt[i][j]表示从前 i 种硬币中凑出总面额为 j 的最少硬币个数状态:硬币
[i]和总面额[j]最优化目标:
opt[i][j]最少硬币数
决策:对于第 i 种硬币,可以选或者不选
当不选时:
opt[i][j] = opt[i-1][j]当选时:
opt[i][j] = opt[i][j-coins[i]] + 1而
opt[i][j]的值就取这两个分支的最小值递推公式:
opt[i][j] = min(opt[i-1][j], opt[i][j-coins[i]] + 1)
开始递推
默认值:
opt[i][j] = Infinity起点:
opt[0][0] = 0目标:
opt[N][amount]
直接套用递推公式,代码如下:
执行结果:通过
优化下代码的空间复杂度,如下:
执行结果:通过
例 2:硬币组合数
题目:零钱兑换的硬币组合数。给定一个整数数组 coins(表示不同面额的硬币)和一个整数 amount(表示总金额),计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0。假设每种面额的硬币有无限个。
这个问题是完全背包+计数。
重复子问题:
opt[i][j]表示从前 i 种硬币中凑出总面额为 j 的硬币组合数状态:硬币
[i]和总面额[j]代表:
opt[i][j]硬币组合数
决策:对于第 i 种硬币,有两种决策,选或者不选
不选:
opt[i][j] = opt[i-1][j]选:
opt[i][j] = opt[i][j-coins[i]]而
opt[i][j]的值等于它们之和递推公式:
opt[i][j] = opt[i-1][j] + opt[i][j-coins[i]]
开始递推
默认值:
opt[i][j] = 0起点:
opt[i][0] = 1目标:
opt[N][amount]
直接按递推公式写,代码如下:
执行结果:通过
优化下代码的空间复杂度,如下:
执行结果:通过
总结
背包问题是一类特殊的动态规划问题,它就是在一定的体积限制下取物品,让价值最大化。背包有两个模型,分别是 0/1 背包和完全背包,它们的唯一区别就在于每种物品的个数是 1 个还是无限个。正因如此,它们的递推公式和通用代码之间的区别也不大。
递推公式的区别就在于:当从第 i 种物品中选择的时候是否包含 i。
背包模型
递推公式 opt[i][j]
0/1 背包
max(opt[i-1][j], opt[i-1][j-Vi] + Wi)
(不选第 i 个物品, 选择第 i 个物品)
完全背包
max(opt[i-1][j], opt[i][j-Vi] + Wi)
(尚未选过第 i 种物品, 从第 i 种物品中选一个)
通用代码中,倒序循环 [j] 时是 0/1 背包,正序循环时是完全背包。
背包问题就是“选或者不选”的子集问题,而子集问题适用于所有指数级别的搜索,所以背包的应用非常广泛,比如各种取物品且有体积限制的。它能求解最优解问题 min, max、可行性问题 |= boolean 以及计数问题 +=。
Last updated