代码随想录day42 | 动态规划P4 | ● 背包问题 ● 416.473.698.

背包理论基础

背包问题分类

01背包

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

二维dp数组01背包

依然动规五部曲分析一波。

  1. 确定dp数组以及下标的含义   dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少
  2. 递推公式   dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
  3. dp初始化 第0列全0 (容量为0) 第一列根据当前容量与首个物品重量的关系进行初始化
  4. 确定遍历顺序 先物品再背包 / 先背包再物品均可
  5. 举例推导dp数组

递推公式确定:

  • 不放物品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得到的最大价值

46. 携带研究材料

卡码网第46题

题目描述

小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。 

小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。

输入描述

第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。

第二行包含 M 个正整数,代表每种研究材料的所占空间。 

第三行包含 M 个正整数,代表每种研究材料的价值。

输出描述

输出一个整数,代表小明能够携带的研究材料的最大价值。

输入示例

6 1
2 2 3 1 5 2
2 3 1 5 4 3

输出示例

5

思路

二维dp数组,标准01背包问题, 按照上述五步曲分析

代码

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        // 读取 N
        Scanner scanner = new Scanner(System.in);
        int M = scanner.nextInt();
        int N = scanner.nextInt();
        int[] weights = new int[M];
        int[] values = new int[M];

        for (int i = 0; i < M; i++) {
            weights[i] = scanner.nextInt();
        }
        for (int j = 0; j < M; j++) {
            values[j] = scanner.nextInt();
        }

        // 创建一个动态规划数组dp,初始值为0
        int[][] dp = new int[M][N+1];

        for(int i = 0; i <= N; i++){
            if(i >= weights[0]) dp[0][i] = values[0];
        }
        for (int i = 1; i < M; ++i) {

            for (int j = 1; j <= N; j++) {
                if(j < weights[i]) dp[i][j] = dp[i-1][j];
                else dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j - weights[i]] + values[i]);
            }
        }
        

        // 输出dp[M-1][N],即在给定 M 材料中任选, N 行李空间可以携带的研究材料最大价值
        System.out.println(dp[M-1][N]);
    }
}

01背包理论基础 滚动数组 

一维dp数组(滚动数组)

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

        可以把dp[i - 1]那一层拷贝到dp[i]上,只用一个一维数组dp[j](滚动数组)。

        这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

②动态规划五步曲

        1,确定dp数组及下标含义:在一维数组中 dp[ j ] 表示 容量为 j 的背包所能取得的最大价值

        2,确定递推公式: dp[j] = max(dp[ j ], dp[ j - w[ i ]] + value[ i  ])相对于二维 dp 数组的写法,是把 dp[i][j] 中 i 的维度去掉了;

        3.dp初始化: 对于价值都大于0的情况, dp初始化为0

关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?

看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。

这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。

        4. 确定遍历顺序:

先遍历物品, 再遍历背包

同时, 状态值的更新只与它上边和左上方元素有关. 将空间投影到一行后, 状态转移(填表)的时候,从右边到左边更新状态值;保证物品i只被放入一次!

        5.具体推导dp数组: 依然使用上述例子

代码

卡码网第46题

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        // 读取 N
        Scanner scanner = new Scanner(System.in);
        int M = scanner.nextInt();
        int N = scanner.nextInt();
        int[] weights = new int[M];
        int[] values = new int[M];

        for (int i = 0; i < M; i++) {
            weights[i]= scanner.nextInt();
        }
        for (int j = 0; j < M; j++) {
            values[j] = scanner.nextInt();
        }

        // 创建一个动态规划数组dp,初始值为0
        int [] dp = new int [N+1];
        
        for(int i = 0; i < M; i++){
            for(int j = N; j >= weights[i]; j--){
                dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
            }
        }

        // 输出dp[N],即在给定 N 行李空间可以携带的研究材料最大价值
        System.out.println(dp[N]);
    }
}

698.划分为k个相等的子集

给定一个整数数组 nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。

示例 1:

输入: nums = [4, 3, 2, 3, 5, 2, 1], k = 4
输出: True
说明: 有可能将其分成 4 个子集(5),(1,4),(2,3),(2,3)等于总和。

示例 2:

输入: nums = [1,2,3,4], k = 3
输出: false

思路

回溯算法, 回溯三步曲: 确定递归函数参数及返回值 , 确定终止条件 , 确定搜索的遍历过程; 这里再提出

「回溯算法框架」

解决回溯问题需要思考的 3 个问题:

  • 路径:已经做出的选择
  • 选择列表:当前可以做的选择
  • 结束条件:到达决策树底层,无法再做选择的条件

我们先结合下面的决策树,根据「排列」问题来详细分析一下如何理解「路径」「选择列表」

  • 当我们处于第 0 层的时候,其实可以做的选择有 3 种,即:选择 1 or 2 or 3
  • 假定我们在第 0 层的时候选择了 1,那么当我们处于第 1 层的时候,可以做的选择有 2 种,即:2 or 3
  • 假定我们在第 1 层的时候选择了 2,那么当我们处于第 2 层的时候,可以做的选择有 1 种,即:3
  • 当我们到达第 3 层的时候,我们面前的选择依次为:1,2,3。这正好构成了一个完整的「路径」,也正是我们需要的结果

经过上面的分析,我们可以很明显的知道「结束条件」,即:所有数都被选择

// 结束条件:已经处理完所有数
if (track.size() == nums.length) {
    // 处理逻辑
}

对问题进行一层抽象:有 n 个球,k 个桶,如何分配球放入桶中使得每个桶中球的总和均为target。如下图所示:

为了可以更好的理解「回溯」的思想,我们这里提供两种不同的视角进行分析对比

视角一:我们站在球的视角,每个球均需要做出三种选择,即:选择放入 1 号桶、2 号桶、3 号桶。所有球一共需要做 𝑘𝑛 次选择 (分析时间复杂度会用到)

这里提一个点:由于回溯就是一种暴力求解的思想,所以对于每个球的三种选择,只有执行了该选择才知道该选择是否为最优解。说白了就是依次执行这三种选择,如果递归到下面后发现该选择为非最优解,然后开始回溯,执行其他选择,直到把所有选择都遍历完

视角二:我们站在桶的视角,每个桶均需要做出六次选择,即:是否选择 1 号球放入、是否选择 2 号球放入、...、是否选择 6 号球放入。对于一个桶最多需要做 2𝑛 次选择,所有的桶一共需要做 (2𝑛)𝑘 次选择

视角一:球视角

如下图所示,「球」选择「桶」

下面给出「球视角」下的决策树

首先解释一下这棵决策树,第 i 层第 i 个球做选择,可做的选择:选择 1 or 2 or 3 号桶,直到第 n 个球做完选择后结束

由于,每个桶可以放下不止一个球,所以不存在某一个球选择了 1 号桶,另一个球就不能放入 1 号桶。判断是否可以放下的条件为:放入该球后,桶是否溢出?

同样的,根据本文给出的框架,详细分析一下如何理解「路径」「选择列表」

  • 当我们处于第 1 层的时候,即值为「1」的球开始做选择,可以做的选择有 3 种,即:选择放入 1 or 2 or 3 号桶
  • 假定我们在第 1 层的时候选择了放入 1 号桶,那么当我们处于第 2 层的时候,即值为「2」的球开始做选择,可以做的选择有 3 种,即:选择放入 1 or 2 or 3 号桶
  • 假定我们在第 2 层的时候选择了放入 1 号桶,那么当我们处于第 3 层的时候,即值为「2」的球开始做选择,可以做的选择有 3 种,即:选择放入 1 or 2 or 3 号桶
  • 假定我们在第 3 层的时候选择了放入 1 号桶,那么当我们处于第 4 层的时候,即值为「4」的球开始做选择,可以做的选择有 2 种,即:选择放入 2 or 3 号桶 (原因:1 号桶放入了 1 2 2,已经满了)
  • 假定我们在第 4 层的时候选择了放入 2 号桶,那么当我们处于第 5 层的时候,即值为「3」的球开始做选择,可以做的选择有 1 种,即:选择放入 3 号桶 (原因:2 号桶放入了 4,容纳不下 3 了)
  • 假定我们在第 5 层的时候选择了放入 3 号桶,那么当我们处于第 6 层的时候,即值为「3」的球开始做选择,可以做的选择有 0 种 (原因:3 号桶放入了 3,容纳不下 3 了)
  • 此时我们已经到达了最后一层!!我们来梳理一下选择的路径,即:「1 号桶:1 2 2」「 2 号桶:4」「3 号桶:3」。显然这条路径是不符合要求的,所以就开始回溯,回溯到第 5 层,改变第 5 层的选择,以此类推,直到得出「最优解」

经过上面的分析,我们可以很明显的知道「结束条件」,即:所有球都做了选择后结束

// 结束条件:已经处理完所有球
if (startIndex == nums.length) {
    // 处理逻辑
}

然后就是套用回溯三步曲, 利用好剪枝

代码

import java.util.Arrays;

//leetcode submit region begin(Prohibit modification and deletion)
class Solution {
    int sum = 0;

    public boolean canPartitionKSubsets(int[] nums, int k) {
        for(int i=0; i < nums.length; i++){
            sum += nums[i];
        }
        if(sum % k != 0) return false;
        int target = sum / k;
        int [] bucket = new int [k];

        //排序优化, 降序排列 使得剪枝更易命中
        Arrays.sort(nums);
        int left = 0, right = nums.length-1;
        while(left <= right){
            int temp = nums[right];
            nums[right] = nums[left];
            nums[left] = temp;
            left++;
            right--;
        }
        return backtracking(nums, 0, bucket, k, target);

    }
    public boolean backtracking(int [] nums, int startIndex, int [] bucket, int k, int target){
        if(startIndex == nums.length){
//            for(int i = 0; i < k; i++){
//                if(bucket[i] != target) return false;
//            }
            //当 startIndex == num.length 时,所有球已经按要求装入所有桶,所以肯定是一个满足要求的解
            return true;
        }

        for(int i = 0; i < k; i++){
            //剪枝  当前桶放入球后超过 target 的值,选择下一个桶
            if(bucket[i] + nums[startIndex] > target) continue;

            // 树层去重 当当前桶与上一个桶内元素和相等, 则跳过
            // 因为如果元素和相等,那么 nums[index] 选择上一个桶和选择当前桶可以得到的结果是一致的
            if(i > 0 && bucket[i] == bucket[i-1]) continue;

            bucket[i] += nums[startIndex];

            if(backtracking(nums, startIndex + 1, bucket, k, target)) return true;

            bucket[i] -= nums[startIndex];
        }

        return false;
    }
}
//leetcode submit region end(Prohibit modification and deletion)

473.火柴拼正方形

你将得到一个整数数组 matchsticks ,其中 matchsticks[i] 是第 i 个火柴棒的长度。你要用 所有的火柴棍 拼成一个正方形。你 不能折断 任何一根火柴棒,但你可以把它们连在一起,而且每根火柴棒必须 使用一次 。

如果你能使这个正方形,则返回 true ,否则返回 false 。

示例 1:

输入: matchsticks = [1,1,2,2,2]
输出: true
解释: 能拼成一个边长为2的正方形,每边两根火柴。

示例 2:

输入: matchsticks = [3,3,3,3,4]
输出: false
解释: 不能用所有火柴拼成一个正方形。

思路

回溯算法, 本题相当于划分 4 个相等的子集

代码

class Solution {
	public boolean makesquare(int[] matchsticks) {
		int sum = 0;
		for (int match : matchsticks) {
			sum += match;
		}
		if (sum % 4 != 0) return false;
		int len = sum / 4;
		int[] bucket = new int[4];
        Arrays.sort(matchsticks);
        int left = 0, right = matchsticks.length - 1;
        while(left < right){
            int temp = matchsticks[right];
            matchsticks[right] = matchsticks[left];
            matchsticks[left] = temp;
            left++;
            right--;
        }

		return backtracking(matchsticks, 0, len, bucket);
	}

	public boolean backtracking(int[] matchsticks, int startIndex, int len, int[] bucket) {
		if (startIndex == matchsticks.length) {
			return true;
		}

		for (int i = 0; i < 4; i++) {
            if(bucket[i] + matchsticks[startIndex] > len) continue;

            if(i > 0 && bucket[i] == bucket[i-1]) continue;

            bucket[i] += matchsticks[startIndex];

            if(backtracking(matchsticks, startIndex + 1, len, bucket)) return true;

            bucket[i] -= matchsticks[startIndex];
		}
        return false;
	}
}
 

416. 分割等和子集 

给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

这道题目初步看,和上面两题几乎是一样的,大家可以用回溯法,解决如下两题

  • 698.划分为k个相等的子集
  • 473.火柴拼正方形

这道题目是要找是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

那么只要找到集合里能够出现 sum / 2 的子集总和,就算是可以分割成两个相同元素和子集了。

本题是可以用回溯暴力搜索出所有答案的,但最后超时了,也不想再优化了,放弃回溯,直接上01背包吧。

问题转化:

背包问题是指 有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

背包问题有多种背包方式,常见的有:01背包、完全背包、多重背包、分组背包和混合背包等等。

要注意题目描述中物品是不是可以重复放入。

即一个商品如果可以重复多次放入是完全背包,而只能放入一次是01背包,写法还是不一样的。

要明确本题中我们要使用的是01背包,因为元素我们只能用一次。

回归主题:首先,本题要求集合里能否出现总和为 sum / 2 的子集。那么来一一对应一下本题,看看背包问题如何来解决。

  • 背包容量为 sum / 2

  • 背包所能放入的物品 (集合中的元素) 重量nums[ i ] 价值也是nums[ i ]

  • 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
  • 背包中每一个元素是不可重复放入。

以上分析完,我们就可以套用01背包,来解决这个问题了。

思路

动态规划五步曲

        确定dp数组以及下标含义: dp[ i ] 容量为 i 的背包所能容纳物品的最大价值 套到本题,dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]

        确定递推公式: dp{ j ] = max(dp[ j ] , dp[ j - nums[ i ] + nums[ i ])

        初始化: 

从dp[j]的定义来看,首先dp[0]一定是0。

如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了

        确定递归顺序:01背包问题 使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!

        举例推导:

代码

class Solution {
	public boolean canPartition(int[] nums) {
		int sum = Arrays.stream(nums).sum();
		if(sum % 2 != 0) return false;
		sum = sum/2;
		int [] dp = new int [sum + 1];

		for(int i = 0; i < nums.length; i++){
			for(int j = sum; j >= nums[i]; j--){
				dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
			}

			//剪枝一下,每一次完成內層的for-loop,立即檢查是否dp[target] == target,
			//优化时间复杂度(26ms -> 19ms)
			if(dp[sum] == sum) return true;
		}

		return dp[sum] == sum;
	}
}

  • 25
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值