(动态规划)01背包

背包九讲

https://blog.csdn.net/yandaoqiusheng/article/details/84782655

leedcode

0-1 背包问题
  第 416 题:分割等和子集;
  第 474 题:一和零;
  第 494 题:目标和。
  第 377 题. 组合总和 Ⅳ

完全背包问题如下:
  第 322 题:零钱兑换;
  第 518 题:零钱兑换 II。

常见背包问题分类

1、组合问题:
377. 组合总和 Ⅳ
494. 目标和
518. 零钱兑换 II

组合问题公式:

dp[0] = 1//怎么理解??
dp[i] += dp[i-num]//怎么理解??
2、True、False问题:
139. 单词拆分
416. 分割等和子集

True、False问题公式:

dp[i] = dp[i] or dp[i-num]
3、最大最小问题:
474. 一和零
322. 零钱兑换

最大最小问题公式:

dp[i] = min(dp[i], dp[i-num]+1)或者dp[i] = max(dp[i], dp[i-num]+1)
当然拿到问题后,需要做到以下几个步骤:

1.分析是否为背包问题。
2.是以上三种背包问题中的哪一种。
3.是0-1背包问题还是完全背包问题。也就是题目给的nums数组中的元素是否可以重复使用。
4.如果是组合问题,是否需要考虑元素之间的顺序。需要考虑顺序有顺序的解法,不需要考虑顺序又有对应的解法。

接下来讲一下背包问题的判定

背包问题具备的特征:给定一个target,target可以是数字也可以是字符串,再给定一个数组nums,nums中装的可能是数字,也可能是字符串,问:能否使用nums中的元素做各种排列组合得到target。

背包问题技巧:

  • 1.如果是0-1背包,即数组中的元素不可重复使用,nums放在外循环,target在内循环,且内循环倒序;
    0-1背包问题,为什么内循环要倒序呢?因为01背包不能重复选择
for num in nums:
    for i in range(target, nums-1, -1):
  • 2.如果是完全背包,即数组中的元素可重复使用,nums放在外循环,target在内循环。且内循环正序。
    完全背包的内外循环可以颠倒~
for num in nums:
    for i in range(nums, target+1):
  • 3.如果组合问题需考虑元素之间的顺序,需将target放在外循环,将nums放在内循环。
    为什么考虑顺序需要把循环层次对调?target 放在外层循环的话,是一个target的值对应nums所有的值,说的简单点就是这个target的值由nums中的某些组成,所以是有可能重复的。
for i in range(1, target+1):
    for num in nums:

代码

class Solution:
    def combinationSum4(self, nums: List[int], target: int) -> int:
        if not nums:
            return 0
        dp = [0] * (target+1)
        dp[0] = 1
        for i in range(1,target+1):
            for num in nums:
                if i >= num:
                    dp[i] += dp[i-num]
        return dp[target]

416. 分割等和子集 动态规划

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

注意:

每个数组中的元素不会超过 100
数组的大小不会超过 200

示例1:

输入: [1, 5, 11, 5]

输出: true

解释: 数组可以分割成 [1, 5, 5] 和 [11].

示例2:

输入: [1, 2, 3, 5]

输出: false

解释: 数组不能分割成两个元素和相等的子集.
首先回忆一下背包问题大致的描述是什么:
给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。
其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?

那么对于这个问题,我们可以先对集合求和,得出 sum,把问题转化为背包问题:

给一个可装载重量为 sum / 2 的背包和 N 个物品,每个物品的重量为 nums[i]。
现在让你装物品,是否存在一种装法,能够恰好将背包装满?
解法步骤

动态规划=填表

背包问题的表长什么样?为什么会多一行?
填表的顺序是什么样?
表格怎么初始化?(重点)
如何优化空间复杂度?
优化空间复杂度时,为什么是从右到左(逆序)填表?
在这里插入图片描述

第一步要明确两点,「状态」和「选择」。

0-1 背包问题 :

  • 状态就是「背包的容量」和「可选择的物品」,
  • 选择就是「装进背包」或者「不装进背包」。
第二步要明确 dp 数组的定义。

按照背包问题的套路,可以给出如下定义:

dp[i][j] = x表示,对于前 i 个物品,当前背包的容量为 j 时,若 x 为 true,则说明可以恰好将背包装满,若 x 为 false,则说明不能恰好将背包装满。

比如说,如果 dp[4][9] = true,其含义为:对于容量为 9 的背包,若只是用前 4 个物品,可以有一种方法把背包恰好装满。

或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进行选择,存在一个子集的和可以恰好凑出 9。

根据这个定义,我们想求的最终答案就是 dp[N][sum/2],base case 就是 dp[..][0] = truedp[0][..] = false,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。

关于bace case的讨论:

dp[0][0]表明装入0个物品,容量为0的背包恰好可以装满,理应为true,但是不符合题意,因此写成false~
第三步,根据「选择」,思考状态转移的逻辑。

回想刚才的 dp 数组含义,可以根据「选择」对 dp[i][j]得到以下状态转移:

for i 前0......n个物品
	for j 背包容量0.......m
		选择{
			if (j - nums[i - 1] < 0) {
               // 背包容量不足,不能装入第 i 个物品
                dp[i][j] = dp[i - 1][j];  // 保持不装入的状态
            } else {
                // 装入或不装入背包
                dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
            }
        }

如果不把 nums[i] 算入子集,或者说你不把这第 i 个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态 dp[i-1][j],继承之前的结果。

如果把 nums[i]算入子集,或者说你把这第 i 个物品装入了背包,那么是否能够恰好装满背包,取决于状态 dp[i - 1][j-nums[i-1]]

首先,由于 i 是从 1 开始的,而数组索引是从 0 开始的,所以第 i 个物品的重量应该是 nums[i-1],这一点不要搞混。

dp[i - 1][j-nums[i-1]] 也很好理解:你如果装了第 i 个物品,就要看背包的剩余重量j - nums[i-1]限制下是否能够被恰好装满。

换句话说,如果j - nums[i-1]的重量可以被恰好装满,那么只要把第 i 个物品装进去,也可恰好装满 j 的重量;否则的话,重量 j 肯定是装不满的。

bool canPartition(vector<int>& nums) {
    int sum = 0;
    for (int num : nums) sum += num;
    // 和为奇数时,不可能划分成两个和相等的集合
    if (sum % 2 != 0) return false;
    int n = nums.size();
    sum = sum / 2;
    vector<vector<bool>> 
        dp(n + 1, vector<bool>(sum + 1, false));
    // base case
    for (int i = 0; i <= n; i++)
        dp[i][0] = true;
    
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= sum; j++) {
            if (j - nums[i - 1] < 0) {
               // 背包容量不足,不能装入第 i 个物品
                dp[i][j] = dp[i - 1][j]; 
            } else {
                // 装入或不装入背包
                dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
            }
        }
    }
    return dp[n][sum];
}


第四步:空间复杂度优化

注意到dp[i][j]都是通过上一行dp[i-1][..]转移过来的,之前的数据都不会再使用了。

所以,我们可以进行状态压缩,将二维 dp 数组压缩为一维,节约空间复杂度:

bool canPartition(vector<int>& nums) {
    int sum = 0, n = nums.size();
    for (int num : nums) sum += num;
    if (sum % 2 != 0) return false;
    sum = sum / 2;
    vector<bool> dp(sum + 1, false);
    // base case
    dp[0] = true;

    for (int i = 0; i < n; i++) 
        for (int j = sum; j >= 0; j--) 
            if (j - nums[i] >= 0) 
                dp[j] = dp[j] || dp[j - nums[i]];

    return dp[sum];
}


这就是状态压缩,其实这段代码和之前的解法思路完全相同,只在一行 dp 数组上操作,i 每进行一轮迭代,dp[j] 其实就相当于 dp[i-1][j],所以只需要一维数组就够用了。

唯一需要注意的是 j 应该从后往前反向遍历,因为每个物品(或者说数字)只能用一次,以免之前的结果影响其他的结果。

  • 为什么要反向遍历?
    因为本次状态dp[10]时会需要使用到上次状态dp[1],上次状态dp[1]必须在被本次状态dp[1]覆盖前,先用来求取本次状态dp[10],然后再求取本次状态dp[1]。所以反向遍历,才能避免:计算本次状态dp[10]误用本次状态dp[1],应该要用上次状态dp[1]才可以。

至此,子集切割的问题就完全解决了,时间复杂度 O(n*sum),空间复杂度 O(sum)。

474. 一和零 动态规划

现在,假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。

你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。

注意:

给定 0 和 1 的数量都不会超过 100。
给定字符串数组的长度不会超过 600。

示例 1:

输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4

解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,
即 "10","0001","1","0" 。

示例 2:

输入: Array = {"10", "0", "1"}, m = 1, n = 1
输出: 2

解释: 你可以拼出 "10",但之后就没有剩余数字了。
更好的选择是拼出 "0" 和 "1" 。

注意点:
每个0和1都只能被使用1次,因此是01背包。
m 个 0 和 n 个 1,因此有2个背包容量。
最大值问题,因此状态转移是dp[i] = max(dp[i], dp[i-num]+1)

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
// strs.size()+1使表格多了一行,dp[0][..][..]表示前0个物品的装入情况,
// 增加该行初始化成0.因为没有物品
        vector<vector<vector<int>>> dp(strs.size()+1,vector<vector<int>>(m+1, vector<int>(n+1, 0)));

        for(int i = 1; i <= strs.size(); i++){
            // strs[i-1]需要偏移,因为i的意义是指前i个物品。
            // i=1时,指前1个物品,而str[1]已经指向第2个物品,所以需要偏移
            string s = strs[i-1];
            int n0 = 0, n1 = 0;
            getNumsOf01(s, n0, n1);
            // 01背包没有空间优化时,内循环的正序倒序。
            // 但是,加入空间优化后,就必须是倒序了。
            for(int j = 0; j <= m; j++){
                for(int k = 0; k <= n; k++){
                    // 比较物品重量与背包容量
                    if(n0 <= j && n1 <= k){
                        dp[i][j][k] = max(dp[i-1][j][k], 1+dp[i-1][j-n0][k-n1]);
                    }
                    else dp[i][j][k] = dp[i-1][j][k];
                }
            }
            
        }
        return dp[strs.size()][m][n];
    }
    int getNumsOf01(string& str, int& numOfZero, int& numOfOne){

        for(int i = 0; i < str.size(); i++){
            char c = str[i];
            if(c == '0') numOfZero++;
            else if(c == '1') numOfOne++;
        }
        return 0;
    }
};
空间优化

观察动态转移方程,我们发现dp[i][][] 只和dp[i-1][][]有关,所以可以去掉第一维,只用一个二维数组保存上一次计算的结果
注意:使用空间优化后,内循环需要逆序。

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        // strs.size()+1使表格多了一行,dp[0][..][..]表示前0个物品的装入情况,而该行通常被初始化成0.因为没有物品
        vector<vector<int>> dp(vector<vector<int>>(m+1, vector<int>(n+1, 0)));

        for(int i = 0; i < strs.size(); i++){
            // strs[i-1]需要偏移,因为i的意义是指前i个物品。
            // i=1时,指前1个物品,而str[1]已经指向第2个物品,所以需要偏移
            string s = strs[i];
            int n0 = 0, n1 = 0;
            getNumsOf01(s, n0, n1);

            for(int j = m; j >= n0; j--){
                for(int k = n; k >= n1; k--){
                    // 比较物品重量与背包容量
                    if(n0 <= j && n1 <= k){
                        dp[j][k] = max(dp[j][k], 1 + dp[j-n0][k-n1]);
                    }
                }
            }
        }
        return dp[m][n];
    }
    int getNumsOf01(string& str, int& numOfZero, int& numOfOne){

        for(int i = 0; i < str.size(); i++){
            char c = str[i];
            if(c == '0') numOfZero++;
            else if(c == '1') numOfOne++;
        }
        return 0;
    }
};

494. 目标和

从一个数组中挑选几个数组合成目标和,每个数只使用1次,即为01背包问题。

暴力法

dfs遍历所有元素,分为+-两种情况,当目标和被满足时,res++

一种常规的思路

在【416.分割等和子集】这道题中,要求的输出结果就是boolean值,因此我们定义的dp数组只需要记录T/F即可,但是这道题要求返回结果是方法数,那么我们dp数组需要记录的数据就是具体的方法数。

  • 状态:将dp[ i ][ j ]定义为从数组nums中 0 - i 的元素进行加减可以得到 j 的方法数量。
  • 转移方程:这道题的关键不是nums[i]的选与不选,而是nums[i]是加还是减,那么我们就可以将方程定义为:

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

  • 表格:每一行的长度表示为:t=(sum*2)+1,其中一个sum表示nums中执行全部执行加/减能达到的数

由于数组中所有数的和不超过 1000,那么 j 的最小值可以达到 -1000。在很多语言中,是不允许数组的下标为负数的,因此我们需要给 dp[i][j]的第二维预先增加 1000,即:

dp[i][j + nums[i] + 1000] += dp[i - 1][j + 1000]
dp[i][j - nums[i] + 1000] += dp[i - 1][j + 1000]
怎么把题目转化成简单的01背包的形式

思路:把整个集合看成两个子集,Q表示整个集合,P表示正数子集,N表示负数子集, TAREGT表示目标和,用SUM(X)表示集合的求和函数,集合中均为非负数,N集合是指选中这部分元素作为负数子集。

SUM(P)−SUM(N)=TARGET

SUM(P)+SUM(N) SUM(P)-SUM(N) = TARGET+SUM(P)+SUM(N)
SUM(P)+SUM(N)+SUM(P)-SUM(N)=TARGET+SUM(P)+SUM(N)

2SUM(P) = SUM(Q) + TARGET
2SUM(P) = SUM(Q) + TARGET

也就是:正数集的和的两倍 == 等于目标和 + 序列总和

所以问题就转换成了,找到一个正数集P,其和的两倍等于目标和+序列总和。

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        // 所有元素合,作为背包容量。
        int sum = 0;
        for (const int &it : nums) sum += it;
        // S + sum准备除以2,因此必须是奇数,并且给定的S可能为无穷大
        if ((S + sum) % 2 == 1 || S > sum) return 0;
        // 从nums中找到一个正数集P,其和的等于(目标和+序列总和)/2
        S = (S + sum) / 2;
        // 创建状态表格
        int *dp = new int[S + 1];
        memset(dp, 0, (S + 1) * sizeof(int));
        // base case 目标数为0的取法有一种,就是什么数都不取
        dp[0] = 1;
        // 外层遍历:所有物品     
        for (const int &it : nums) {
            // 内层遍历:所有容量
            for (int j = S; j >= it; j--)
                dp[j] += dp[j - it];
        }
        int ans = dp[S];
        delete[] dp;
        return ans;
    }
};


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值