【代码随想录刷题总结】0-1背包问题
引言
大家好啊,我是前端拿破轮😁。
跟着卡哥学算法有一段时间了,通过代码随想录的学习,受益匪浅,首先向卡哥致敬🫡。
但是在学习过程中我也发现了一些问题,很多当时理解了并且AC的题目过一段时间就又忘记了,或者不能完美的写出来。
根据费曼学习法,光有输入的知识掌握的是不够牢靠的,所以我决定按照代码随想录的顺序,输出自己的刷题总结和思考。
同时,由于以前学习过程使用的是JavaScript,而在2025年的今天,TypeScript几乎成了必备项,所以本专题内容也将使用TypeScript,来巩固自己的TypeScript语言能力。
0-1背包理论基础
什么是0-1背包问题?
0-1背包问题是经典的动态规划问题之一。
问题描述
给定n个物品,每个物品有两个属性:
w[i]:第i个物品的重量(weight)v[i]:第i个物品的价值(value)
再给一个背包容量W(能承受的最大总重量),问我们:
从这
n个物品中选择若干个(每个物品最多只能选择一次),使得在不超过总容量W的前提下,获得的最大价值是多少?
这是纯正的*0-1背包问题。这里的“0-1”表示每个物品要么选(1),要么不选(0),不能部分选取或重复选取。
如何解决0-1背包问题
我们使用动规五部曲来解决:
- 确定dp数组及其含义
我们定义一个二维数组
1 | dp[i][j]表示从0-i的物品中选择,背包容量为j的情况下,所能获取的最大价值 |
- 确定动态转移方程
所谓的动态转移方程,就是不同的dp[i][j]之间的关系,有点类似于高中数学学习的数列递归公式。
那对于第i个物品(下标从0开始),我们有两种选择:
- 不选第
i个物品,那么最大的价值就和只在前i-1个物品中选择的最大价值是一样的,即dp[i][j] = dp[i - 1][j]。 - 选第
i个物品(前提是背包能放下第i个物品,即j >= w[i]),那么最大价值就应该是,在第i个物品的价值v[i]加上前i-1个物品在背包容量为j-w[i]的情况下的最大价值dp[i - 1][j - w[i]]。我们要的是最大价值,所以dp[i][j]要取两者之间的最大值。
1 | if (j < w[i]) { |
- 确定初始化条件
我们确定初始化条件一定要在确定状态转移方程之后,因为只有确定了状态转移方程,我们才能知道需要初始化哪些值。根据我们的状态转移方程dp[i][j]只与它正上方的值,和左上方的值有关。所以我们初始化的时候,只需要初始化最左边的一列和最上面的一行即可。
我们先来考虑第一列,即dp[i][0],回顾一下dp数组的含义,dp[i][0]表示0-i的物品中选取,装到背包容量为0的背包中,所能装的最大价值。背包容量已经为0了,肯定无法装任何东西,所以这一列初始化为0。
然后我们来考虑第一行dp[0][j],再回顾dp数组的含义dp[0][j]表示的是只能装第0个物品,背包容量为j,在这种情况下的最大价值。不能发现,如果背包能装下第0个物品,那么最大价值就是第0个物品的价值v[0],如果背包装不下第0个物品,那么最大价值就是0.
至于其他位置的值,因为我们的状态转移方程表面,当前位置的值只取决于正上方和左上方,和当前位置自身值无关,所以其他位置初始化成多少都可以。都会在我们遍历的过程中被覆盖掉。
- 确定遍历顺序
由于我们要依靠第一行和第一列的值来得出其他位置的值,所以我们从左上角向右下角遍历。即从dp[1][1]开始遍历求值,直到dp[n][W]。
- 打印dp数组
我们可以在运行过程中打印出最后的dp数组,看其是否符合我们对dp数组的定义。
代码如下
1 | const zeroOneBag = (w: number[], v: number[], totalWeight: number) => { |
用一维数组优化空间复杂度
几乎所有的背包问题都有一个通用的降低空间复杂度的优化手段,就是把dp数组降维。为什么可以对dp数组降维呢?我们观察状态转移方程:
1 | if (j < w[i]) { |
不难发现dp[i][j]只和它的上一行,也就是dp[i - 1][某个值]有关。所以我们完全没有必要把整个二维数组保存下来,而是可以只用一个一维数组,每次在这个数组上原地刷新即可。
那么我们重新用动规五部曲来分析一下。
- 确定dp数组及其含义。
dp[j]表示用0-i的元素,装背包容量为j的背包,所能装的最大价值。有的同学看到这里可能会疑惑了,你这根本没有i啊,怎么能是用0-i的元素呢?确实,在dp数组的定义上我们只有dp[j],没有i,
但是理解上还是建议大家理解为0-i的元素,装背包容量为j的背包,所能装的最大价值。虽然我们没有在数组中定义i,但是在循环中是有i的,也就是说这种方式只能降低空间复杂度,无法降低时间复杂度。
所以当第一次循环时dp[j]就相当于二维数组中的dp[0][j]。后续的都是一次类推。
- 确定动态转移方程
如果能理解上面说的,看似dp[j]中没有出现i,但他实际表示的是装背包容量为j的背包,所能装的最大价值,只不过是在循环中动态变化的。那么这里的动态转移方程就很好理解了。
1 | if (j < w[i]) { |
我们来仔细看一下这个式子,有的同学可能感觉很奇怪,你这第一个条件语句中的内容不是啥也没干吗?把dp[j]赋值给dp[j],相当于没有任何操作啊。
从结果来看,确实没有任何变化,但是拿破轮为了方便同学们理解,才这样显式地写了出来。
因为这两个dp[j]的含义是不一样的。dp[j] = dp[j],等号左边的dp[j]表示新的当前行的dp[j],等号右边的dp[j]表示的是上一行的dp[j]。也就是说,这一句实际上相当于二维数组中的
1 | if (j < w[i]) { |
只不过由于我们现在只有一维数组了,在原地更新,所以左边的dp[j]就相当于dp[i][j],而右边的就相当于dp[i - 1][j],所以如果同学们直接去看题解可能会很懵,因为它会直接写成下面这样:
1 | if (j >= w[i]) { |
看起来就像没有处理j < w[i]的情况一样,实际上真实的过程应该就像我们上面写的那样才对。
在j >= w[i]的情况下,语义仍然相同,等号左边表示新的当前行的dp[i][j],等号右边表示的都是上一行的dp[i - 1][j]。
- 初始化dp数组。
根据我们的状态转移方程,现在dp[j]只依赖于它左边的值了,所以我们对dp[0]初始化为0,表示背包容量为0时,总价值一定是0.至于其他值初始化为什么,关键就是第一行遍历的时候会用到。首先价值肯定不能是负数。
然后我们看动态转移方程中,我们的dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i])求得是两者中的最大值,那我们只需要初始化为最小的非负数就一定不会造成干扰,所以其他位置我们必须初始化为0。就不能像二维数组一样其他位置随便初始化了。
- 确定遍历顺序
这是最容易错的地方!!!
在二维数组中,我们先遍历物品,再遍历背包,或者先遍历背包,再遍历物品都可以。但是在一维数组中,我们必须先遍历物品,因为我们数组只有一行,不能一列一列的遍历。而且在遍历的时候,我们必须要从右往左,也就是从后向前遍历。
这是为什么呢?回顾我们刚才谈到的,在一维数组的情况下,其实dp[j]在等号左边就表示当前行的值,在等号右边就表示上一行的值。我们要用上一行的值来更新当前行的值。
而dp[j]依赖于它左边的值,如果我们从左向右遍历,先把左边的值更新为当前行的值,再求后续的dp[j],那么dp[j]就会用到已经更新过的值,而我们想用的是上一行的值,所以就会出现错误。
直观上表现就是我们把某个物品取了多次,不符合0-1背包只取一次的要求。
- 打印dp数组,在最后可以打印dp数组进行检查。
1 | const zeroOneBagWithOneDimensionalArray = (w: number[], v: number[], totalWeight: number) => { |
有的同学可能会注意到,我们在遍历背包容量的时候,用的是倒序遍历,而且条件是let j = totalWeight; j >= w[i]; j--,只遍历到了w[i],并没有再往小的遍历,直到0,这是为什么呢?
因为当j < w[i]后,我们就会进行之前说的那个dp[j] = dp[j]的状态,实际上没有任何操作,不用遍历。
leetcode的0-1背包类问题
在leetcode上,并没有纯粹的0-1背包问题,而是有基于它的变式。核心思想是一致的。我们通过二维数组的方式理解了原理后,后续的题目拿破轮都将使用一维数组的方式进行代码的书写。
leetcode416-分割等和子集问题
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
有的同学乍一看这个题目感觉有点懵,这和0-1背包有什么关系呢?根本没有背包啊!其实这道题目只需要稍加转化,就可以变成一个0-1背包的变种问题。
题目让我们把一个数组分割成两个和相等的子集,其实不就是从数组中选出若干个元素,判断能否凑成数组总和的一半吗?
我们可以把这个问题理解为,有nums这么多物品,重量是nums[i],有一个背包,容量是sum / 2,其中sum是数组所有元素的总和。问题就是让我们判断能否恰好把背包装满。由于每个物品只能取一次,所以是0-1背包问题。
我们还是按照动规五部曲来进行分析:
- 确定dp数组及其含义。
dp[j]表示选取0-i的物品,能否恰好凑满容量为j的背包,是一个布尔值。
- 确定动态转移方程
对于第i个元素,有两种可能,一种是不选第i个元素就能凑成j,一种是选了第i个(前提是j >= nums[i]),然后凑成了j。这两种情况中任意一个能凑成,那么对于第i个元素来说就能凑成。对于不选第i个就能凑成的情况来说,就是说dp[j]在上一行就是true,对于选了第i个元素能凑成的情况来说,也就是说本来能凑成j - nums[i],dp[j - nums[i]]是true,那么加上当前的nums[i],dp[j]就肯定是true了。
所以应该用逻辑或来连接这两种情况。
1 | if (j >= nums[i]) { |
- 确定初始化值
dp[0]表示凑成0,那就什么都不取就可以,所以应该是true,而对于其他值,由于我们的动态转移方程是用的||连接的,所以肯定不能初始化为true,否则后续所有的都是true。所以我们初始化为false。
- 确定遍历顺序
由于是一维数组,所以还是需要倒序遍历。
- 打印dp数组
1 | function canPartition(nums: number[]): boolean { |
除了我们上述说的核心算法外,还根据题目加入了具体的剪枝代码。大家自行理解即可,应该比较好懂。
leetcode1049-最后一块石头的重量
有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:
如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。
这个题目乍一看好像又和背包没什么关系,但是通过我们的小小经验,不难看出,稍加转化即可。要想让最后返回的石头总量尽可能的小,只需要将整个石头分成尽可能重量相近的两堆,最后剩余石头的重量就是两堆石头重量差值的绝对值。
我们还是按照动规五部曲来进行分析。
- dp数组的含义
dp[j]表示在0-i中任选,每个最多选一次,放入容量为j的背包中,不超过容量j的前提下能放入的最大重量。
- 确定动态转移方程
还是老规矩,对于第i个,有两种选择,要当前石头(前提是背包容量要大于当前石头重量)和不要当前石头,这两种情况下的最大值,就是应该更新的值。
1 | if (j < stones[i]) { |
- 初始化dp
dp[0]表示在背包容量为0的情况下的最大重量,当然是0.而其他的值,由于后需要用Math.max,所以为了不影响后续,所以也初始化为0.
- 遍历顺序
一维数组还是先遍历物品,再遍历背包,遍历背包时从后向前,逆向遍历。
- 打印dp数组
1 | function lastStoneWeightII(stones: number[]): number { |
leetcode494-目标和
给你一个非负整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
这个题目乍一看好像和背包又没什么关系,但是经过前面的套路,我们已经深谙其道。稍微转换一下,本题就是要将数组分成两堆,一堆加+,一堆加-两堆相加后要求值为target,问我们构造两堆的方式数量。
我们设正数的和为pos,负数的和为neg。根据题目信息,我们可以得到以下信息:
1 | pos + neg = target |
其中sum是整个数组的元素和。由上面两个式子,相加可得2pos = target + sum,即pos = (target + sum) / 2
那么问题就变成了从nums中选出若干个数,凑出pos,问可能的情况有哪些。
还是用动规五部曲来分析:
- 确定dp数组含义
dp[j]表示从0-i任取,填满容量为j的背包可能的方法数
- 确定动态转移方程
对于第i个物品,还是有取和不取两种情况。当然要想取还得j >= nums[i]。如果不取,当前值不用更新,直接用上一行的。如果要取,就要dp[j - nums[i]]
应该把这两种情况都算进去,所以要相加。
1 | if (j < nums[i]) { |
- 初始化dp数组
dp[0]表示凑出容量j可能的方法数,那就是一种,就是什么都不取。
所以dp[0] = 1。那其他的值,由于我们需要在动态转移方程中相加,所以为了不干扰后续,应该初始化为0.
- 遍历顺序
先物品,再背包容量,背包容量还是倒序遍历。
- 打印dp数组
1 | function findTargetSumWays(nums: number[], target: number): number { |
leetcode474-一和零
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
这个题目乍一看又和背包没关系,但是转换一下还是可以看出来的。不过这道题目和之前的确实不太一样,之前的背包只有重量这一个维度的属性,本题的背包可以看做有两个维度,一个是0的数量,另一个是1的数量。
那就是问我们从0-i任选,在不超过背包容量限制的前提下,所能容纳的最大物品数目。
我们还是按照动规五部曲来进行分析:
- 确定dp数组的含义
由于背包中有两个维度,所以我们需要定义一个二维数组。dp[j][k]表示在0的数量限制为i,1的数量限制为j的情况下,从数组中任选,所能容纳的最多物品数量。
- 确定动态转移方程
对于第i个物品,还是有两个选择,不选择第i个物品和选择第i个物品。在容量不够的情况下,只能不选第i个物品,不用更新,在容量够的情况下,应该要选和不选中的最大值。
1 | if (i >= zero(nums[i]) && j >= one(nums[i])) { |
这里的zero()和one()使我们定义的辅助函数,用于获取物品中0和1的个数
- 初始化dp数组
dp[0][0]表示在0的容量为0,1的容量为0的条件下所能容纳物品的最大数量,自然是0.
对于其他dp,由于我们在动态转移过程中用了Math.max(),所以为了不干扰后续判断,也初始化为0.
- 遍历顺序。
还是先遍历物品,再遍历背包容量,只不过背包容量有两个维度,所以需要一个二重循环,并且也要倒序遍历。可能有的同学看到这疑惑了,不是说一维数组才需要倒序遍历吗?这里是二维的为什么也需要呢?其实不是一维需要,二维不需要,而是如果我们降低了本来的维度,就都需要后续遍历。这样才能保证不会用到新值。
而这个题目本来应该是一个三维数组dp[i][j][k],只不过我们把第一个维度,即物品维度i给压缩了。所以还是要后续遍历。
- 打印dp数组
1 | /** |
总结
本文先分析了0-1背包的理论基础,分别用二维数组的方式和一维数组的方式给出了实现代码。在做动态规划类问题的时候,应该牢记动规五部曲来进行分析。
- 确定dp数组及其含义
- 确定状态转移方程
- 确定dp数组的初始化值
- 确定遍历顺序
- 打印dp数组检查
这五步是环环相扣的,后面的步骤取决于前面的结果,所以千万不可颠倒次序。
比较容易出错的是遍历顺序,只要发生了降维,就必须后后序遍历,这样才能确保用到上一行的旧值,而不是新值。
接着我们以leetcode中关于0-1背包的几个变种题目进行分析,进一步巩固了对0-1背包的掌握。
我们来梳理一下:
- 纯粹的0-1背包问题是:每个物品最多选一次,给定容量,问能装的最大价值
- leetcode416分割等和子集问题是:每个物品最多选一次,给定容量,问能否恰好装满
- leetcode1049最后一块石头的重量问题是:每个物品最多选一次,给定容量,在不超过的前提下,求能装的最大重量
- leetcode494目标和的问题是:每个物品最多选一次,给定容量,问恰好装满背包的所有可能选择数量
- leetcode474一和零问题是:每个物品最多选一次,给定容量,但是容量有两个维度,问最多能装的物品数量
只要每个物品最多只能选择一次,那么他就是0-1背包问题。上述几个题目分别考察了0-1背包的不同角度。核心思路是相似的,就是最后求的结果不同而已。
好了,这篇文章就到这里啦,如果对您有所帮助,欢迎点赞,收藏,分享👍👍👍。您的认可是我更新的最大动力。由于笔者水平有限,难免有疏漏不足之处,欢迎各位大佬评论区指正。
往期推荐✨✨✨
我是前端拿破轮,关注我,一起学习前端知识,我们下期见!


