算法学习中总结概括的几种背包问题的解题思路

前言

笔者最近的算法学习到了动态规划的阶段,刷了很多动态规划的题,对于动态规划中的背包问题,刚开始真的很是头痛,不少题可能只是稍稍更改了一些约束条件,我就答不出来了。这类题虽然解题的方法都是相似,可是存在不少变种,不一样的变种,它所须要修改的解题思路都很是须要去仔细琢磨体会,因此我一开始,不但解题成功率不高,解题速度也是很是慢。在这里,我基于本身的实践以及一些前辈大佬的经验,总结概括了几个比较基础的解题方法,用来提升解答背包问题的成功率和效率。html

经典0-1背包问题

我先对最基础的0-1背包问题作一个简单的介绍和回顾,其它的背包问题基本都是这个基础问题的变种。算法

有一个背包,它的容量为C。如今有n种不一样的物品,编号为0...n-1,
其中每一件物品的重量为w(物品重量的数组),价值为v(物品价值的数组)。
问能够向这个背包中盛放哪些物品,使得在不超过背包容量的基础上,物品的总价值最大?
复制代码

简单说下解题逻辑:数组

假设如今有个容量为c = 5的背包,有三个物品,它们的重量和价值的数组为 w = [1, 2, 3]v = [6, 10, 12]markdown

咱们定义一个二维数组dp,物品个数做为x轴,背包容量做为y轴。dp[i][j]表明当放入背包的物品个数为i + 1时,背包容量为j时,背包里的最大物品价值。dp初始值都为-1,以下:app

y轴长度为背包容量 + 1,便于后续计算ide

0 1 2 3 4 5
dp[0] -1 -1 -1 -1 -1 -1
dp[1] -1 -1 -1 -1 -1 -1
dp[2] -1 -1 -1 -1 -1 -1

咱们先给第一排赋值,当容量为1的时候,才能放下第一个元素,在dp中存入该元素的价值,即6。函数

0 1 2 3 4 5
dp[0] 0 6 6 6 6 6
dp[1] -1 -1 -1 -1 -1 -1
dp[2] -1 -1 -1 -1 -1 -1

而后咱们给第二排赋值,由于第二个元素的重量为2,因此以2为标准,这里有三种状况。假设当前节点为dp[i][j]学习

  • 当容量小于2的时候,放不下,因此按dp[i-1][j]的值来。
  • 当容量大于等于2的时候,拿dp[i-1][j]v[i] + dp[i - 1][c - w[i]]比较,取较大值。即比较不使用当前元素的最大值以及当前元素价值 + (容量-当前元素重量)时的最大价值
0 1 2 3 4 5
dp[0] 0 6 6 6 6 6
dp[1] 0 6 12 16 16 16
dp[2] -1 -1 -1 -1 -1 -1

最后赋值第三排,也是同样的规则,最终获得优化

0 1 2 3 4 5
dp[0] 0 6 6 6 6 6
dp[1] 0 6 10 16 16 16
dp[2] 0 6 10 16 18 22

dp[2][5]所表明的值即为咱们题目中须要求的最大值。不过咱们还能够对空间复杂度进行优化,由于二维数组dp在赋值的时候,只须要使用上面以及上一行左侧的元素,因此咱们能够用一个一维数组进行优化,在初始化dp[0]以后,使用从右往左的顺序来对dp进行赋值。ui

经典0-1背包问题的解法:

// 本例优化了空间复杂度,把二维数组优化成了一个一维数组
const knapsack01 = (w, v, c) => {
    let len = w.length;
    if (len === 0) return 0;
    let memo = new Array(c + 1).fill(0);
    for (let i = 0; i <= c; i ++) {
        if (i >= w[0]) memo[i] = v[0];
    }

    for (let i = 1; i < len; i ++) {
        for (let j = c; j >= w[i]; j --) {
            memo[j] = Math.max(memo[j], v[i] + memo[j - w[i]]);
        }
    }
    return memo[c];
}
复制代码

本题是最基本的背包问题,有过动态规划学习的同窗应该都能解答出来。

总结的几种背包问题的基本解题方法

咱们常见的有几种背包问题,我先列出它们基本的循环逻辑以及核心的状态转移方程。下面几节,关于这几种常见的背包问题,我都会列一个很是经典的例子来实践。

常见背包问题的特征:

通常都会给出一组数组nums,再给一个目标值target,要求从nums中取出多少个元素能够知足target?

循环逻辑

类0-1背包问题

nums中的数据只能使用一次,不须要顺序关系,它的循环逻辑通常为

nums循环(x轴)嵌套target循环(y轴),且target循环倒序
复制代码

可重复背包

nums中的数据能够重复使用,不须要顺序关系

nums循环(x轴)嵌套target循环(y轴),且target循环正序
复制代码

排列背包

nums中的数据可重复使用,可是须要考虑元素之间的顺序,不一样的顺序表明不一样的结果。

target循环(x轴)嵌套nums循环(y轴), 都正序
复制代码

状态转移方程

数量问题

求有多少种组合,有多少知足条件的项

dp[i] += dp[i-num];
复制代码

true,false问题

验证是否存在知足条件的项

dp[i] = dp[i] || dp[i-num];
复制代码

最大最小问题

求知足条件的最大/小值

dp[i] = Math.max / min(dp[i], dp[i-num]+1);
复制代码

实践:几道经典例题

组合总和4

leetcode 377
给你一个由 不一样 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。
题目数据保证答案符合 32 位整数范围。

输入:nums = [1,2,3], target = 4
输出:7
解释:全部可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不一样的序列被视做不一样的组合。
复制代码

咱们先来肯定循环以及核心状态转移方程。

由于取的组合数量,因此核心状态转移方程为dp[i] += dp[i-num]

属于排列背包,不一样顺序的结果被视做不一样的组合,因此循环方式为target循环嵌套nums循环, 都正序

咱们再来确认dp的定义,dp[i][j]表明当target值为j时,从nums中取出i个元素,能够知足总和为target的元素组合个数。

var combinationSum4 = function(nums, target) {
    // 二维数组优化成一维数组
    // 设置长度为target + 1,用于处理target = 0的状况
    const dp = new Array(target + 1).fill(0);
    // 初始化dp[0][j],当使用0个元素,以及target为0时,存在一种组合数,因此为1
    dp[0] = 1;
    for (let i = 1; i <= target; i++) {
        for (const num of nums) {
            if (num <= i) {
                // 只有当target大于当前num值,才可能存在使用当前num项的组合
                dp[i] += dp[i - num];
            }
        }
    }
    return dp[target];
};
复制代码

零钱兑换

leetcode 322
给定不一样面额的硬币 nums 和一个总金额 target。编写一个函数来计算能够凑成总金额所需的最少的硬币个数。
若是没有任何一种硬币组合能组成总金额,返回 -1。

你能够认为每种硬币的数量是无限的。

输入:nums = [1, 2, 5], target = 11
输出:3
解释:11 = 5 + 5 + 1

输入:nums = [2], target = 3
输出:-1

输入:nums = [1], target = 0
输出:0
复制代码

由于取的最小值,因此核心状态转移方程为dp[i] = Math.min(dp[i], dp[i-num]+1);

属于可重复背包,nums中的数据能够重复使用,因此循环方式为nums循环嵌套target循环,且target循环正序

dp[i][j]可定义为当使用第1,2, 3...nums.length的元素,并且targetj时,能够凑成总金额的最少硬币个数

var coinChange = function(nums, target) {
    const len = nums.length;
    // 边界条件处理
    if (len === 0) return target === 0 ? 0 : -1;
    const dp = new Array(target + 1).fill(Infinity);
    // 初始化当x轴为0,即只取nums中的第一个元素时,dp[0,1,2...target]的值
    for (let i = 0; i <= target; i ++) {
        // 当前target能够被nums[0]元素整除时,设置该值为i / nums[0]
        // 这里须要注意,当target为0时,能够存在一个0值,说明取了0个元素
        if ((i % nums[0]) === 0) dp[i] = i / nums[0];
    }
    for (let i = 1; i < len; i ++) {
        // 这里须要正序遍历,由于nums中的元素可被重复使用
        // 好比例子中的nums = [1, 2, 5], target = 11,当遍历到5的值时
        // target = 6时,dp[6]将被更新
        // amout = 11时,dp[11]的值会跟dp[6]有关,可是这时的dp[6]已经被更新过
        // 这样就实现了,nums中元素的重复使用
        for (let j = 1; j <= target; j ++) {
            if (j >= nums[i]) {
                // 核心状态转移方程
                dp[j] = Math.min(dp[j], dp[j - nums[i]] + 1);
            }
        }
    }
  // 结果的处理
  const res = dp[target];
  return res === Infinity ? -1 : res;
}
复制代码

分割等和子集

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

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

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

本题须要先对该数据进行处理,使其符合背包问题的形式。

由于是分红两个子集,因此只须要判断是否可以选出n个元素,和为sum/2

由于是判断是否存在知足的条件,因此核心状态转移方程为dp[i] = dp[i] || dp[i-num]

属于类0-1背包问题,由于nums中的数据只能使用一次,因此循环方式为nums循环嵌套target(即sum/2)循环,且target循环倒序

dp[i][j]表明当取nums[0...i]里的元素时,是否存在子集的值之和等于j

var canPartition = function(nums) {
    // 计算sum / 2
    let sum = 0, len = nums.length;
    for (let i = 0; i < len; i ++) {
        sum = sum + nums[i];
    }
    if (sum % 2 !== 0) return false;
    let target = sum / 2;
    // 使用target(sum / 2)做为容量,建立一个target + 1的数组
    let dp = new Array(target + 1).fill(false);
    // dp[0][j]的初始化,即当只取nums[0]的状况
    // 当i等于nums[0]的状况,dp[i]设为true
    for (let i = 0; i <= target; i ++) {
        dp[i] = (nums[0] === i);
    }
    // 这里的i表明当前取的是nums[i]元素
    // 套用上面的循环逻辑
    for (let i = 1; i < len;i ++) {
        for (let j = target; j >= nums[i]; j --) {
            // 套用上面的核心状态转移方程
            dp[j] = dp[j] || dp[j - nums[i]];
        }
    }
    return dp[target];
}
复制代码

总结和建议

本文中总结了一些常见的背包问题的解题方法(循环的方式,状态转移方程)。在背包问题的解题过程当中,若是题型能够对号入座, 那就能够根据这些方法去寻找解题的思路,对于初学者来讲,能够提升很多的解题效率。

这边我还须要说明一下,单纯的去记忆这些方法是没有用的,各位必定要在理解的基础上去记忆。由于这些方法只是一个最最基本的框,根据题目的不一样,条件的不一样,都会致使的边界状况、dp, 循环, 状态转移方程的定义的变化,因此咱们得根据具体的场景去对代码逻辑进行修改。

并且背包问题确定不止这么几种分类,它还有不少其余的变种,好比多维费用的背包问题,有依赖的背包问题等等。对于这些问题,还须要咱们对于这些基本的方法进行一个拓展。

总之,变强只有一条路,多刷题。

感谢

感谢各位的阅读,若是本文对你有所帮助的话,请动手点个赞,感谢!

相关文章
相关标签/搜索