导语
leetcode刷题笔记记录,本篇博客是动态规划4,主要记录背包理论(0-1背包)题目包括:
知识点
背包理论
背包问题在动态规划中是一类经典问题,主要涉及到物品和一个有限的背包容量,需要决定如何选择物品以满足某种优化准则(如最大价值)。以下是背包问题的几种基本形式:
-
0-1背包问题:
- 你有一个容量为 W 的背包和 N 个物品。每个物品都有自己的重量和价值。你的目标是在不超过背包容量的情况下,最大化物品的总价值。
- 状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i]] + values[i]),其中i是物品的数量,j是背包的容量。
-
完全背包问题:
- 与 0-1 背包问题相似,但每种物品都有无限件可用。
- 状态转移方程为:
dp[i] = max(dp[i], dp[i-weights[j]] + values[j]),其中j是物品的索引。
-
多重背包问题:
- 与完全背包问题类似,但每种物品有有限的可用数量。
- 对于这种问题,通常会使用三重循环来解决:一个循环遍历物品,一个循环遍历背包容量,一个循环遍历每种物品的数量。
-
分组背包问题:
- 物品被分成了几个组,从每个组中选择某些物品,以使得背包中物品的总价值最大。且每个组只能选一个物品。
- 状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[k]] + values[k]),其中k是在第i组中的物品。
-
其它变体:如路径计数、恰好装满背包等。
基本思路: 对于大部分背包问题,核心思想是使用二维数组 dp[i][j],其中 i 通常代表选择到第 i 个物品时,j 代表背包的容量。值 dp[i][j] 表示在给定的约束条件下可以得到的最大价值。解决背包问题的关键在于状态转移方程,这通常涉及决定包括或不包括当前物品,然后在之前的选择中寻找最优解。
注意:背包问题的解决方法不仅限于动态规划,还可以通过回溯、贪心、分支界限等方法解决,但动态规划是最常用的方法。
0-1背包
0-1背包问题是背包问题中的经典版本,得名于每种物品只能选择一次或完全不选择(0 或 1)。
问题描述
给定一个固定容量为 W 的背包和 N 个物品,每个物品 i 有自己的重量 weights[i] 和价值 values[i]。目标是选择一些物品放入背包,使得背包的总价值最大化,同时不超过背包的容量。
问题特性
- 每种物品只有一件。
- 可以选择放入物品或者不放。
动态规划解法
-
定义状态: 定义一个二维数组
dp[i][j],其中 i 表示前 i 个物品,j 表示背包的容量。dp[i][j]的值表示在前 i 个物品中选择并且背包容量为 j 时可以获得的最大价值。 -
初始化状态:
- 对于j=0(背包容量为0),
dp[i][0] = 0,因为容量为0的背包不能容纳任何物品。 - 对于i=0(没有物品可选),
dp[0][j] = 0,因为没有物品可以放入背包。
- 对于j=0(背包容量为0),
-
状态转移方程: 对于每个物品 i 和每个容量 j:
-
如果 weights[i]>j(物品 i 的重量大于当前的背包容量),那么物品 i 不能被选择,因此
dp[i][j] = dp[i-1][j]。 -
否则,我们有两种选择:
- 不选择物品 i:
dp[i-1][j]。 - 选择物品 i:
dp[i-1][j-weights[i]] + values[i]。
- 不选择物品 i:
-
取这两种选择中的最大值,即
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i]] + values[i])。
-
-
结果:
dp[N][W]表示在给定背包容量 W 和所有物品的情况下可以获得的最大价值。
优化
由于每次更新 dp[i][j] 时只依赖于上一行的数据,因此可以使用滚动数组或一维数组来优化空间复杂度,从 O(N×W) 优化到 O(W)。但是经过状态压缩后,dp数组的遍历顺序是固定的,如下图所示:
即一维滚动dp数组应该先遍历物品数量,再遍历背包容量,而且遍历背包容量时是倒序遍历(为了避免出现物品被重复选取的情况)。
应用
0-1背包问题不仅仅局限于价值和重量的概念,它的核心是有限的选择和最大化/最小化某种价值,因此它在很多实际问题中都有应用,例如资源分配、任务调度等。
Leetcode 416 分割等和子集
题目描述
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入: nums = [1,5,11,5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入: nums = [1,2,3,5]
输出: false
解释: 数组不能分割成两个元素和相等的子集。
提示:
1 <= nums.length <= 2001 <= nums[i] <= 100
解法
这道题目可以转化为一个0-1背包问题,即我们的背包容量为sum/2,其中候选的物品就是数组中的元素,物品的重量和价值都是自身的数值大小,每个物品仅能选取0或1次。目标则是填满背包。接下来应用动规五部曲:
- dp数组含义:容量为j的背包,最大价值为dp[j]
- 递推公式:参考一维滚动数组的递推公式,在该问题场景下,由于重量和价值相同,则
- 初始化:由于递推公式中需要进行max操作判断,为了不影响这个操作,dp[i]应该初始化为非负整数中的最小值,即0
- 遍历顺序:由于采用了一维滚动数组,所以遍历顺序是固定的,即先遍历物品,后遍历背包容量,且倒序;
- 打印dp数组:略
class Solution:
def canPartition(self, nums: List[int]) -> bool:
# 计算数组中所有数字的总和
total = sum(nums)
# 如果总和是奇数,则不可能将数组分为两个和相等的子集,直接返回False
if total % 2 != 0:
return False
# 获取数组的长度
n = len(nums)
# 计算目标和,这是我们要找的子集的和
target = total // 2
# 初始化dp数组,长度为target+1。dp[i]表示是否可以通过数组的部分元素得到和为i
# 初始化为0表示不能得到,后续在循环中更新
dp = [0] * (target+1)
# 遍历数组中的每个数字
for i in range(n):
# 从target开始,逆向遍历到nums[i]-1
# 我们需要逆向遍历,因为我们不想在这个循环中使用同一个元素多次
for j in range(target, nums[i]-1, -1):
# 更新dp[j]
# 如果我们不使用nums[i],则dp[j]不变
# 如果我们使用nums[i],则dp[j]更新为dp[j-nums[i]] + nums[i]
# 这实际上表示是否可以从之前的数字得到和为j-nums[i],如果可以,则加上nums[i]就可以得到和为j
dp[j] = max(dp[j], dp[j-nums[i]]+nums[i])
# 最后,检查是否可以得到和为target的子集,即dp[target]是否为target
return dp[target] == target
同时,也可以采用二维dp数组:
class Solution:
def canPartition(self, nums: List[int]) -> bool:
# 计算数组中所有数字的总和
total = sum(nums)
# 如果总和是奇数,则不可能将数组分为两个和相等的子集,直接返回False
if total % 2 != 0:
return False
# 获取数组的长度
n = len(nums)
# 计算目标和,这是我们要找的子集的和
target = sum(nums) // 2
# 初始化二维dp数组,dp[i][j]表示从数组前i个元素中能得到的最大子集和为j
dp = [[0 for _ in range(target+1)] for _ in range(n+1)]
# 遍历数组中的每个数字
for i in range(1, n+1):
for j in range(1, target+1):
# 如果不取nums[i-1]
dp[i][j] = dp[i-1][j]
# 如果j大于或等于nums[i-1],则还可以考虑选择nums[i-1]
if j >= nums[i-1]:
dp[i][j] = max(dp[i][j], dp[i-1][j-nums[i-1]] + nums[i-1])
# 返回是否可以从整个数组中找到子集和为target
return dp[n][target] == target
Leetcode 1049.最后一块石头的重量II
题目描述
有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:
- 如果
x == y,那么两块石头都会被完全粉碎; - 如果
x != y,那么重量为x的石头将会完全粉碎,而重量为y的石头新重量为y-x。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。
示例 1:
输入: stones = [2,7,4,1,8,1]
输出: 1
解释:
组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1],
组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1],
组合 2 和 1,得到 1,所以数组转化为 [1,1,1],
组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。
示例 2:
输入: stones = [31,26,33,21,40]
输出: 5
提示:
1 <= stones.length <= 301 <= stones[i] <= 100
解法
问题可以转化为将石头分成两堆,使得两堆的重量差最小。为此,我们可以采用类似于背包问题的方法,尝试将石头分到一个“背包”中,使其总重量不超过所有石头重量的一半,但又尽可能大。这样,剩下的石头自然就是第二堆。两堆石头的重量差就是我们的答案。
class Solution:
def lastStoneWeightII(self, stones: List[int]) -> int:
# 计算石头总重量的一半,这是“背包”的容量
target = sum(stones) // 2
# 初始化dp数组,dp[j]表示能否从stones中选择一些石头,其总重量恰好为j
dp = [0] * (target + 1)
# 遍历stones中的每块石头
for i in range(len(stones)):
# 从“背包”的容量开始,逆向到stones[i]
# 我们需要逆向,以避免在这个内层循环中多次使用同一块石头
for j in range(target, stones[i]-1, -1):
# 更新dp[j]
# 考虑是否加入当前石头stones[i],使得总重量达到j
dp[j] = max(dp[j], dp[j-stones[i]]+stones[i])
# dp[target]是我们能够从stones中取得的,且重量不超过target的最大重量
# 所以两堆石头的重量分别是dp[target]和sum(stones)-dp[target]
# 它们的重量差就是sum(stones) - 2*dp[target]
return sum(stones) - 2*dp[target]