vlambda博客
学习文章列表

算法面试题——动态规划01背包问题

本文引用了Leetcode第494、416、474题

我们先看一个智力题:

小王有一个限重15公斤的背包,有五样物品,每样物品只有一个,物品的重量和价值如下图所示:

在满足背包限重的情况下,怎样装,才能装走的物品价值最高呢?

相信聪明的朋友们动动纸笔就可以枚举出结果:小王装XXX这几样物品,既满足限重,又满足价值最高。

不过,如果把背包重量改为150公斤(小王可能是大力士),物品改为100件呢?有没有什么好方法呢?

这就是动态规划中的01背包问题,那么什么是背包问题,又怎么解决背包问题呢?

背包问题指的是给定一些物品,这些物品分别有自己的重量和价值(以及体积等),你有一个背包,这个背包能装的重量(以及体积等)有限,那么怎么装,能在不超过背包重量(以及体积等)的前提下,装走的物品价值最高呢?

背包问题根据每种物品的个数,又分为: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]])



算法面试题——动态规划01背包问题
填表

我们把整个过程填入表中:

算法面试题——动态规划01背包问题


算法面试题——动态规划01背包问题
撸代码
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件物品的信息。



算法面试题——动态规划01背包问题
降低空间复杂度

在上面的代码中,我们使用了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背包问题
思考一下

01背包问题是一个套路比较强的一类问题,它的关键在两个方面:

1. dp的初始值:初始值就是上面表的第一行,所以我们要想想,如果一个物品都没有,在0-W的情况下,应该填什么数字,那么dp的初始值自然而然地就想明白了。

2. 递推方程:在上面的例子中,递推方程是判断dp[j]和v[i]+dp[j-w[i]]之间的大小关系,取较大的那一个。那么,有没有其他递归关系呢?我们看下面LeetCode中的问题。



算法面试题——动态规划01背包问题
LeetCode 416 分割等和子集

问题如下:

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

注意:

每个数组中的元素不会超过 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。

剩下就是套公式的活儿了。



算法面试题——动态规划01背包问题
LeetCode 497 目标和

看这道题:

给定一个非负整数数组,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。

递推方程:当前的选取方法,是“选取当前元素”和“不选当前元素”的和。


LeetCode 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" 。

来源:力扣(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背包问题头疼么?