01背包问题及滚动数组优化空间

2,011 阅读5分钟

前言

小M公司年会运气爆棚中奖,老板说给你一个容量w的蛇皮袋,去奖池里愉快的捞吧。奖池里的商品都独一份。袋子能装多少,就算中多少。不同奖品体积价格都不同,且每种奖品拿一次喔。小M心想这机会千载难逢,我咋薅才能让老板薅出血。

这个场景中如果归纳到算法中来说,都是很典型的背包问题。都可简化为:

有N个物品,这些物品有各自的体积W和价值V。现有已定容量的背包,求如何让背包里装入的物品价值总和最大? image.png 在解决此问题前,我们简单回顾一下dp的原理以及解决思路

动态规划的原理

动态规划(Dynamic Programming),简称DP。若某一问题有很多重叠子问题,往往使用动态规划是最有效的。动态规划中每一个状态都是由上一个状态推导出来的。对应贪心是没有状态推导,而是从局部直接选最优的,分治法在子问题上会被重复计算多次。而DP有记忆性,计算过程中会被记录下来,在新问题里需要用到的子问题可以直接提取,避免重复计算、节约了时间。

最优性原理是动态规划的基础,最优性原理是指“多阶段决策过程的最优决策序列具有这样的性质:不论初始状态和初始决策如何,对于前面决策所造成的某一状态而言,其后各阶段的决策序列必须构成最优策略”。

动态规划解题思路

  1. 确定dp数组和其下标的含义

  2. 推导出状态转移方程

  3. 初始化dp数组

  4. 确定遍历的顺序

对于以上的前两个步骤:

将问题抽象化、确定个模型 -> 寻找约束条件 -> 判断是否满足最优性 -> 找大问题与小问题的关系

1. 确定dp数组和其下标的含义

dp[i][j]表示从下标为[0 - i]的物品里任意取,放进剩余容量为j的背包,价值总和最大值。

2. 推导状态转移方程

dp[i][j]={dp[i1][j] if wi>jmax{dp[i1][j],  dp[i1][jwi]+vi)} if wi<=j dp[i][j] = \begin{cases} dp[i-1][j] & \text { if } w_{i}>j \\ \max \left\{dp[i-1][j], \ \ dp\left[i-1][j-w_{i}] + v_{i}\right)\right\} & \text { if } w_{i}<=j \end{cases}

可区分为两种情况来推出递推公式:

  1. 当第i件物品太重放不进去,那么此时背包未放第i件物品,此时dp[i][j] = dp[i - 1][j]

  2. 当第i件物品重量小于剩余的背包大小,可以放入时。又区分为两种情况。

    1. 放:dp[i][j] = dp[i - 1][j - w[j]] + v[i]
    2. 不放:dp[i][j] = dp[i - 1][j]

此时对于放和不放,我们要选二者的较大值。下面放一张log图帮助理解。注意log的最后一行。 image.png

没看懂?来,再默念一遍dp[i][j]表示从下标为[0 - i]的物品里任意取,放进剩余容量为j的背包,价值总和最大值。

3. 初始化dp数组

背包剩余容量为0的时候,肯定是不可再放入东西。所以dp[i][0] = 0

vector<vector<int>> dp(wSize + 1, vector<int>(bagW + 1, 0));

接下来,根据上一步的出来的动态转移方程:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + v[i])可以知i是由i - 1推出来的,所以i为0的时候就一定要初始化。另外,倒叙遍历,保证物品0只被放入一次。

for (int j = bagW; j >= weight[0]; j--) {
    dp[0][j] = dp[0][j - weight[0]] + value[0];
}

4. 确定遍历的顺序

image.png 依据本题的递归公式中可看出dp[i][j]是由dp[i-1][j]dp[i - 1][j - weight[i]]推导出来的。根据填表画图可明显的看出来,dp[i-1][j]dp[i - 1][j - weight[i]]都在dp[i][j]的左上⻆方向。所以先遍历背包或者是先遍历物品都是可以的。

综上,上代码

vector<int> weight = {1, 3, 5};

vector<int> value = {15, 20, 30};

int bagW = 5;

size_t wSize = weight.size();

vector<vector<int>> dp(wSize + 1, vector<int>(bagW + 1, 0));

for (int j = bagW; j >= weight[0]; j--) {
    dp[0][j] = dp[0][j - weight[0]] + value[0];
}

for(int i = 1; i < wSize; i++) {
    for(int j = 0; j <= bagW; j++) {
        if (j < weight[i]) {
            dp[i][j] = dp[i - 1][j];
        } else {
            dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
        }
    }
}

printf("max value = %d", dp[wSize - 1][bagW]);

优化成一维数组

滚动数组:让数组滚起来,很直白吧。这是一种用时间去换空间的思路。滚动数组基本上都是在DP和递推中使用,大部分都是通过数组中的值结合其他的数更新数组中的某一位,之后在数组中交换数值的位置,再更新下一位。

上一步我们得出本题如果用二位数组,递推公式为:

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

同时我们也知道动态规划中每一个状态都是由上一个状态推导出来的。所以dp[i - 1]可以直接放入到dp[i]。所以可改为:

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

上面的状态转移方程,记录下了每次操作后的最大价值,但是最后需要的结果只有最后一行的最大容量的价值。根据上一行得到下行数据后,上一行的数据就是没有用处的了。所以优化空间,用一个一维数组dp[j]。

1. 确定dp数组和其下标的含义

在01背包问题中,dp[j]表示:容量为j的背包,所背的物品价值最大为dp[j]。

2. 推导状态转移方程

dp[j]可由dp[j - weight[i]]推出,表示容量为j - weight[i]的背包所背的最大价值。

dp[j - weight[i]] + value[i]:容量为j的背包,放入物品i了,减去weight[i],加上对应i的价值

依据题意取dp[j]dp[j - weight[i]] + value[i]间较大值。

所以递归公式为:

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

3. 初始化dp数组

因为背包容量为0,所背的物品的最大价值肯定也是0.

4. 确定遍历的顺序

举个例子:物品0的weight[0] = 1,value[0] = 15

正序遍历:

dp[1] = dp[1 - weight[0]] + value[0] => dp[1] = 15

dp[2] = dp[2 - weight[0]] + value[0] => dp[2] = 30

此时dp[2]就已经是30了,物品0已经被放入了两次,所以不能正序遍历。

倒序遍历:

dp[2] = dp[2 - weight[0]] + value[0] => dp[2] = 15

dp[1] = dp[1 - weight[0]] + value[0] => dp[1] = 15

所以需要从后从后向前遍历,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。另外,如果遍历背包容量放在外层,那么每个dp[j]只会放入一个物品,即背包里只放入了一个物品。所以需要背包容量的遍历要放在内层。

size_t wSize = weight.size();

for (int i = 0; i < wSize; i++) {
    //遍历背包容量
    for(int j = bagW; j >= weight[i]; j--) {

    }
}

综上,上完整代码

vector<int> weight = {1, 3, 5};

vector<int> value = {15, 20, 30};

int bagW = 5;

size_t wSize = weight.size();

vector<int>(bagW + 1, 0);

for(int i = 0; i < wSize; i++) {
    //遍历背包容量
    for(int j = bagW; j >= weight[i]; j--) {
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

printf("max value = %d", dp[bagW]);

其他一些算法笔记

鸣谢

在此非常感谢代码随想录的思路。在笔者之前刷leetcode dp和二叉树类题目时,常看到这位大佬的精妙解题。在回顾背包问题时,本文也深受大佬的启发。

其他算法详细题解