动态规划之背包问题

本文详细介绍了背包问题中的0-1背包和完全背包问题,包括各自的题解思路和状态转移方程。0-1背包问题中物品不可分割,每个物品有重量和价值,目标是求解在不超过背包容量的情况下,如何选取物品以获得最大价值。完全背包问题则允许物品无限数量,重点在于如何利用动态规划找到恰好装满背包的方法数。文章通过具体例子和伪码解释了解决这些问题的动态规划策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

背包问题

0-1 背包问题

给定一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i],现在让你用这个背包装物品,最多能装的价值是多少?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BQFOXKce-1641524249049)(%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98.assets/1.png)]

举个简单的例子,输入如下:

N = 3, W = 4
wt = [2, 1, 3]
val = [4, 2, 3]

算法返回 6,选择前两件物品装进背包,总重量 3 小于 W,可以获得最大价值 6。

题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这就是 0-1 背包这个名词的来历。

解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们 动态规划详解 中的套路,直接走流程就行了。

0-1背包问题模板

dp 数组的定义:

dp[i][w] 表示:对于前 i 个物品,当前背包的容量为 w 时,这种情况下可以装下的最大价值是 dp[i][w]

如果你没有把这第 i 个物品装入背包,那么很显然,最大价值 dp[i][w] 应该等于 dp[i-1][w],继承之前的结果。

如果你把这第 i 个物品装入了背包,那么 dp[i][w] 应该等于 dp[i-1][w - wt[i-1]] + val[i-1]

首先,由于 i 是从 1 开始的,所以 valwt 的索引是 i-1 时表示第 i 个物品的价值和重量。

dp[i-1][w - wt[i-1]] 也很好理解:你如果装了第 i 个物品,就要寻求剩余重量 w - wt[i-1] 限制下的最大价值,加上第 i 个物品的价值 val[i-1]

综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:

注意为什么i不从0开始

//01背包
for (int i = 1; i <= N; i++) {
    for (int w = 1; w <= W; w++) {//w可以从nums[i]开始
        if (w - wt[i-1] < 0) {
            // 这种情况下只能选择不装入背包
            dp[i][w] = dp[i - 1][w];
        } else {
            // 装入或者不装入背包,择优
            dp[i][w] = max(dp[i -1][w - wt[i-1]] + val[i-1], 
                           dp[i - 1][w]);
        }
    }
}    
return dp[N][W]

完全背包问题

我们可以把这个问题转化为背包问题的描述形式

有一个背包,最大容量为 amount,有一系列物品 coins,每个物品的重量为 coins[i]每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?

与0-1的区别在于每个物品数量无限

题解思路

第一步要明确两点,「状态」和「选择」

状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」嘛,背包问题的套路都是这样。

明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:

for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 计算(选择1,选择2...)

第二步要明确 dp 数组的定义

首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维 dp 数组。

dp[i][j] 的定义如下:

若只使用前 i 个物品(可以重复使用),当背包容量为 j 时,有 dp[i][j] 种方法可以装满背包。

换句话说,翻译回我们题目的意思就是:

若只使用 coins 中的前 i 个硬币的面值,若想凑出金额 j,有 dp[i][j] 种凑法

经过以上的定义,可以得到:

base case 为 dp[0][..] = 0, dp[..][0] = 1。因为如果不使用任何硬币面值,就无法凑出任何金额;如果凑出的目标金额为 0,那么“无为而治”就是唯一的一种凑法。

我们最终想得到的答案就是 dp[N][amount],其中 Ncoins 数组的大小。

大致的伪码思路如下:

int dp[N+1][amount+1]
dp[0][..] = 0
dp[..][0] = 1

for i in [1..N]:
    for j in [1..amount]:
        把物品 i 装进背包,
        不把物品 i 装进背包
return dp[N][amount]

第三步,根据「选择」,思考状态转移的逻辑

注意,我们这个问题的特殊点在于物品的数量是无限的,所以这里和之前写的 0-1 背包问题 文章有所不同。

如果你不把这第 i 个物品装入背包,也就是说你不使用 coins[i] 这个面值的硬币,那么凑出面额 j 的方法数 dp[i][j] 应该等于 dp[i-1][j],继承之前的结果。

如果你把这第 i 个物品装入了背包,也就是说你使用 coins[i] 这个面值的硬币,那么 dp[i][j] 应该等于 dp[i][j-coins[i-1]]

首先由于 i 是从 1 开始的,所以 coins 的索引是 i-1 时表示第 i 个硬币的面值。

dp[i][j-coins[i-1]] 也不难理解,如果你决定使用这个面值的硬币,那么就应该关注如何凑出金额 j - coins[i-1]

比如说,你想用面值为 2 的硬币凑出金额 5,那么如果你知道了凑出金额 3 的方法,再加上一枚面额为 2 的硬币,不就可以凑出 5 了嘛。

综上就是两种选择,而我们想求的 dp[i][j] 是「共有多少种凑法」,所以 dp[i][j] 的值应该是以上两种选择的结果之和

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= amount; j++) {
        if (j - coins[i-1] >= 0)
            dp[i][j] = dp[i - 1][j] + dp[i][j-coins[i-1]];
        else 
            dp[i][w] = dp[i-1][w];
return dp[N][W]

416.分割等和子集

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D72AMYAx-1641524249050)(%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98.assets/image-20211215200302615.png)]

从集合中选出一部分值,达到和的1/2

public boolean canPartition(int[] nums) {
    int n = nums.length;
    int sum = 0;        
    for (int i : nums) {
        sum += i;
    }
    if(sum % 2 != 0) return false;
    int W = sum / 2;
    int[][] dp = new int[n+1][W+1];
    for (int i = 0; i < n; i++){
        for (int w = 1; w <= W; w++){
            if (w-nums[i]<0){
                dp[i+1][w] = dp[i][w];
            } else{
                dp[i+1][w] = Math.max(dp[i][w-nums[i]] + nums[i], dp[i][w]);
            }                
        }
    }
    
    for (int[] row : dp){
        if (row[W] == W){           
            return true;
        }            
    }
    return false;        
}

优化他的空间复杂度,因为只用到了 i 和 i -1。注意是++还是–

public boolean canPartition(int[] nums) {
    int n = nums.length;
    int sum = 0;        
    for (int i : nums) {
    	sum += i;
    }
    if(sum % 2 != 0) return false;
    int W = sum / 2;
    int[] dp = new int[W+1];
    for (int i = 0; i < nums.length; i++){
        for (int w = W; w >= nums[i]; w--){
        	dp[w] = Math.max(dp[w-nums[i]] + nums[i],dp[w]);
        }
    }
    return dp[W] == W;        
}

518.零钱兑换

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lQNofXeF-1641524249051)(%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98.assets/image-20211215203658477.png)]

完全背包问题

public int change(int amount, int[] coins) {
    int n = coins.length;
    int[][] dp = new int[n+1][amount+1];
    for (int[] row : dp)
        row[0] = 1;
    for (int i = 1; i <= n; i++){
        for (int w = 1; w <= amount; w++){
            if (w-coins[i-1] >= 0)
                dp[i][w] = dp[i-1][w] + dp[i][w-coins[i-1]];
            else 
                dp[i][w] = dp[i-1][w];
        }
    }
    return dp[n][amount];
}

由于只需要i和i-1,所以可以优化空间复杂度。注意是++还是–

public int change(int amount, int[] coins) {
    int n = coins.length;
    int[] dp = new int[amount+1];
    dp[0] = 1;
    for (int i = 1; i <= n; i++){
        for (int w = coins[i-1]; w <= amount; w++){
            dp[w] = dp[w] + dp[w-coins[i-1]];
        }
    }
    return dp[amount];
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值