笔者最近的算法学习到了动态规划的阶段,刷了很多动态规划的题,对于动态规划中的背包问题,刚开始真的很是头痛,不少题可能只是稍稍更改了一些约束条件,我就答不出来了。这类题虽然解题的方法都是相似,可是存在不少变种,不一样的变种,它所须要修改的解题思路都很是须要去仔细琢磨体会,因此我一开始,不但解题成功率不高,解题速度也是很是慢。在这里,我基于本身的实践以及一些前辈大佬的经验,总结概括了几个比较基础的解题方法,用来提升解答背包问题的成功率和效率。html
我先对最基础的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]
学习
dp[i-1][j]
的值来。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
?
nums
中的数据只能使用一次,不须要顺序关系,它的循环逻辑通常为
nums循环(x轴)嵌套target循环(y轴),且target循环倒序
复制代码
nums
中的数据能够重复使用,不须要顺序关系
nums循环(x轴)嵌套target循环(y轴),且target循环正序
复制代码
nums
中的数据可重复使用,可是须要考虑元素之间的顺序,不一样的顺序表明不一样的结果。
target循环(x轴)嵌套nums循环(y轴), 都正序
复制代码
求有多少种组合,有多少知足条件的项
dp[i] += dp[i-num];
复制代码
验证是否存在知足条件的项
dp[i] = dp[i] || dp[i-num];
复制代码
求知足条件的最大/小值
dp[i] = Math.max / min(dp[i], dp[i-num]+1);
复制代码
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
的元素,并且target
为j
时,能够凑成总金额的最少硬币个数
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
, 循环, 状态转移方程的定义的变化,因此咱们得根据具体的场景去对代码逻辑进行修改。
并且背包问题确定不止这么几种分类,它还有不少其余的变种,好比多维费用的背包问题,有依赖的背包问题等等。对于这些问题,还须要咱们对于这些基本的方法进行一个拓展。
总之,变强只有一条路,多刷题。
感谢各位的阅读,若是本文对你有所帮助的话,请动手点个赞,感谢!