leetcode刷题笔记——【动态规划】背包问题

232 阅读9分钟

参考教程

代码随想录 (programmercarl.com)

❤ 动态规划背包问题总结

动态规划五部曲

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

0-1背包问题

dp数组选择二维还是一维

与二维数组相比,一维数组是不断用dp[i]来覆盖dp[i-1],所以选择哪个都可以,但一维数组要注意遍历的顺序

image.png

dp数组遍历顺序

一维数组必须先遍历物品重量,再遍历背包容量

代码

二维数组

function testWeightBagProblem (weight, value, size) {
    // 定义 dp 数组
    const len = weight.length,
          dp = Array(len).fill().map(() => Array(size + 1).fill(0));

    // 初始化
    for(let j = weight[0]; j <= size; j++) {
        dp[0][j] = value[0];
    }
    // weight 数组的长度len 就是物品个数
    for(let i = 1; i < len; i++) { // 遍历物品
        for(let j = 0; j <= size; j++) { // 遍历背包容量
            if(j < weight[i]) dp[i][j] = dp[i - 1][j];
            else dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
        }
    }
    console.table(dp)

    return dp[len - 1][size];
}
function test () {
    console.log(testWeightBagProblem([1, 3, 4, 5], [15, 20, 30, 55], 6));
}
test();

一维数组

function testWeightBagProblem(wight, value, size) {
  const len = wight.length, 
    dp = Array(size + 1).fill(0);
  for(let i = 1; i <= len; i++) {
    for(let j = size; j >= wight[i - 1]; j--) {
      dp[j] = Math.max(dp[j], dp[j - wight[i - 1]]+ value[i - 1]);
    }
  }
  return dp[size];
}
function test () {
  console.log(testWeightBagProblem([1, 3, 4, 5], [15, 20, 30, 55], 6));
}
test();

刷题

494. 目标和

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

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

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

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

    示例 1:
    输入: 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

提示:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

题解

首先,题目可以理解为 left - right = target,而 left + right = sum,因此可以得出left = (sum + target)/2。
将 left 视为背包容量,nums[i] 即是重量,又是价值,题目的问题可以理解为是求解装满 left 容量的背包有多少种情况。

这道题是0、1背包问题,但求的是装满背包有多少种情况,因此递推公式应改为以下结构—— dp[j] += dp[j - nums[i]];

以下是JavaScript的实现代码:

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
var findTargetSumWays = function(nums, target) {
    const sum = nums.reduce((a, b) => a+b);
    if(Math.abs(target) > sum) {
        return 0;
    }
    if((target + sum) % 2) {
        return 0;
    }
    const halfSum = (target + sum) / 2;
    let dp = new Array(halfSum+1).fill(0);
    dp[0] = 1;
    for(let i = 0; i < nums.length; i++) {
        for(let j = halfSum; j >= nums[i]; j--) {
            dp[j] += dp[j - nums[i]];
        }
    }
    return dp[halfSum];
};
  • 时间复杂度:O(n × m),n为正数个数,m为背包容量
  • 空间复杂度:O(m),m为背包容量

474. 一和零

给你一个二进制字符串数组 strs 和两个整数 m 和 n 。 请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。

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

示例 1:

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

示例 2:

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

题解

这道题是一个01背包问题,m和n是两个背包容量,也可以说一个二维背包,而字符串数组中的每个字符串为放入背包的物品,每个物品只有一个,因此是01背包问题。

  1. 确定dp数组(dp table)以及下标的含义
    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] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);

此时大家可以回想一下01背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

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

/**
 * @param {string[]} strs
 * @param {number} m
 * @param {number} n
 * @return {number}
 */
var findMaxForm = function(strs, m, n) {
    let dp = new Array(m + 1).fill(0).map(() => (new Array(n +1).fill(0)))
    let zeroNum, oneNum
    for(let str of strs){
        zeroNum = 0, oneNum = 0
        for(let c of str){
            if(c === '0')zeroNum++;
            else oneNum++;
        }
        for(let i = m; i >= zeroNum; i--){
            for(let j = n; j >= oneNum; j--){
                dp[i][j] = Math.max(dp[i][j], dp[i-zeroNum][j-oneNum] + 1)
            }
        }
    }
    return dp[m][n]
};
  • 时间复杂度: O(kmn),k 为strs的长度
  • 空间复杂度: O(mn)

总结

完全背包问题

理论基础

  1. 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次) ,求解将哪些物品装入背包里物品价值总和最大。

  2. 完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。 因此只需要修改遍历的代码即可。

  3. 01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。而完全背包的物品是可以添加多次的,所以要从小到大去遍历

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

数组遍历顺序

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

代码

// 先遍历物品,再遍历背包容量
function test_completePack1() {
    let weight = [1, 3, 5]
    let value = [15, 20, 30]
    let bagWeight = 4 
    let dp = new Array(bagWeight + 1).fill(0)
    for(let i = 0; i <= weight.length; i++) {
        for(let j = weight[i]; j <= bagWeight; j++) {
            dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
        }
    }
    console.log(dp)
}

// 先遍历背包容量,再遍历物品
function test_completePack2() {
    let weight = [1, 3, 5]
    let value = [15, 20, 30]
    let bagWeight = 4 
    let dp = new Array(bagWeight + 1).fill(0)
    for(let j = 0; j <= bagWeight; j++) {
        for(let i = 0; i < weight.length; i++) {
            if (j >= weight[i]) {
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
            }
        }
    }
    console.log(2, dp);
}

刷题

518.零钱兑换2

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。 

var change = function(amount, coins) {
    let dp = new Array(amount +1).fill(0)
    dp[0] = 1
    for(let i = 0; i < coins.length; i++){
        for(let j = coins[i]; j <= amount; j++){
            dp[j] += dp[j - coins[i]]
        }
        console.log(dp)
    }
    return dp[amount]
};

377.组合 IV

完全背包 + 求排列的问题,因此遍历顺序为先遍历背包,再遍历物品,递推公式为 dp[j] += dp[j - nums[i]

var combinationSum4 = function(nums, target) {
    let dp = new Array(target + 1).fill(0)
    dp[0] = 1
    for(let j = 0; j <= target; j++){
        for(let i = 0; i < nums.length; i++){
            if(j - nums[i] >=0) dp[j]+= dp[j-nums[i]]
        }
    }
    return dp[target]
};

322.零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

这里是求最小硬币个数,因此递推公式应改为dp[j] = min(dp[j], dp[j - coins[i]]),同时这里无需考虑硬币的顺序,因此遍历顺序不需要特别指定,哪种都可以

var coinChange = function(coins, amount) {
    if(amount === 0)return 0;
    let dp = new Array(amount + 1).fill(Infinity)
    dp[0] = 0
    for(let i = 0; i< coins.length; i++){
        for(let j = coins[i]; j <= amount; j++){
            dp[j] = Math.min(dp[j], dp[j-coins[i]]+ 1)
        }
    }
    if(dp[amount] !== Infinity) return dp[amount];
    else return -1;
};

279.完全平方数

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,149 和 16 都是完全平方数,而 3 和 11 不是。

本题可以使用1*1、2*2、3*3、4*4等等作为物品重量

var numSquares = function(n) {
    let dp = new Array(n + 1).fill(Infinity)
    dp[0] = 0
    for(let i = 0; i**2<=n ;i++){
        for(let j = i*i; j <= n;j++){
            dp[j] = Math.min(dp[j], dp[j - i*i] +1)
        }
    }
    return dp[n]
};

139. 单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。

注意: 不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

本题单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。 拆分时可以重复使用字典中的单词,说明就是一个完全背包!

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

    dp[i] : 字符串长度为i的话,dp[i]为true,表示可以拆分chu为一个或多个在字典中出现的单词

  2. 确定递推公式

    如果确定dp[i] 是true,且 [i, j] 这个区间的子串出现在字典里,那么dp[j]一定是true。(j > i )。

    所以递推公式是 if([i, j] 这个区间的子串出现在字典里 && dp[i]是true) 那么 dp[j] = true。

  3. dp数组初始化

  4. 确定遍历顺序

    题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。

    还要讨论两层for循环的前后顺序。

    如果求组合数就是外层for循环遍历物品,内层for遍历背包

    如果求排列数就是外层for遍历背包,内层for循环遍历物品。这里需要考虑字符串顺序,因此是求排列数,需要先遍历背包,再遍历物品。

var wordBreak = function(s, wordDict) {
    let dp = new Array(s.length+1).fill(false)
    dp[0] = true
    for(let j = 0; j <= s.length; j++){
        for(let i = 0; i < wordDict.length; i++){
            if(j >= wordDict[i].length){
                if(s.slice(j - wordDict[i].length, j)===wordDict[i] && dp[j - wordDict[i].length]){
                    dp[j] = true
                }
            }
        }
    }
    return dp[s.length]
};

总结

背包问题总结

递推公式

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

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

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

问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:

遍历顺序

01背包

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

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

完全背包

纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

如果求最小数,那么两层for循环的先后顺序就无所谓了。