【动态规划】背包问题的应用
0-1背包问题的应用
应用1:Leetcode.416
题目
分析
设 \(dp[i][j]\) 表示取数组 \(nums\) 中前 \(i\) 个元素,可以凑成和为 \(j\) 的种类是否大于 \(0\)。同时假设,设数组 \(nums\) 中所有元素的和为 \(S\) ,长度为 \(n\)。
如果 \(S\) 不能被 \(2\) 整除,那么,所有的元素肯定不能分割为等和子集。
如果 \(S\) 可以被 \(2\) 整除,那么,我们就可以将数组中的元素看作物品,背包容量为 \(S/2\),题目就可以转换为0-1背包问题,最后答案就是 \(dp[i][S/2]\) 。
边界条件
边界条件就是凑成和为零的方案,即对于任何数字都不选,它也对应一种方法,所以
状态转移
对于数组中的第 \(i\) 个元素 \(nums[i - 1]\) ,它有两种选择:
-
如果不选择当前元素,则当前状态与 \(dp[i-1][j]\) 相同;
-
如果选择当前元素,则当前状态与 \(dp[i - 1][j-nums[i - 1]]\) 相同。
也就是说,我们只需要从上述两种方案中,选择一个结果为 \(true\) 的方案即可,所以,状态转移方程:
代码实现
class Solution:
def canPartition(self, nums: List[int]) -> bool:
n = len(nums)
_sum = sum(nums)
if _sum % 2 != 0:
return False
_sum = _sum // 2
dp = [[False for _ in range(_sum + 1)] for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = True
for i in range(1, n + 1):
for j in range(1, _sum + 1):
# 背包容量不足,不能装入nums[i-1]
if j < nums[i-1]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = dp[i - 1][j] | dp[i - 1][j - nums[i - 1]]
return dp[n][_sum]
对其压缩状态,优化后的实现:
class Solution:
def canPartition(self, nums: List[int]) -> bool:
n = len(nums)
_sum = sum(nums)
if _sum % 2 != 0:
return False
_sum = _sum // 2
dp = [False] * (_sum + 1)
dp[0] = True
for i in range(1, n + 1):
for j in range(_sum, 0, -1):
if j >= nums[i - 1]:
dp[j] = dp[j] | dp[j - nums[i - 1]]
return dp[_sum]
完全背包问题的应用
应用1:Leetcode.518
题目
分析
设 \(dp[i][j]\) 表示使用前 \(i\) 枚金币能凑出金额 \(j\) 的凑法数量。
边界条件
使用任何面额的金币,凑出金额 \(0\) 的数量都是 \(1\),也就是说不使用金币,它对应的凑法数量为 \(1\),即:
状态转移
对于数组 \(coins\) 中的第 \(i\) 枚金币 \(coins[i - 1]\) ,都有两种选择:
-
当前金额 \(j\) 小于当前的金币的面额,凑法数量就是不选择当前面额的金币的种数,即:
\[dp[i][j] = dp[i - 1][j], \quad j \lt coins[i - 1] \] -
当前金额 \(j\) 大于等于当前的金币面额,此时,可以选择也可以不选择当前金币:
-
如果不选择,那么,凑法数量就是与前一个状态相同,即:
\[dp[i][j] = dp[i - 1][j], \quad j \ge coins[i - 1] \] -
如果选择,那么,凑法数量就是在当前状态下,多次选择当前面额的金币种数,即:
\[dp[i][j] = dp[i][j - coins[i - 1]], \quad j \ge coins[i - 1] \]
因此,当面额总数 \(j\) 大于当前金币面额时,凑法数量就是上述两种选择之和:
\[dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]], \quad j \ge coins[i - 1] \] -
综上,结合上述两种场景,状态转移方程为:
代码实现
from typing import List
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
n = len(coins)
dp = [[0 for _ in range(amount + 1)] for _ in range(n + 1)]
for i in range(0, n + 1):
dp[i][0] = 1
for i in range(1, n + 1):
for j in range(1, amount + 1):
if j - coins[i - 1] >= 0:
dp[i][j] = dp[i][j - coins[i - 1]] + dp[i - 1][j]
else:
dp[i][j] = dp[i - 1][j]
return dp[n][amount]
if __name__ == "__main__":
s = Solution()
print(s.change(amount=5, coins=[1, 2, 5]))
对其压缩状态,优化后的实现:
from typing import List
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
dp = [0 for _ in range(amount + 1)]
dp[0] = 1
for i in range(1, len(coins) + 1):
for j in range(1, amount + 1):
if j - coins[i - 1] >= 0:
dp[j] = dp[j - coins[i - 1]] + dp[j]
return dp[amount]
应用2:Leetcode.322
题目
方法一:自底向上动态规划
分析
简而言之,题目等价于:
有 \(N\) 种物品和一个容量为 \(W\) 的背包。第 \(i\) 种物品的重量是 \(w_i\),每种物品的数量都有无限个。求解将哪些物品装入背包可使这些物品的重量刚好等于背包容量,且物品数量最少。
假设 \(dp[i]\) 表示金额 \(i\) 最少可以兑换的硬币数量。
边界条件
如果金额为零,显然最少的硬币数量为零,即:
状态转移
这里,我们直根据完全背包问题的模板接给出状态转移方程:
代码实现
from typing import List
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
n = len(coins)
dp = [float("INF")] * (amount + 1)
dp[0] = 0
for i in range(1, n + 1):
for j in range(1, amount + 1):
if j >= coins[i - 1]:
dp[j] = min(dp[j], dp[j - coins[i - 1]] + 1)
result = dp[amount]
if dp[amount] == float("INF"):
result = -1
return result
方法二:自顶向下动态规划
分析
这里我们利用分治的思想:通过定义一个带返回值的递归函数,将问题分解为子问题(子树),通过递归推导出答案。
我们定义一个函数 traverse(coins: List[int], amount: int, memory: List[int])
表示:当硬币为 \(coins\) 时,凑齐金额 \(amount\) 的最小数量,其中,\(memory\) 记录已经搜索过的结果,避免重复计算。
代码实现
from typing import List
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
memory = [0] * amount
return self.traverse(coins, amount, memory)
def traverse(self, coins: List[int], amount: int, memory: List[int]) -> int:
if amount == 0:
return 0
if amount < 0:
return -1
# 避免重复搜索
if memory[amount - 1] != 0:
return memory[amount - 1]
answer = float("INF")
for coin in coins:
result = self.traverse(coins, amount - coin, memory)
if result == -1:
continue
answer = min(answer, result + 1)
if answer == float("INF"):
answer = -1
# 记录已经计算过的结果
memory[amount - 1] = answer
return answer
应用3:Leetcode.337
题目
分析
这道题如果直接使用回溯暴力搜索组合数,会导致内存超出限制。
这里,我们考虑使用动态规划求解,即在给定数组 nums 中,选择若干元素的组合,使它们的和等于 target,其中,元素可以多次重复选择。
这道题与完全背包问题的模型类似,但是,这道题的差异是:选择的不同物品的顺序代表不同的方案,因此,我们需要求出满足条件的所有物品的排列数。
假设数组的长度为 \(n\),我们定义 \(dp[i][j]\) 表示选择 \(i\) 个元素,凑成和为 \(j\) 的方案数,那么,最后要求的答案就是
即把所有的个数组合都选择一遍,然后将每个数字对应的所有方案数求和,就是最后的答案。
边界条件
当不选择任何元素时,只有一种方案,因此,有
状态转移
对于任意的 \(dp[i][j]\) 而言,组合中的最后一个数可以选择 \(nums\) 中的任意一个值,即:
-
如果最后一个数选择 \(nums[0]\),则方案数为:\(dp[i-1][j - nums[0]]\);
-
如果最后一个数选择 \(nums[1]\),则方案数为:\(dp[i-1][j - nums[1]]\);
-
如果最后一个数选择 \(nums[2]\),则方案数为:\(dp[i-1][j - nums[2]]\);
-
\(\cdots\)
因此,当选择 \(i\) 个元素时,可以凑成和为 \(j\) 的组合数应该为上述所有方案的总和,即
那么,我们只需要遍历所有的组合,并累加组合,即可得到答案。
这里需要注意,物品种类和物品个数的区别:
-
完全背包是从背包中选择 \(i\) 类物品,每类物品可以无限制重复地选择,因此,状态转移可以从当前状态 \(i\) 继续转移;
-
这道题是从背包中选择 \(i\) 个物品,它们可以是同一类物品,也可以是多类物品,选择的物品总数固定(能选择的物品总数不会超过 \(target\)),因此,状态转移需要从上一个状态 \(i - 1\) 转移。
代码实现
class Solution:
def combinationSum4(self, nums: List[int], target: int) -> int:
result = 0
dp = [[0 for _ in range(target + 1)] for _ in range(target + 1)]
dp[0][0] = 1
length = target
# 遍历结果集中的物品数量,所有凑成和为target时,结果集中最多可以选择target个物品
for i in range(1, length + 1):
# 遍历可以凑成的和
for j in range(target + 1):
# 尝试选择每一个物品
for num in nums:
if num <= j:
# 累加可以选的物品的总数之和
dp[i][j] += dp[i - 1][j - num]
result += dp[i][j]
return result
优化后
class Solution:
def combinationSum4(self, nums: List[int], target: int) -> int:
dp = [0] * (target + 1)
dp[0] = 1
for i in range(1, target + 1):
for num in nums:
if num <= i:
dp[i] += dp[i - num]
return dp[target]
参考: