Leetcode刷题笔记42:动态规划4(背包理论-416.分割等和子集-1049.最后一块石头的重量II)

193 阅读8分钟

导语

leetcode刷题笔记记录,本篇博客是动态规划4,主要记录背包理论(0-1背包)题目包括:

知识点

背包理论

背包问题在动态规划中是一类经典问题,主要涉及到物品和一个有限的背包容量,需要决定如何选择物品以满足某种优化准则(如最大价值)。以下是背包问题的几种基本形式:

  1. 0-1背包问题

    • 你有一个容量为 W 的背包和 N 个物品。每个物品都有自己的重量和价值。你的目标是在不超过背包容量的情况下,最大化物品的总价值。
    • 状态转移方程为:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i]] + values[i]),其中 i 是物品的数量,j 是背包的容量。
  2. 完全背包问题

    • 与 0-1 背包问题相似,但每种物品都有无限件可用。
    • 状态转移方程为:dp[i] = max(dp[i], dp[i-weights[j]] + values[j]),其中 j 是物品的索引。
  3. 多重背包问题

    • 与完全背包问题类似,但每种物品有有限的可用数量。
    • 对于这种问题,通常会使用三重循环来解决:一个循环遍历物品,一个循环遍历背包容量,一个循环遍历每种物品的数量。
  4. 分组背包问题

    • 物品被分成了几个组,从每个组中选择某些物品,以使得背包中物品的总价值最大。且每个组只能选一个物品。
    • 状态转移方程为:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[k]] + values[k]),其中 k 是在第 i 组中的物品。
  5. 其它变体:如路径计数、恰好装满背包等。

基本思路: 对于大部分背包问题,核心思想是使用二维数组 dp[i][j],其中 i 通常代表选择到第 i 个物品时,j 代表背包的容量。值 dp[i][j] 表示在给定的约束条件下可以得到的最大价值。解决背包问题的关键在于状态转移方程,这通常涉及决定包括或不包括当前物品,然后在之前的选择中寻找最优解。

注意:背包问题的解决方法不仅限于动态规划,还可以通过回溯、贪心、分支界限等方法解决,但动态规划是最常用的方法。

0-1背包

0-1背包问题是背包问题中的经典版本,得名于每种物品只能选择一次或完全不选择(0 或 1)。

问题描述

给定一个固定容量为 W 的背包和 N 个物品,每个物品 i 有自己的重量 weights[i] 和价值 values[i]。目标是选择一些物品放入背包,使得背包的总价值最大化,同时不超过背包的容量。

问题特性

  • 每种物品只有一件。
  • 可以选择放入物品或者不放。

动态规划解法

  1. 定义状态: 定义一个二维数组 dp[i][j],其中 i 表示前 i 个物品,j 表示背包的容量。dp[i][j] 的值表示在前 i 个物品中选择并且背包容量为 j 时可以获得的最大价值。

  2. 初始化状态

    • 对于j=0(背包容量为0),dp[i][0] = 0,因为容量为0的背包不能容纳任何物品。
    • 对于i=0(没有物品可选),dp[0][j] = 0,因为没有物品可以放入背包。
  3. 状态转移方程: 对于每个物品 i 和每个容量 j:

    • 如果 weights[i]>j(物品 i 的重量大于当前的背包容量),那么物品 i 不能被选择,因此 dp[i][j] = dp[i-1][j]

    • 否则,我们有两种选择:

      1. 不选择物品 i:dp[i-1][j]
      2. 选择物品 i:dp[i-1][j-weights[i]] + values[i]
    • 取这两种选择中的最大值,即 dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i]] + values[i])

  4. 结果dp[N][W] 表示在给定背包容量 W 和所有物品的情况下可以获得的最大价值。

优化

由于每次更新 dp[i][j] 时只依赖于上一行的数据,因此可以使用滚动数组或一维数组来优化空间复杂度,从 O(N×W) 优化到 O(W)。但是经过状态压缩后,dp数组的遍历顺序是固定的,如下图所示:

image.png

即一维滚动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 <= 200
  • 1 <= nums[i] <= 100

解法

这道题目可以转化为一个0-1背包问题,即我们的背包容量为sum/2,其中候选的物品就是数组中的元素,物品的重量和价值都是自身的数值大小,每个物品仅能选取0或1次。目标则是填满背包。接下来应用动规五部曲:

  1. dp数组含义:容量为j的背包,最大价值为dp[j]
  2. 递推公式:参考一维滚动数组的递推公式,在该问题场景下,由于重量和价值相同,则
dp[j]=max(dp[j],dp[jnums[i]]+nums[i])dp[j]=max(dp[j], dp[j-nums[i]]+nums[i])
  1. 初始化:由于递推公式中需要进行max操作判断,为了不影响这个操作,dp[i]应该初始化为非负整数中的最小值,即0
  2. 遍历顺序:由于采用了一维滚动数组,所以遍历顺序是固定的,即先遍历物品,后遍历背包容量,且倒序;
  3. 打印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

image.png

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 <= 30
  • 1 <= 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]