动态规划之01背包问题

目录

问题描述

问题分析

朴素想法

动态规划——二维数组

重要优化——一维滚动数组

写在最后


问题描述

2. 01背包问题 - AcWing题库

有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。

第 i 件物品的体积是 vi,价值是 wi。

求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。

输入格式

第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。

接下来有 N 行,每行两个整数 vi,wi,用空格隔开,分别表示第 i 件物品的体积和价值。

输出格式

输出一个整数,表示最大价值。

数据范围

0<N,V≤1000
0<vi,wi≤1000

输入样例

4 5
1 2
2 4
3 4
4 5

输出样例:

8

问题分析

01背包是一种最基本的背包问题,特点是背包容量有限,每个物品有且仅有一个。对于每个物品,都有放与不放两种选择,因此也叫01背包问题。

朴素想法

最朴素的想法是依次枚举每件物品选与不选的情况,时间复杂度为O(n^2)显然是低效而不可取的。但这也给了我们拆分重叠子问题的思路:从1到n依次考虑物品的状态,通过构造最优子结构来求得最后的解。

怎么表示每件物品的状态呢?

动态规划——二维数组

我们最先想到的是用一个二维数组dp表示状态,其中dp[i][j]表示前i个物品放进容量为j的背包能够获得的最大价值。

依次考虑物品状态,怎么列出状态转移方程求解呢?

考虑对第i件物品的选择策略,有以下两种:

  • 不放第i件物品,那么显然有dp[i][j]=dp[i-1][j]
  • 放第i件物品,问题转化为dp[i][j]=dp[i-1][j-v[i]]+w[i]

从而得到状态转移方程

dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i]]+w[i])

进而可以写出以下代码:

#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
int v[N], w[N];
int dp[N][N];
int main(){
    ios::sync_with_stdio(false); //关闭流同步,提高cin速度
    cin >> n >> m;
    for(int i = 1; i <= n; i ++)
        cin >> v[i] >> w[i];
    for(int i = 1; i <= n; i ++)
        for(int j = 0; j <= m; j ++){
            dp[i][j] = dp[i - 1][j];
            if(j >= v[i]) //判断能不能装下
                dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }
        cout << dp[n][m] << '\n';
        return 0;
}

重要优化——一维滚动数组

可以知道,时间复杂度和空间复杂度都是O(nV),其中时间复杂度无法优化,空间复杂度却可以进一步优化。

经过思考可以发现,原先方法计算dp[i][j]的时候总是要用到dp[i-1]这个维度中,在dp[i - 1][v]左侧的数组元素,而当计算dp[i + 1]这一维度的时候,只需要用到dp[i]这一维度,dp[i - 1]这一维度则完全用不到了。因此,不妨直接开一个一维数组dp[j](即省去第一维),dp[j]代表状态为:

N件物品,背包容量为j下的最优解

这样,状态转移方程改变为:

dp[j]=max(dp[j],dp[j-v[i]]+w[i])

注意点:在枚举j的时候,需要逆序从右往左枚举。这是01背包最难以理解的地方。

在这里需要引入滚动数组的概念:

  • 在求解斐波那契数组的时候,我们通常觉得要开一个大数组(比如说A[n]),A[i]表示的就是第i个元素的值;
  • 但是当我们仅仅需要第k个元素的值的时候,事实上我们不关心其他元素的值是多少;
  • 那么,来来回回我们真正需要存储的值只有三个:当前元素的值,上一个元素的值,上上一个元素的值。有了这三个,我们就可以循序渐进,求出后面元素的值,当求出下一个元素的值之后,上上一个元素其实已经对我毫无用处了,不如舍弃它,拿它的下一个元素覆盖它;
  • 此时,“当前元素”变成了“上一个元素”,“上一个元素”变成了“上上一个元素”,可以进一步求值。我们向前推进了问题,数组却还是原来的数组;
  • 那么,我就可以开一个大小为3的数组(或者直接定义三个变量,本质上是一样的),不断地用后面的运算结果覆盖前面的运算结果,最后求得答案。
  • def fibonacci(n):
        a = 1
        b = 1
        ans = 0
        if n == 1 or n == 2:
            return a
        else:
            while n >= 3:
                ans = a + b
                a = b
                b = ans
                n -= 1
    
            return ans
    
    
    n = int(input())
    print(fibonacci(n))
    //懒得写C++了,附上翻出的python代码

这就是滚动数组,不断地用后面的值覆盖前面的值,车轮滚滚向前,重复利用着每一寸轮胎。

这正是动态规划中常用的技巧。再来看原来的问题:为什么j需要从后往前枚举?

我们对比一下两个状态转移方程:

dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i]]+w[i])

 dp[j]=max(dp[j],dp[j-v[i]]+w[i])

当前的问题是:背包容量为j时选N件物品的最优解

  • 在二维解法中,我们当前dp[i]这一维度的状态是由dp[i - 1]这一维度得来的;
  • 在一维解法中,dp[j]的状态是由自身与左边的元素得来的;
  • 二维状态与一维状态的状态转移方程,本质是一样的,这就是说对于一维状态下dp[j]左边的元素,它们在二维解法中实际的第一维度是i-1
  • 如果我们按正序遍历数组,在处理当前问题的时候,我们用到的来自dp[j]左边的元素实际的第一维度是i,因为已经被更新过了;
  • 这就体现了逆序遍历的合理性:从右向左遍历,使得右侧被更新过的、需要留给下一个维度的数据不会再被使用;而左侧需要使用的数据都还没被更新,依然来自上一个维度。

一切如上。接着来看代码:

#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int dp[N], v[N], w[N];
int main(){
    ios::sync_with_stdio(false); //关闭流同步,提高cin速度
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i ++){
        cin >> v[i] >> w[i];
    }
    for(int i = 1; i <= n; i ++)
        for(int j = m; j >= v[i]; j --){
            dp[j] = max(dp[j], dp[j-v[i]] + w[i]);
        }
        cout << dp[m] << '\n';
        return 0;
}

这里有最终优化版本

#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int dp[N], v, w;
int main(){
    ios::sync_with_stdio(false);
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i ++){
        cin >> v >> w;
        for(int j = m; j >= v; j --)
            dp[j] = max(dp[j], dp[j-v] + w);
    }
    cout << dp[m] << '\n';
    return 0;
}

写在最后

动态规划如何避免重复计算d的问题在01背包问题中非常明显。暴力枚举方法忽略了这一点:在考虑第i件物品选与不选而产生的最大值的时候,它其实是由前面i-1件物品的最大值决定的。

因此,01背包的每件物品其实都可以看做一个阶段,这阶段里的状态有dp[i][0]\rightarrow dp[i][V],它们均由上一个阶段得到。对于能够划分阶段的问题来说,可以尝试把阶段作为状态的一维,这样能使我们更加方便地得到满足无后效性的状态。如果当前设计的状态不能满足无后效性,可以尝试升维操作。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AryCra_07

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值