zl程序教程

您现在的位置是:首页 >  其他

当前栏目

动态规划之背包问题——01背包

规划 问题 动态 01 背包
2023-06-13 09:12:46 时间

大家好,又见面了,我是你们的朋友全栈君。

算法相关数据结构总结:

序号

数据结构

文章

1

动态规划

动态规划之背包问题——01背包 动态规划之背包问题——完全背包 动态规划之打家劫舍系列问题 动态规划之股票买卖系列问题 动态规划之子序列问题 算法(Java)——动态规划

2

数组

算法分析之数组问题

3

链表

算法分析之链表问题 算法(Java)——链表

4

二叉树

算法分析之二叉树 算法分析之二叉树遍历算法分析之二叉树常见问题算法(Java)——二叉树

5

哈希表

算法分析之哈希表算法(Java)——HashMap、HashSet、ArrayList

6

字符串

算法分析之字符串算法(Java)——字符串String

7

栈和队列

算法分析之栈和队列算法(Java)——栈、队列、堆

8

贪心算法

算法分析之贪心算法

9

回溯

Java实现回溯算法入门(排列+组合+子集)Java实现回溯算法进阶(搜索)

10

二分查找

算法(Java)——二分法查找

11

双指针、滑动窗口

算法(Java)——双指针算法分析之滑动窗口类问题

文章目录

背包问题中我们常见的就是 01背包完全背包。在leetcode的题库中主要就是这两种类型的题目。而完全背包又是也是01背包稍作变化而来,即:完全背包的物品数量是无限的。所以背包问题的基础就是01背包问题。完全背包问题请参考 动态规划之背包问题——完全背包

一、01背包问题

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

每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是O(2^n)。

所以需要动态规划来解题。

二、二维dp数组解决01背包问题

1. 确定dp数组以及下标的含义

dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

2. 确定递推公式

从两个方向推出来dp[i][j]

  1. 不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
  2. 放物品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的物品重量还小。

j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。

for (int j = 0 ; j < weight[0]; j++) { 
     // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略。
    dp[0][j] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= bagWeight; j++) { 
   
    dp[0][j] = value[0];
}

一开始把数组统一初始化为0,更方便。

4. 确定遍历顺序

有两个遍历维度:物品和背包重量。 所以要确定遍历顺序。

两者都可以,先遍历物品好理解。

先遍历物品,然后遍历背包重量:

// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { 
    // 遍历物品
    for(int j = 0; j <= bagWeight; j++) { 
    // 遍历背包容量
        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]);

    }
}

先遍历背包,再遍历物品:

// weight数组的大小 就是物品个数
for(int j = 0; j <= bagWeight; j++) { 
    // 遍历背包容量
    for(int i = 1; i < weight.size(); i++) { 
    // 遍历物品
        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]);
    }
}

为什么两种顺序都可以?要理解递归的本质和递推的方向

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。

dp[i-1][j]dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),

那么先遍历物品,再遍历背包的过程和先遍历背包,再遍历物品,dp[i][j]所需要的数据就是左上角,根本不影响dp[i][j]公式的推导。

5. 举例推导dp数组

最终结果就是dp[2][4]。

完整Java测试代码:

public static void testWeightBagProblem(int[] weight, int[] value, int bagSize){ 
   
        //定义dp数组:dp[i][j]表示背包容量为j时,前i个物品能获得的最大价值
        int[][] dp = new int[wLen + 1][bagSize + 1];
        //初始化:背包容量为0时,能获得的价值都为0
        for (int j = weight[0]; j <= bagWeight; j++) { 
   
            dp[0][j] = value[0];
        }
        //遍历顺序:先遍历物品,再遍历背包容量
        for (int i = 1; i <= weight.length; i++){ 
   
            for (int j = 1; j <= bagSize; j++){ 
   
                if (j < weight[i - 1]){ 
   
                    dp[i][j] = dp[i - 1][j];
                }else{ 
   
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
                }
            }
        }
    }

三、一维dp数组解决01背包问题

把二维dp降为一维dp,即使用一维滚动数组。

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

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

dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

1. 确定dp数组以及下标的含义

在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]

2. 一维dp数组的递推公式

dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。

dp[j - weight[i]] + value[i] 表示 容量为 j – 物品i重量 的背包加上物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j]

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]);

3. 一维dp数组如何初始化

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

假设物品价值都是大于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]);
    }
}

和二维dp的写法中,遍历背包的顺序是不一样的!

一维dp遍历的时候,背包是从大到小:倒叙遍历是为了保证物品i只被放入一次!

一旦正序遍历,那么物品0就会被重复加入多次。

举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15

如果正序遍历

dp[1] = dp[1 – weight[0]] + value[0] = 15

dp[2] = dp[2 – weight[0]] + value[0] = 30

此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

倒叙就是先算dp[2]

dp[2] = dp[2 – weight[0]] + value[0] = 15 (dp数组已经都初始化为0)

dp[1] = dp[1 – weight[0]] + value[0] = 15

所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

但对于二维dp,dp[i][j]都是通过上一层即dp[i – 1][j]计算而来,本层的dp[i][j]并不会被覆盖!

两个嵌套for循环的顺序先遍历物品嵌套遍历背包容量

因为一维dp的写法,背包容量一定是要倒序遍历,如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

5. 举例推导dp数组

一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:

完整Java测试代码

public static void testWeightBagProblem(int[] weight, int[] value, int bagWeight){ 
   
        //定义dp数组:dp[j]表示背包容量为j时,能获得的最大价值
        int[] dp = new int[bagWeight + 1];
        //遍历顺序:先遍历物品,再遍历背包容量
        for (int i = 0; i < weight.length; i++){ 
   
            for (int j = bagWeight; j >= weight[i]; j--){ 
   
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
            }
        }
    }

可以看出,一维dp 的01背包,要比二维简洁的多!而且空间复杂度还降了一个数量级!

四、leetcode例题讲解01背包问题

416. 分割等和子集

leetcode题目链接:416. 分割等和子集

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

示例一:

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

示例二:

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

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

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

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

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

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

1.确定dp数组以及下标的含义

套到本题,dp[i]表示 背包总容量是i,最大可以凑成i的子集总和为dp[i]。

2.确定递推公式

本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。

所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);

3.dp数组如何初始化

题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

4.确定遍历顺序

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]);
    }
}

5.举例推导dp数组 dp[i]的数值一定是小于等于i的。

如果dp[i] == i 说明,集合中的子集总和正好可以凑成总和i,理解这一点很重要。

用例1,输入[1,5,11,5] 为例,如图:

最后dp[11] == 11,说明可以将这个数组分割成两个子集,使得两个子集的元素和相等。

完整Java代码:

class Solution { 
   
    public boolean canPartition(int[] nums) { 
   
        if(nums == null || nums.length == 0) return false;
        int n = nums.length;
        int sum = 0;
        for(int num : nums){ 
   
            sum += num;
        }
        //总和为奇数,不能平分
        if(sum % 2 != 0) return false;
        int target = sum / 2;
        int[] dp = new int[target + 1];
        for(int i = 0; i < n; i++){ 
   
            for(int j = target; j >= nums[i]; j--){ 
   
                //物品 i 的重量是 nums[i],其价值也是 nums[i]
                dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
            }
        }
        return dp[target] == target;
    }
}

这道题目就是一道01背包应用类的题目,需要我们拆解题目,然后套入01背包的场景。

01背包相对于本题,主要要理解,题目中物品是nums[i],重量是nums[i],价值也是nums[i],背包体积是sum/2。

1049. 最后一块石头的重量 II

leetcode题目链接:1049. 最后一块石头的重量 II

有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。

最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。

示例一:

输入: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],这就是最优值。

示例二:

输入:stones = [31,26,33,21,40]
输出:5

本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这就回到了上一题分割等和子集,就化解成01背包问题了。

背包体积为sum/2,物品的重量stones[i],物品的价值stones[i]。

1.确定dp数组以及下标的含义

dp[i] 表示容量为i的背包最多可以装dp[i]重的石头

2.确定递推公式

dp[j] = max(dp[j], dp[j-stones[i]]+stones[i])

3.dp数组如何初始化

同上。

4.确定遍历顺序

同上,先遍历物品,然后倒序遍历背包。

完整Java代码:

class Solution { 
   
    public int lastStoneWeightII(int[] stones) { 
   
        int sum = 0;
        for(int stone : stones){ 
   
            sum += stone;
        }
        int target = sum / 2;
        int[] dp = new int[target+1];
        for(int i = 0; i < stones.length; i++){ 
   
            for(int j = target; j >= stones[i]; j--){ 
   
                dp[j] = Math.max(dp[j], dp[j-stones[i]] + stones[i]);
            }
        }
        return sum - 2 * dp[target];
    }
}

494. 目标和

leetcode题目链接:494. 目标和

给你一个整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :

例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例一:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例二:

输入:nums = [1], target = 1
输出:1

本题其实是让数组中的数字分成添加正号的数字和与添加负号的数字和相等,就化解成01背包的组合问题。

计算相等的方法有几种,就是组合问题。

这里可以设添加负号的数字和为neg (也可以设正号的),总和为sum,则添加正号的数字和为sum – neg。

要求 (sum – neg ) – neg= target,则neg = (sum – target) / 2。

判断target的值和sum的值的大小,如果target大,没有方案,不能整除也没有方案,都是非负整数。

转化为背包体积为neg= (sum – target) / 2,重量和价值都为nums[i]

1.确定dp数组以及下标的含义

dp[j] 表示元素和为j时的表达式数目

2.确定递推公式

dp[j] += dp[j], dp[j-nums[i]]

求组合类问题的公式,都是类似这种。

3.dp数组如何初始化

从递归公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递归结果将都是0。

dp[0] = 1,理论上也很好解释,装满容量为0的背包,有1种方法,就是装0件物品。

4.确定遍历顺序

同上,先遍历物品,然后倒序遍历背包。

完整Java代码:

class Solution { 
   
    public int findTargetSumWays(int[] nums, int target) { 
   
        // 动态规划,一个正集,一个负集,和为target
        // 设元素和为sum,负号和为neg,正号和为sum-neg ,则(sum-neg)-neg = target, neg = (sum - target) / 2
        // 装满背包体积为neg,重量和价值为nums[i]的方法有几种
        // dp[j] 表示元素和为j时的表达式数目
        int sum = 0;
        for(int num : nums){ 
   
            sum += num;
        }
        int diff = sum - target; // 判断target的值和sum的值的大小,如果target大,没有方案,不能整除也没有方案,都是非负整数
        if(diff < 0 || diff % 2 != 0) return 0; 
        int neg = diff / 2; // 添加负号集的和
        int[] dp = new int[neg+1];
        dp[0] = 1;
        for(int i = 0; i < nums.length; i++){ 
   
            for(int j = neg; j >= nums[i]; j--){ 
   
                dp[j] += dp[j - nums[i]];  // 组合问题
            }
        }
        return dp[neg];
    }
}

474. 一和零

leetcode题目链接:474. 一和零

给你一个二进制字符串数组 strs 和两个整数 m 和 n 。

请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

示例一:

输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 { 
   "10","0001","1","0"} ,因此答案是 4 。
其他满足题意但较小的子集包括 { 
   "0001","1"} 和 { 
   "10","1","0"} 。{ 
   "111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。

示例二:

输入:strs = ["10", "0", "1"], m = 1, n = 1
输出:2
解释:最大的子集是 { 
   "0", "1"} ,所以答案是 2 。

这道题可以说是一个变形的01背包问题,本题中strs 数组里的元素就是物品,每个物品都是一个!

而m 和 n相当于是一个背包,两个维度的背包。而不同长度的字符串就是不同大小的待装物品。

1.确定dp数组以及下标的含义

dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。

2.确定递推公式

dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1。

dp[i][j] 就可以是 dp[i – zeroNum][j – oneNum] + 1。

然后我们在遍历的过程中,取dp[i][j]的最大值。

dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);

对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。

这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。

3.dp数组如何初始化

因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。

4.确定遍历顺序

物品就是strs里的字符串,背包容量就是题目描述中的m和n。

所以背包倒序遍历的时候需要遍历两个维度。

完整Java代码:

class Solution { 
   
    public int findMaxForm(String[] strs, int m, int n) { 
   
        // 动态规划,01背包问题,背包有两个维度,一个m,一个n.
        // strs 数组里的元素就是物品,每个物品都是一个,不同长度的字符串都是待装物品
        // dp[i][j]表示最多有i个0,j个1的strs的最大子集长度
        // dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
        int zeroNum, oneNum;
        int[][] dp = new int[m+1][n+1];
        for(String str : strs){ 
     // 先统计每个字符串0的个数和1的个数,遍历物品
            zeroNum = 0;
            oneNum = 0;
            for(char c : str.toCharArray()){ 
   
                if(c == '0') zeroNum++;
                else oneNum++;
            }
            for(int i = m; i >= zeroNum; i--){ 
    // 遍历背包容量从后往前,两个背包
                for(int j = n; j >= oneNum; j--){ 
   
                    dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        return dp[m][n];
    }
}

五、01背包问题总结

1. 动规五步分析法

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

2. 背包递推公式

问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:

416. 分割等和子集

1049. 最后一块石头的重量 II

问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:

494. 目标和

问背包装满最大价值(多维背包问题):dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:

474. 一和零

3. 遍历顺序

二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历

一维dp数组01背包只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历

动态规划其它题型总结:

动态规划之背包问题——完全背包

动态规划之打家劫舍系列问题

动态规划之股票买卖系列问题

动态规划之子序列问题

参考:代码随想录:背包理论基础01背包

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/164404.html原文链接:https://javaforall.cn