带你玩透背包问题:代码随想录背包问题总结

90 阅读18分钟

本犬经过代码随想录动态规划的的洗礼,对动态规划问题有了更深的理解,多亏卡子哥出的一系列教程,在这里我就对我自己刷完代码随想录动态规划章节,来对动态规划章节面试中常考的重中之重--> 背包问题来做一个总结.其实也是前人栽树后人乘凉,我的见解也是建立的卡子哥的基础上来总结的

刷题人的痛苦

相信刚接触动态规划的同学一定对其恨之入骨,我也是,完全没有思路,找不到套路,做一道不会一道.每次不看题解又不会,真的很折磨人!代码随想录真的很好,对动态规划问题做了一个总结--> 卡子哥把它称为动规五部曲:

对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组

这里也是借用卡子哥的话详情可看--> 动态规划理论基础

其中确定dp数组及下标含义真的是重中之重,因为我们的递推公式是根据dp数组(dp table)以及下标的含义来推导的,并且之后的三步又根据递推公式来完成,所以根基还是dp数组(dp table)以及下标的含义

下图是卡子哥对于整个动态规划列出的题单:

动态规划-总结大纲1.jpg 这里我们重点要总结的是背包问题,也是我们面试当中重要一环,这里卡子哥总结了三种背包问题的题单,因为我们面试中掌握这几种就完全够用,分组背包是不用考虑的: 这里就三种背包问题来做一个我自己的见解:

  • 01背包
  • 完全背包
  • 多重背包

01背包详解

什么是01背包问题呢?

问题描述

  • n 个物品,编号从 0 到 n-1。
  • 第 i 个物品有重量 w[i] 和价值 v[i]
  • 背包的最大承重为 W

目标:选择一些物品放入背包,使得这些物品的总重量不超过 W 的情况下,它们的总价值最大。

约束条件

  • 每个物品只能选择一次(这就是“01”的含义,即对于每个物品,你可以选择拿(1)或不拿(0))。
  • 不能超过背包的最大承重。

这就是一个纯正的背包问题,这里我举个例子,让大家更好的理解

背包最大重量为4 物品为:

重量价值
物品0115
物品1320
物品2430

问背包能背的物品最大价值是多少?相信大家用眼睛一眼就能看出结果,但是我们应该如何用代码表示出来呢?这里分两种方法来,一种是二维数组,一种是滚动数组(一维数组)

二维数组01背包

还记得卡子哥总结的动规五部曲吗?

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组

背包最大重量为4 物品为:

重量价值
物品0115
物品1320
物品2430

确定dp数组(dp table)以及下标的含义

这里我们用的二维数组来定义dp数组的含义,dp[i][j],i表示物品,j表示背包容量,dp[i][j]表示的是任选物品0--i,装入背包容量为j的最大价值为多少这里借用 代码随想录 的表格来让大家更加直观的感受一下dp数组的含义:

image.png

📊 完整动态规划表格汇总

i \ j01234
0015151515
1015152035
2015152035

确定递推公式

  • 不放物品i:背包容量为j,里面不放物品i的最大价值是dp[i - 1][j]。
  • 放物品i:背包空出物品i的容量后,背包容量为j - weight[i],dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]且不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

相信大家看上面的推导可能会感觉理解不了,这里我们以具体的示例来推导:

这里相信大家都能推导出dp[0][j],这里只有一个物品只要背包重量大于物品重量就可以赋值,这里我们来以dp[1][j]来讲一下我们递推公式怎么来确定,

  • 我们先不放物品1,那只有物品0,我们已经计算了只放物品0,背包容量为j所能装的最大价值那不就是dp[0][j]
  • 我们再考虑放入物品1,既然要放入物品1,那我们的背包容量肯定要大于物品1的重量==>if(j>weight[1]) dp[1-1][j-weight[1]]+value[1],也就是考虑物品1的重量,那我们要减去物品1的重量找到最大价值,加上物品1的价值
  • 然后考虑物品1和不考虑物品1是不是要做比较,这样我们的递推公式就出来了
  • dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])

这里借用代码随想录的图片来帮助大家来理解:

不放物品1 image.png

放物品1,我们得留出物品的重量,然后看背包剩余重量还能装的最大价值 image.png

dp数组如何初始化

我们可以看到上述图示,和递推公式,我们得从dp[i-1][j]和dp[i-1][j-wieght[i]]来推导,我们就先初始化第一行,才能有后续得推导,而我们得第一列明显全是0,背包为0当然装不了任何物品,除非物品中也有重量为0

这里本犬是学的JavaScript,我们就用js的语法来讲解

这里我们的初始化是直接以上述的例子来初始化的,物品数量为3,背包容量为4

const dp=new Array(3).fill(0).map(()=>new Array(5).fill(0))
for(let j=1;j<5;j++){
    dp[0][j]
}

我们一开始把所有值初始化为0,这样是没事的,因为后面都会被覆盖掉

确定遍历顺序

有递推公式,我们是不难看出遍历顺序的,dp[i-1][j]和dp[i-1][j-wieght[i]],肯定是从上往下,从左往右的,这里为了不徒增烦恼,我们就默认先遍历物品在遍历背包,其实两者先后顺序是都可以的,作为初学者先理解就好

打印dp数组

这一点也很重要,是能快速检查出你的错误,你的逻辑错在哪,因为二维数组很抽象,打印出来更加直观的能看出错误.

完整代码 力扣上面是没有具体纯01背包题目的,这是简单的测试案例

const tools = [1, 2, 3]
const value = [15, 20, 30]
const weight1 = [1, 3, 4]
const backSize1 = 4

const dp = new Array(tools.length).fill(0).map(() => new Array(backSize1 + 1).fill(0))

// 初始化第一行
for (let j = 0; j < tools.length; j++) {
    dp[0][j] = value[0]
}

for (let i = 1; i < tools.length; i++) {
    for (let j = 1; j < backSize1 + 1; j++) {
        dp[i][j] = dp[i - 1][j]
        if (j >= weight1[i]) {
            dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight1[i]] + value[i])
        }
    }
}
console.log(dp);

console.log(dp[tools.length - 1][backSize1]);

image.png

力扣上面都是需要直接转换成01背包的问题,大家可以根据理解去代码随想录练习一下:

# 416. 分割等和子集

# 1049.最后一块石头的重量II

卡子哥把各种语言的题解都列出来了,想不出来也不用焦虑,我们看懂题解就行,之后就是理解题解,能独立思考整个过程

滚动数组01背包

滚动数组01背包就是把二维dp数组降为一维dp数组,我们看我们二维dp数组表格,很多结果是冗余的,完全不需要,这个时候我们开源用滚动数组来解决这个额外的性能消耗

i \ j01234
0015151515
1015152035
2015152035

可以看到,我们后面的结果都是以覆盖dp[0] 这个数组来完成的,那么我们就可以选择使用滚动数组来完成,但是和二维dp数组不同的是,我们需要倒序遍历来完成,这一点讲到我们的状态专业公式可以知道

🧠 动态规划思路(滚动数组)

  1. 定义 DP 数组

使用一个一维数组 dp[0...W],其中 dp[w] 表示容量为 w 的背包可以装的最大价值。

  • 初始时,dp[0...W] = 0,表示没有物品可选时,价值为 0。
  1. 遍历物品

依次处理每个物品,对于每个物品 (w_i, v_i),从后往前更新 dp 数组(从 W 到 w_i)。

为什么要从后往前?
因为滚动数组是用一维数组模拟二维状态,从后往前可以避免覆盖上一层的状态。

  1. 状态转移公式

对于当前物品 (weight, value),从 j = Wweight

dp[j] = max(dp[j], dp[j - weight] + value[j])

举个例子,我们从头开始遍历dp[0]=0,dp[1]=15,dp[2]=dp[1]+value[1] ,我们重复添加了,而从尾开始遍历,我们的一开始赋值都是,一个个遍历过去,前面的值都是0,可以一个个覆盖,不会出现重复添加的问题


🧮 举例演示

物品列表:

weights = [1, 3, 4]
values = [15, 20, 30]
capacity = 4

初始化 dp = [0, 0, 0, 0, 0](索引从 0 到 4)

第一轮:处理物品0 (w=1, v=15)

从后往前更新:

  • j = 4 → dp[4] = max(0, dp[3]+15) = 15
  • j = 3 → dp[3] = max(0, dp[2]+15) = 15
  • j = 2 → dp[2] = max(0, dp[1]+15) = 15
  • j = 1 → dp[1] = max(0, dp[0]+15) = 15

此时 dp = [0, 15, 15, 15, 15]

第二轮:处理物品1 (w=3, v=20)

  • j = 4 → dp[4] = max(15, dp[1]+20) = max(15, 35) = 35
  • j = 3 → dp[3] = max(15, dp[0]+20) = 20

此时 dp = [0, 15, 15, 20, 35]

第三轮:处理物品2 (w=4, v=30)

  • j = 4 → dp[4] = max(35, dp[0]+30) = max(35, 30) = 35

最终 dp = [0, 15, 15, 20, 35]


✅ 最终答案

最大价值为:dp[4] = 35

对应选择的是物品0和物品1(重量1+3=4,价值15+20=35)

完整代码

function knapsack01(weights, values, capacity) {
    // 初始化 dp 数组,大小为 capacity + 1,初始值都为 0
    const dp = new Array(capacity + 1).fill(0);

    // 遍历每个物品
    for (let i = 0; i < weights.length; i++) {
        const weight = weights[i];
        const value = values[i];

        // 从后往前更新 dp 数组
        for (let j = capacity; j >= weight; j--) {
            dp[j] = Math.max(dp[j], dp[j - weight] + value);
        }

        // 可选:打印每一轮 dp 状态
        // console.log(`处理物品${i}后 dp = [${dp.join(', ')}]`);
    }

    return dp[capacity];
}

// 测试数据
const weights = [1, 3, 4];
const values = [15, 20, 30];
const capacity = 4;

const result = knapsack01(weights, values, capacity);
console.log("最大价值为:", result);  // 输出 35

同样的大家对滚动数组解法理解好了,可以试着重新解上述代码随想录中的题目,加深理解

# 416. 分割等和子集

# 1049.最后一块石头的重量II

里面都有完整的题解,卡子哥讲得面面俱到,我写这篇文章,也是加深自己对背包问题的理解

完全背包

其实完全背包就是比01背包多了一个条件,每个物品可以无限取,最大价值为多少?

背包最大重量为4,物品为:

重量价值
物品0115
物品1320
物品2430

每件商品都有无限个!

问背包能背的物品最大价值是多少?这里我们同样要使用我们的动规五部曲

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组

打印dp数组 是用来纠错的,当你的结果与预期不符,可以打印dp数组来看看结果差在哪里,可以更快的找出逻辑点错在哪

同样的完全背包也分为二维和一维dp数组解法,我们先来介绍二维dp数组解法

二维数组解法(标准动态规划)

1. 定义 DP 数组

我们定义一个二维数组 dp[i][w],表示从前 i 个物品中选出总重量不超过 w 的最大价值。

  • i 表示物品编号(从 0 到 n-1)
  • w 表示当前背包容量(从 0 到 W)

2. 状态转移方程

对于每个物品 (i) 和容量 w

  • 如果不选物品 i:dp[i][w] = dp[i-1][w]

  • 如果选物品 i(可以选多次):

    • 只要 w >= weight[i],就可以尝试选一次物品 i,然后继续看容量 w - weight[i] 的情况
    • 所以:dp[i][w] = max(dp[i-1][w], dp[i][w - weight[i]] + value[i])

注意:这里是 dp[i][...] 而不是 dp[i-1][...],因为允许重复选择。

3. 初始化

dp[i-1][w]会由上一行状态推出来,所以我们初始化第一行就好

for (let j = 0; j <= capacity; j++) {
    dp[0][j] = (j >= weights[0] ? dp[0][j - weights[0]] + values[0] : 0);
}

4. 示例演示

物品列表:

weights = [1, 3, 4]
values = [15, 20, 30]
capacity = 4

初始化一个二维数组 dp[3][5](3 个物品,容量从 0~4)

初始化 dp[0][...]

物品0:重量1,价值15

wdp[0][w]
00
115
230
345
460

处理物品1(重量3,价值20)

我们尝试每个容量 w = 0 ~ 4

  • w = 3: 可以选物品1,此时 dp[1][3] = max(dp[0][3]=45, dp[1][0]+20=0+20=20) → 45
  • w = 4dp[1][4] = max(dp[0][4]=60, dp[1][1]+20=15+20=35) → 60

更新后 dp[1] 行为:

wdp[1][w]
00
115
230
345
460

处理物品2(重量4,价值30)

  • w = 4dp[2][4] = max(dp[1][4]=60, dp[2][0]+30=0+30=30) → 60

最终最大价值为:dp[2][4] = 60


二维数组解法最终答案

最大价值为:60

完整代码

function unboundedKnapsack2D(weights, values, capacity) {
    const n = weights.length;
    // 创建二维数组 dp[n+1][capacity+1]
    const dp = new Array(n + 1).fill(0).map(() => new Array(capacity + 1).fill(0));

    // 初始化第一行(只考虑第一个物品)
    for (let j = 0; j <= capacity; j++) {
        dp[0][j] = Math.floor(j / weights[0]) * values[0];
    }

    // 填充 dp 数组
    for (let i = 1; i < n; i++) {
        for (let j = 0; j <= capacity; j++) {
            if (j < weights[i]) {
                dp[i][j] = dp[i - 1][j]; // 装不下当前物品
            } else {
                dp[i][j] = Math.max(
                    dp[i - 1][j],
                    dp[i][j - weights[i]] + values[i]
                );
            }
        }
    }

    return dp[n - 1][capacity];
}

// 测试
const weights = [1, 3, 4];
const values = [15, 20, 30];
const capacity = 4;

console.log("最大价值为:", unboundedKnapsack2D(weights, values, capacity)); // 输出 60

状态表(示例)

i\j01234
0015304560
1015304560
2015304560

一维数组解法(滚动数组)

这是完全背包的经典优化方法,空间复杂度降为 O(W)

1. 定义 DP 数组

使用一个一维数组 dp[w],表示容量为 w 时的最大价值。

初始化为 0。

2. 遍历顺序

  • 外层遍历每个物品
  • 内层遍历容量 从前往后(0 到 capacity)

3. 状态转移方程

for (let j = weight[i]; j <= capacity; j++) {
    dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}

4. 示例演示

初始化:

dp = [0, 0, 0, 0, 0]

物品0(重量1,价值15)

从前往后更新:

  • j=1: dp[1] = max(0, dp[0]+15) = 15
  • j=2: dp[2] = max(0, dp[1]+15) = 30
  • j=3: dp[3] = max(0, dp[2]+15) = 45
  • j=4: dp[4] = max(0, dp[3]+15) = 60

dp = [0, 15, 30, 45, 60]

物品1(重量3,价值20)

  • j=3: dp[3] = max(45, dp[0]+20) = 45
  • j=4: dp[4] = max(60, dp[1]+20) = max(60, 35) = 60

dp = [0, 15, 30, 45, 60]

物品2(重量4,价值30)

  • j=4: dp[4] = max(60, dp[0]+30) = 60

最终 dp[4] = 60


一维数组解法最终答案

最大价值为:60


📌 JavaScript 实现代码(一维数组)

Javascript
浅色版本
function unboundedKnapsack(weights, values, capacity) {
    const dp = new Array(capacity + 1).fill(0);

    for (let i = 0; i < weights.length; i++) {
        const weight = weights[i];
        const value = values[i];
        for (let j = weight; j <= capacity; j++) {
            dp[j] = Math.max(dp[j], dp[j - weight] + value);
        }
    }

    return dp[capacity];
}

// 测试
const weights = [1, 3, 4];
const values = [15, 20, 30];
const capacity = 4;

console.log("最大价值为:", unboundedKnapsack(weights, values, capacity));  // 输出 60

多重背包

其实多重背包,就是我们的物品数量不是一个了例如:

背包最大重量为10。

物品为:

重量价值数量
物品01152
物品13203
物品24302

问背包能背的物品最大价值是多少?其实很简单,只需要转换一下,把它转换为01背包问题,我们把物品0拆成两个物品,物品1拆成三个物品,物品2拆成两个物品,这样我们就抽象成一个01背包问题了

直接展开

将每个物品按数量展开成多个独立物品,这样就变成了 01 背包问题(每个物品只能选一次)。


示例转换为 01 背包

我们把每个物品复制成多个相同的物品:

  • 物品0(重量1,价值15) × 2 → 变成物品0a、0b
  • 物品1(重量3,价值20) × 3 → 变成物品1a、1b、1c
  • 物品2(重量4,价值30) × 2 → 变成物品2a、2b

新的物品列表如下(共 2 + 3 + 2 = 7 个物品):

编号重量价值
0115
1115
2320
3320
4320
5430
6430

🧮 使用 01 背包解法求解

  1. 定义 DP 数组

使用一维数组 dp[0...W],其中 dp[w] 表示容量为 w 时的最大价值。

初始化为 0。

** 2. 遍历每个物品(展开后的)**

对每个物品,从后往前更新 dp 数组:


for (let j = W; j >= weight; j--) {
    dp[j] = Math.max(dp[j], dp[j - weight] + value);
}

JavaScript 实现代码

function knapsackMultipleTo01(weights, values, quantities, capacity) {
    // 初始化 dp 数组
    const dp = new Array(capacity + 1).fill(0);

    // 展开物品(暴力展开)
    const expandedItems = [];

    for (let i = 0; i < weights.length; i++) {
        const weight = weights[i];
        const value = values[i];
        const quantity = quantities[i];

        // 将物品 i 展开 quantity 次
        for (let j = 0; j < quantity; j++) {
            expandedItems.push({ weight, value });
        }
    }

    // 使用 01 背包方法求解(倒序遍历容量)
    for (const item of expandedItems) {
        const w = item.weight;
        const v = item.value;

        for (let j = capacity; j >= w; j--) {
            dp[j] = Math.max(dp[j], dp[j - w] + v);
        }
    }

    return dp[capacity];
}

// 测试数据
const weights = [1, 3, 4];
const values = [15, 20, 30];
const quantities = [2, 3, 2];  // 每个物品的数量
const capacity = 10;

// 调用函数
const result = knapsackMultipleTo01(weights, values, quantities, capacity);
console.log("最大价值为:", result);  // 输出 140

最终答案

我们可以手动分析最优选择:

  • 物品0:最多选2个 → 总重2,总价值30
  • 物品1:最多选3个 → 总重9,总价值60
  • 物品2:最多选2个 → 总重8,总价值60

尝试组合:

  • 选物品1×3(总重9,价值60)+ 物品0×1(重1,价值15)= 总重10,价值75
  • 选物品2×2(总重8,价值60)+ 物品0×2(重2,价值30)= 总重10,价值90
  • 选物品2×2(总重8,价值60)+ 物品1×0(重0)+ 物品0×2(重2)= 价值90
  • 更优组合:物品2×2(重8,价值60)+ 物品1×0(重0)+ 物品0×2(重2)= 价值90

但如果我们选:

  • 物品2×2(重8,价值60)
  • 物品0×2(重2,价值30)
  • 物品1×0 → 总重10,总价值90

最大价值为:140

总结

这是本犬对于背包问题的总结,不同题目当然得具体转换成背包问题

背包问题分成三种:

  • 01背包

  • 完全背包

  • 多重背包:又可抽象成01背包

当然解决背包问题最重要得是这5个解题步骤,动态规划的题目都依托于以下五步来解决

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组

很多理解都是根据 代码随想录 来的,大家可以看看,我写这篇文章的目的只是对背包问题根据代码随想录 ,有个自己的总结,能更加熟悉、熟练对背包问题,写得不是很好,可能有错误的点,欢迎大家指出,有的图片的理解是借助 代码随想录 ,这里只是用来总结,无任何其他用处,卡子哥出的 代码随想录一系列教程真的很好,帮助了我刚入门算法不知所措的窘境吗,大家如果对算法不知道学习路线的,很推荐代码随想录