秋招算法备战第42天 | 背包问题(二维、一维)、416. 分割等和子集

背包问题

01背包和完全背包就够用了

二维dp数组01背包

  1. 确定dp数组以及下标的含义:对于背包问题,有一种写法, 是使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
  2. 确定递推公式:那么可以有两个方向推出来dp[i][j]
    不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)
    放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
    所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
  3. dp数组如何初始化
    首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。
    状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
    dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
    那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
  4. 确定遍历顺序
    那么问题来了,先遍历 物品还是先遍历背包重量呢?
    其实都可以!! 但是先遍历物品更好理解。
  5. 举例推导dp数组
def test_2_wei_bag_problem1(weight, value, bagweight):
    # 二维数组
    dp = [[0] * (bagweight + 1) for _ in range(len(weight))]

    # 初始化
    for j in range(weight[0], bagweight + 1):
        dp[0][j] = value[0]

    # weight数组的大小就是物品个数
    for i in range(1, len(weight)):  # 遍历物品
        for j in range(bagweight + 1):  # 遍历背包容量
            if j < weight[i]:
                dp[i][j] = dp[i - 1][j]
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])

    return dp[len(weight) - 1][bagweight]

if __name__ == "__main__":

    weight = [1, 3, 4]
    value = [15, 20, 30]
    bagweight = 4

    result = test_2_wei_bag_problem1(weight, value, bagweight)
    print(result)

一维dp数组01背包(滚动数组)

对于背包问题其实状态都是可以压缩的。

在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);

与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。

  1. 确定dp数组的定义
    在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
  2. 一维dp数组的递推公式
    dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值,
    所以递归公式为:
    dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
    可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。
  3. 一维dp数组如何初始化
    假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
  4. 一维dp数组遍历顺序
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

  1. 举例推导dp数组

GPT4对于背包问题的解释

背包问题是计算机科学和优化领域中的经典问题,主要关于资源的最优分配。最著名的背包问题是 0-1 背包问题,但它有很多变种。

0-1 背包问题
给定 n 个物品,每个物品有一个重量 w[i] 和一个价值 v[i]。现在有一个背包,最多可以承载重量 W。问如何选择物品放入背包,使得背包中物品的总价值最大,同时不超过背包的重量限制?

解决这个问题的一个常见方法是使用动态规划。以下是一个简化的算法:

初始化一个二维数组 dp[n+1][W+1]
其中 dp[i][j] 表示考虑前 i 个物品,总重量不超过 j 的最大价值

for i from 1 to n:
    for j from 1 to W:
        if w[i-1] <= j:
            dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i-1]] + v[i-1])
        else:
            dp[i][j] = dp[i-1][j]

返回 dp[n][W],这是最大的价值

这种方法的时间复杂度是 O(nW)。

除了 0-1 背包问题,还有一些其他变种,例如:

  • 分数背包问题:物品可以被分割。这意味着你可以取走物品的一部分而不是整个物品。这个问题可以使用贪婪算法来解决。

  • 完全背包问题:每种物品可以取无数次。这需要稍微修改上面的动态规划解法。

  • 多重背包问题:每种物品有有限的数量。

这些问题虽然看起来很相似,但它们的解决方法可以根据问题的具体限制和要求有所不同。

416. 分割等和子集 - 力扣(LeetCode)

GPT4

此问题可以转化为 0-1 背包问题的变种来解决。

思路:

  1. 首先,我们需要判断整个数组的和是否是偶数,因为只有当总和为偶数时,我们才可能将其分为两个和相等的子集。
  2. 如果数组的总和是偶数,则问题变成了:是否存在一个子集,其和为 total/2,其中 total 是数组的总和。

使用动态规划来解决这个问题:

  1. target = total/2
  2. 创建一个长度为 target + 1 的布尔数组 dp,其中 dp[i] 表示能否从数组中选择部分数字使其和为 i
  3. 初始化 dp[0] = true
  4. 对于数组中的每个数字 num 和从 target1 的每个数字 i,如果 i >= num 并且 dp[i-num] == true,则设置 dp[i] = true
  5. 返回 dp[target]

代码:

def canPartition(nums):
    total = sum(nums)
    
    # 如果总和是奇数,则不能将其分为两个和相等的子集
    if total % 2 != 0:
        return False
    
    target = total // 2
    dp = [False] * (target + 1)
    dp[0] = True
    
    for num in nums:
        for i in range(target, num-1, -1):
            dp[i] = dp[i] or dp[i-num]
            
    return dp[target]

# 测试
print(canPartition([1,5,11,5]))  # True
print(canPartition([1,2,3,5]))  # False

这个算法的时间复杂度为 O(n * target),其中 n 是 nums 的长度,target 是目标和。

代码随想录

只有确定了如下四点,才能把01背包问题套到本题上来。

  1. 背包的体积为sum / 2
  2. 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
  3. 背包如果正好装满,说明找到了总和为sum / 2 的子集。
  4. 背包中每一个元素是不可重复放入。

动规五部曲分析如下:

  1. 确定dp数组以及下标的含义
    dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]。
  2. 确定递推公式
    01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。
    所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
  3. dp数组如何初始化
    本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。
  4. 确定遍历顺序:如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
// 开始 01背包
for(int i = 0; i < nums.size(); i++) {
    for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
        dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
    }
}
  1. 举例推导dp数组
    如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,理解这一点很重要。
class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        if sum(nums) % 2 != 0:
            return False
        target = sum(nums) // 2
        dp = [0] * (target + 1)
        for num in nums:
            for j in range(target, num-1, -1):
                dp[j] = max(dp[j], dp[j-num] + num)
        return dp[-1] == target

总结

这是自己第一次比较系统地学习背包问题,感觉如何识别出一个题目是背包问题是此类题目的重点(笔记末尾总结了如何识别出一个题目是背包问题)

如何识别出一个题目是背包问题

背包问题是优化问题中的一个经典范例,主要围绕有限的资源(如背包的容量)来选择最优解(如最大价值)。虽然背包问题有很多变体,但它们之间存在一些共同特点。以下是一些提示来帮助你识别一个题目是否可能是背包问题:

  1. 有限资源:问题中通常会有一个有限的资源,如背包的容量、时间、预算等。

  2. 选择的问题:你需要从给定的一组物品或任务中选择一部分,而不是所有。

  3. 最优化目标:通常,目标是最大化或最小化某些值,如最大化价值或最小化花费。

  4. 重复子问题:解决问题的方法经常涉及到处理重复的子问题。例如,在0-1背包问题中,你可能会多次询问“如果我加入这个物品,剩余的背包容量是多少?”这种结构通常暗示可以使用动态规划来解决。

  5. 子集与组合:背包问题经常要求找到一个物品的子集或组合,这样总的权重或花费不超过给定的限制。

  6. 隐含的顺序不重要性:在大多数背包问题中,物品或任务的顺序并不重要。这与其他一些问题(如序列对齐或最长上升子序列)形成对比,其中元素的顺序非常重要。

  7. 变体提示

    • 如果物品可以选择多次,则可能是完全背包问题。
    • 如果物品可以被分割,则可能是分数背包问题。
    • 如果每种物品都有数量限制,则可能是多重背包问题。
  8. 上下文线索:题目描述中可能会有一些关键词或短语,如“最大化总价值”、“容量限制”、“选择子集”等。

不是所有具有上述特点的问题都是背包问题,但上述线索可以帮助你在遇到可能的背包问题时提供一些方向。如果一个问题看起来像背包问题,可以尝试使用背包问题的方法来解决它。如果这种方法有效,那么很可能就是一个背包问题。

你可能感兴趣的:(算法,背包问题)