0-1背包和完全背包问题总结

0-1背包

二维实现

有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

由于每个物体只有两种可能的状态(取与不取),对应二进制中的 0 和 1,这类问题便被称为「0-1 背包问题」

借用《算法图解》书中示例:

你是一个小偷,携带一个能装4磅的背包,有如下三样东西,选择那些能偷的价值最多

ad6b93c2b4685638cc239ad6ac63c40

  1. 确定dp数组以及下标含义

最普遍的解法是创建一个二维dp数组

int dp[weightSize(物品数量)][bagWeight(背包容量) + 1]; // weight数组的大小 就是物品个数

我们的行表示不同的物品,列表示不同的背包容量。

dp[i][j]表示从第1行到当前行[0-i]所对应的物品中任意取,放进容量为j的背包,价值总和最大是多少。

我们来看dp数组来逐行分析理解:

dp[0][0]表示给出1磅的空间,在吉他中选择,所能获得的最大收益,显然是吉他的价值;

dp[0][1] dp[0][2] dp[0][3]则表示了给出2,3,4磅空间,在吉他中选择,所能获得的最大收益,此时我们定义只能选择当前行的物品,所以无论多大容量,最大收益都是1500;

dp[1][0]表示给出1磅的空间,在吉他和音响中选择,所能获得的最大收益,1磅无法装下音响,此时最佳选择仍是偷吉他

dp[1][1] dp[1][2] dp[1][3]则表示了给出2,3,4磅空间,在吉他(1磅),音响(4磅)中选择,所能获得的最大收益,在4磅之前,我们都只能偷吉他,当有了4磅空间后,显然直接偷音响是最划算的选择。

e1a2c5ab51a24212566540e309025ab

  1. 在明白每格的定义后,我们来看看如何确定递推公式,得到数据

    对于每件物品我们有不取和取两种状态,

    • 对于不取当前行物品,此时值为当前单元格上边的一格(dp[i - 1][j]表示的是不考虑本行物品时的最大价值)

    • 对于取当前行物品,此时值为当前行对应商品的价值 + 剩余空间的价值dp[i - 1][j - weights[i]] + costs[i]表示的时如果优先放入本行对应物品后,考虑放入后剩余空间(j - weights[i])能放的最大价值,最大价值需要从上一层查找)

    我们可以得到递推公式

    dp[i][j] = fmax(dp[i - 1][j],  dp[i - 1][j - weights[i]] + costs[i]);
    
  2. 初始化

    第一列都是0:背包容量为0,所得最大收益均为0。

    第一行表示只选取0号物品最大价值:小于0号物品质量也为0,大于后为0号物品价值

    memset(dp, 0, sizeof(int) * weightSize * (bagWeight + 1));
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }
    
  3. 遍历顺序

    • 先遍历物品还是先遍历背包重量都可行

    • 每行物品的顺序位置也不影响最终的结果。

    • 如果我们有第四件可以偷的东西,我们也只需添加到最后一行就行了,因为我们每个数据的得出只依靠上一行的数据

代码如下:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

int knapsack(int weightSize, int bagWeight, int weights[], int values[]) {
    int dp[weightSize][bagWeight + 1];

    memset(dp, 0, sizeof(int) * weightSize * (bagWeight + 1));
    for (int j = weights[0]; j <= bagWeight; j++) {
        dp[0][j] = values[0];
    }
    // 内层循环遍历背包的不同容量,外层循环遍历可选的物品。
    //在每次迭代中,我们都尝试将当前物品放入背包,以获得背包在当前容量下的最大价值。
    for (int i = 1; i < weightSize; i++) {
        for (int j = 0; j <= bagWeight; j++) {
            if (weights[i] > j) {
                // 当前物品超过当前背包容量,无法放入,保持上一行的状态
                // 最大价值就是拿第i-1个物品的最大价值
                dp[i][j] = dp[i - 1][j];
            } else {
                // 考虑放入或不放入当前物品,取最大值
                dp[i][j] = MAX(dp[i - 1][j], dp[i - 1][j - weights[i]] + values[i]);
            }
        }
    }
    return dp[weightSize - 1][bagWeight];
}

一维滚动数组

二维代码可以进行优化,简化为一维背包。

思路是将dp[i - 1]那一层拷贝到dp[i]下来,并对数据进行覆盖

  1. 确定dp数组含义

    dp[j]表示的是容量为j的背包,所能得最大价值

  2. 递推公式

    dp[j] = fmax(dp[j], dp[j - weight[i]] + value[i]);
    

    有不取当前物品和取当前物品两种状态:

    • 不取:dp[j],拷贝上一层数据不变,相当于二维数组方法中的dp[i - 1]

    • 取:dp[j - weight[i]] + value[i],考虑当前的第i个物品,优先放入该物品后所剩空间为j - weight[i]

      找到对应dp[j - weight[i]]加上value[i]就是取当前物品的价值

    将两个数据比较后,取最大即可

  3. 初始化

    dp[0]一定是0,因为容量为0装不下任何物品

    除了下标0的位置,也初始为0,dp数组每次遍历是取价值最大的数,所以我们初始化为0

  4. 遍历顺序

    虽然是一维,但是要进行两层遍历,这是为了模拟不同物品的选择。出于此我们只能先外层遍历不同物品,

    内层遍历背包的不同容量。

    同时我们要对内层背包容量进行倒序遍历:

    • 如果正序遍历dp[j - weight[i]]的值将不是上一层的拷贝,而是本层遍历时所改变的值。

    i = 0(外层第一层遍历)

    j = 1dp[1] = max(dp[1], dp[1 - 1] + value[0]) = 1500

    j = 2dp[2] = max(dp[2], dp[2 - 1] + value[0]) = 3000 此时dp[1]的值为1500

    说明当前物品被计算了两次。

    • 如果倒序遍历

    i = 0(外层第一层遍历)

    j = 4dp[4] = max(dp[4], dp[4 - 1] + value[0]) = 1500

    j = 3dp[3] = max(dp[3], dp[3 - 1] + value[0]) = 1500

    所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

    对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖。

//倒序遍历
for(int i = 0; i < weightSize; i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

代码如下:


#define MAX(a, b) ((a) > (b) ? (a) : (b))

int knapsack(int weightSize, int bagWeight, int weights[], int values[]) {
    int dp[bagWeight + 1];
    for (int i = 0; i <= bagWeight; i++) {
        dp[i] = 0;
    }

    for (int i = 0; i < weightSize; i++) { // 遍历物品
        for (int j = bagWeight; j >= weights[i]; j--) { // 逆序遍历背包容量
            dp[j] = MAX(dp[j], dp[j - weights[i]] + values[i]); // 不取或者取第i个
        }
    }

    return dp[bagWeight];
}

完全背包

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

0-1背包滚动数组部分我们提到如果不进行倒序遍历物品会被多次计算,这正好符合完全背包的需求

//0-1背包
for(int i = 0; i < weightSize; i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}
//完全背包
for(int i = 0; i < weightSize; i++) { // 遍历物品
    for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

对于上述代码不难发现相比于0-1背包,我们此时正序遍历,从当前物品重量开始考虑背包容量,直到背包容量达到最大值为止。

  • 我们dp数组的推导依靠每次从左侧遍历dp[j]的数据,这样dp[j - weight[i]]为本行左侧的数据
  • 而从右往左倒序遍历,dp[j - weight[i]]为上一行的数据

对于完全背包问题,我们先遍历物品还是先遍历背包重量都可行

for (int j = 0; j <= bagWeight; j++) { // 遍历背包容量
    for (int i = 0; i < weightSize; i++) { // 遍历物品
        if (j - weights[i] >= 0) {
            dp[j] = MAX(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
}

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

518. 零钱兑换 II - 力扣(LeetCode)

这道题求排列数求组合数

class Solution {
    public int change(int amount, int[] coins) {
        int i, j;
        int[] dp = new int[amount + 1];
        dp[0] = 1;
        for (i = 0; i < coins.length; i++) {
            for (j = coins[i]; j <= amount; j++) {
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
}

377. 组合总和 Ⅳ - 力扣(LeetCode)

这道题求排列数

class Solution {
    public int combinationSum4(int[] nums, int target) {
        int i, j;
        int[] dp = new int[target + 1];
        dp[0] = 1;
        // 外层容量内存物品
        for (i = 0; i <= target; i++) {
            for (j = 0; j < nums.length; j++) {
                if (i >= nums[j])
                    dp[i] += dp[i - nums[j]];
            }
        }
        return dp[target];
    }
}

参考:

  1. 代码随想录
  2. 算法图解

感谢您的阅读

如有错误,烦请指正

  • 14
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值