算法面试题——动态规划01背包问题
Posted 前端三剑客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法面试题——动态规划01背包问题相关的知识,希望对你有一定的参考价值。
本文引用了Leetcode第494、416、474题
我们先看一个智力题:
小王有一个限重15公斤的背包,有五样物品,每样物品只有一个,物品的重量和价值如下图所示:
在满足背包限重的情况下,怎样装,才能装走的物品价值最高呢?
相信聪明的朋友们动动纸笔就可以枚举出结果:小王装XXX这几样物品,既满足限重,又满足价值最高。
不过,如果把背包重量改为150公斤(小王可能是大力士),物品改为100件呢?有没有什么好方法呢?
这就是动态规划中的01背包问题,那么什么是背包问题,又怎么解决背包问题呢?
背包问题指的是给定一些物品,这些物品分别有自己的重量和价值(以及体积等),你有一个背包,这个背包能装的重量(以及体积等)有限,那么怎么装,能在不超过背包重量(以及体积等)的前提下,装走的物品价值最高呢?
背包问题根据每种物品的个数,又分为:01背包问题、多重背包问题和完全背包问题。01背包问题中,每种物品只有一件;完全背包问题中,每种物品的个数无限;而多重背包问题中,每种物品的数量是有上限的。这篇文章中,我们只解决01背包问题。
我们首先把上面的问题抽象一下:
给定重量W,这个是背包的限重,每种物品的重量分别是w = [1, 1, 2, 4, 12],每种物品的价值v = [1, 2, 2, 10, 4]。求解价值最高的装法。
动态规划的思想就是把大问题分解成小问题,一步一步地解决小问题之后,大问题也就自然而然地解决了。而且,它和分治法的区别在于,动态规划是一个记忆系统,每个小问题解决之后,结果会保存下来,用来帮助解决下一个小问题,以避免重复计算,用空间换取时间。
动态规划最最困难的就是要找到递推方程。
对于01背包问题的递推方程,我们可以这么想:
对于最后一件物品,背包的限重为W的条件下,它有两种情况:
最后一件物品重量比背包限重W还要大,那么不放入背包,最后的价值就是对最后一件物品之前的物品的处理最优结果
最后一件物品重量小于等于背包限重W,那么我们就有两种选择:不放入背包内和放入背包内。对于不放入背包内,处理结果和上面那种情况相同;而对于放入背包内,则背包里已有的物品重量之和不能超过W-w[最后一件物品],如果已有物品已经是最优解,那么放入最后一件物品之后还是最优解。这两种选择,我们选取更优的结果就可以了。
上面就是最后一件物品的处理方法,我们发现,想要得到最优解,是需要知道之前的处理最优解的,即W-w[最后一件物品]重量下的最优解。以此类推。
那么,对于任意一件物品i怎么处理呢?
与上面类似,对于第i件的物品,在限重为j的条件下,它也是有两种情况:
第i件物品重量比此时限重j还要大,那么不放入背包,当前价值最优解就是对前i-1件物品处理最优结果
第i件物品重量小于等于此时限重j,那么我们就有两种选择:不放入背包内和放入背包内。对于不放入背包内,处理结果和上面那种情况一样了;而对于放入背包内,则背包里已有的物品重量之和不能超过j-w[i],如果已有物品已经是最优解,那么放入第i件物品之后还是最优解。这两种选择,我们选取更优的结果就可以了。
用公式表示这个递推方程就是:
w[i] > j 时候:dp[i][j] = dp[i-1][j]
w[i] <= j 时候:dp[i][j] = Math.max(dp[i-1][j], v[i] + dp[i-1][j-w[i]])
我们把整个过程填入表中:
function knapSack(W, w, v) {
let dp = [[]];
for (let j=0;j<=W;j++) {
dp[0].push(0);
}
for (let i=1;i<=w.length;i++) {
dp.push([0]);
for (let j=1;j<=W;j++) {
if (w[i]>j) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = Math.max(dp[i-1][j], v[i]+dp[i-1][j-w[i]]);
}
}
}
return dp[w.length-1][W];
}
let a = knapSack(15, [0, 1, 1, 2, 4, 12], [0, 1, 2, 2, 10, 4]);
console.log(a)
为了计算方便,我们把0添加在输入参数w和v的开始,这样可以直接使用v[i]和w[i]得到第i件物品的信息。
在上面的代码中,我们使用了dp——一个二维数组来存储中间结果。看是我们仔细看看递推方程:
if (w[i]>j) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = Math.max(dp[i-1][j], v[i]+dp[i-1][j-w[i]]);
}
我们会发现,dp[i][j]这一行的结果只与dp[i-1]这一行(也就是上一行)有关。而且,相关的元素要么是dp[i][j]正上方的元素(dp[i-1][j]),要么是左侧的元素。这提醒了我们,可以使用“滚动数组”来降低空间复杂度。我们可以把dp[i][j]压缩成dp[j]。
因为dp[j]依赖于其左侧元素的值,如果我们从左往右进行循环的话,会先更新左侧元素dp[j-w[i]],这样当我们计算到dp[j]的时候,dp[j-w[i]]已经是最新的值(可以理解为dp[i][j-w[i]]),并不是我们想要的“上一行”的值(可以理解为我们想要的是dp[i-1][j-w[i]])。因此,正确的做法是,从右往左进行循环。
另外,对于j < w[i]的情形,dp[j]就等于dp[j],不需要做任何的更新,因为j在循环的时候,只需要从W递减循环到w[i]即可。
简化代码如下:
function knapSack(W, w, v) {
let dp = [];
for (let j=0;j<=W;j++) {
dp.push(0);
}
for (let i=1;i<=w.length;i++) {
for (let j=W;j>=w[i];j--) {
dp[j] = Math.max(dp[j], v[i]+dp[j-w[i]]);
}
}
return dp[W];
}
let a = knapSack(15, [0, 1, 1, 2, 4, 12], [0, 1, 2, 2, 10, 4]);
console.log(a)
01背包问题是一个套路比较强的一类问题,它的关键在两个方面:
1. dp的初始值:初始值就是上面表的第一行,所以我们要想想,如果一个物品都没有,在0-W的情况下,应该填什么数字,那么dp的初始值自然而然地就想明白了。
2. 递推方程:在上面的例子中,递推方程是判断dp[j]和v[i]+dp[j-w[i]]之间的大小关系,取较大的那一个。那么,有没有其他递归关系呢?我们看下面LeetCode中的问题。
问题如下:
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/partition-equal-subset-sum
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
你看出这是01背包问题了么?问题可以做如下抽象:
在一个数组中,是否可以选取一些元素,让它们的和等于一个sum,而这个sum正是数组所有元素和的一半。那么自然,剩下没有被你选中的元素的和,也正好等于sum。我们的背包就是sum,而选什么元素,对应我们应选什么物品。
先看代码:
/**
* @param {number[]} nums
* @return {boolean}
*/
var canPartition = function(nums) {
const sumTotal = nums.reduce((sum, n)=> {
sum += n;
return sum;
}, 0);
if ((sumTotal % 2) !== 0) return false;
const sum = sumTotal/2;
let dp = [true];
for (let i=1;i<=sum;i++) {
dp[i] = false;
}
for (let n of nums) {
for (let j=sum;j>=n;j--) {
dp[j] = dp[j] || dp[j-n];
}
}
return dp[sum];
};
首先,如果所有元素和是奇数的话,sum不可能是整数,直接就可以结束程序。
然后,想想dp初始条件是什么。如果一个数字都不选,组成和为0是可以的,所以dp[0]是true,但是想要组成和为1到sum是不可能的,所以之后的值都为false。
再然后,递推方程。只要不取当前元素,或者取了当前元素,至少有一个为true,那么当前的dp[j]就true,也就是至少会有一种方式,让我们能在前n个元素中取出一些数字,让它们的和等于j。
剩下就是套公式的活儿了。
看这道题:
给定一个非负整数数组,a1, a2, ..., an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。
返回可以使最终数组和为目标数 S 的所有添加符号的方法数。
示例 1:
输入: nums: [1, 1, 1, 1, 1], S: 3
输出: 5
解释:
-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
一共有5种方法让最终目标和为3。
注意:
数组非空,且长度不会超过20。
初始的数组的和不会超过1000。
保证返回的最终结果能被32位整数存下。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/target-sum
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
这道题目看着挺唬人,我看到题目第一反应是回溯法,但是最后发现,动态规划可以更优雅地解决这个问题。
这个问题可以转化为上面LeetCode416问题:
sum(正数)-sum(负数) = target
sum(正数)+sum(负数)+sum(正数)-sum(负数)= target+sum(正数)+sum(负数)
2*sum(正数) = target+sum(所有数字)
sum(正数) = (target+sum(所有数字))/2
就是挑选一些数字,让它们的和等于(target+sum(所有数字))/2!看看一共有多少种挑选方式。代码如下:
/**
* @param {number[]} nums
* @param {number} S
* @return {number}
*/
var findTargetSumWays = function(nums, S) {
// pos - neg = target
// pos - neg + pos + neg = target + pos + neg
// 2 * pos = target + sum
const sum = nums.reduce((s, n)=> {
s += n;
return s;
}, 0);
if (sum < S) return 0;
if ((sum + S)%2 === 1) return 0;
const sumPos = (sum + S) / 2;
let dp = [1];
for (let i=1;i<=sumPos;i++) {
dp[i] = 0;
}
for (let n of nums) {
for (let i=sumPos; i>=n; i--) {
dp[i] = dp[i] + dp[i-n];
}
}
return dp[sumPos];
};
dp初始条件:如果一个数字都不选,组成和为0是可以的,所以dp[0]是1,代表有一种方法,但是想要组成和为1到sum是不可能的,所以之后的值都为0。
递推方程:当前的选取方法,是“选取当前元素”和“不选当前元素”的和。
再看看这道题:
在计算机界中,我们总是追求用有限的资源获取最大的收益。
现在,假设你分别支配着 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" 。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/ones-and-zeroes
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
01背包问题,考虑的只是物品重量,价值,以及背包的最大载重;升级的01背包问题,还会考虑物品的体积以及背包的最大体积,因为问题只是把dp从一维升级到二维,从dp[j]在j重量下的问题,升级到dp[j][k]在j重量下同时也在k体积下的问题。这个问题就是升级的01背包问题。
代码如下:
/**
* @param {string[]} strs
* @param {number} m
* @param {number} n
* @return {number}
*/
var findMaxForm = function(strs, m, n) {
let dp = [];
for (let i=0;i<=m;i++) {
dp.push([]);
for (let j=0;j<=n;j++) {
dp[i][j] = 0;
}
}
for (let str of strs) {
let num0 = 0, num1 = 0;
for (let s of str) {
if (s === "0") num0++;
if (s === "1") num1++;
}
for (let i=m;i>=num0;i--) {
for (let j=n;j>=num1;j--) {
dp[i][j] = Math.max(dp[i][j], dp[i-num0][j-num1]+1);
}
}
}
return dp[m][n];
};
初始条件:没有输入字符串的时候,什么都做不成,自然都是0,巧妇难为无米之炊啊!
递推方程:针对当前的字符串,比较“不选取当前字符串”和“选取当前字符串”(别忘记+1啊)的最大值。
看到了这里,你还在为01背包问题头疼么?
以上是关于算法面试题——动态规划01背包问题的主要内容,如果未能解决你的问题,请参考以下文章