【动态规划之完全背包问题】完全背包问题的通用解法与优化

1,102 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第21天,点击查看活动详情


⭐️前面的话⭐️

本篇文章将介绍动态规划中的背包问题——完全背包问题,前面我们已经介绍了0-1背包问题,其实完全背包问题就只改了0-1背包问题的一个条件,即物品可选择次数由一次改为无数次,仅此而已,下面我们就来开始介绍完全背包问题。

📒博客主页:未见花闻的博客主页
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
📌本文由未见花闻原创!掘金首发!
📆掘金首发时间:🌴2022年10月22日🌴
✉️坚持和努力一定能换来诗与远方!
💭推荐书籍:📚《无》
💬参考在线编程网站:🌐牛客网🌐力扣
博主的码云gitee,平常博主写的程序代码都在里面。
博主的github,平常博主写的程序代码都在里面。
🍭作者水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!


⭐️【问题导入】完全背包原题⭐️

🔐题目详情

NN 种物品和一个容量为 CC 的背包,每种物品都有无限件

ii 件物品的体积是 v[i]v[i] ,价值是 w[i]w[i]

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

其实就是在 0-1 背包问题的基础上,增加了每件物品可以选择多次的特点(在容量允许的情况下)。

示例 :

输入: N = 2, C = 5, v = [1,2], w = [1,2]
输出: 5
解释: 选一件物品 1,再选两件物品 2,可使价值最大。

💡解题思路与代码

🍭朴素解法

我们可以直接将0-1背包的状态定义直接拿来使用:

状态定义: 不妨定义f[i][j]f[i][j]表示从前ii件物品中选择,容量不超过jj的最大价值。

确定初始状态: 当只有一种物品选择时,由于数量是无限的,所以尽量地选就行,不要超过容量jj,不妨设选这一种物品的最大件数为kk,则有f[0][j]=k×w[i],其中k×v[i]<=jf[0][j]=k \times w[i],其中k\times v[i]<=j

状态转移方程推导: 当我们选择第ii件物品的时候,我们可以选择0,1,2...k0,1,2...k件,其中kk是最大能够选择的件数,即在不超过容量jj的情况下。

当我们不选择第ii件物品时,即k=0k=0f[i][j]=f[i1][j]f[i][j]=f[i-1][j]
当我们选择11ii件物品时,即k=1k=1f[i][j]=f[i1][jv[i]]+w[i]f[i][j]=f[i-1][j - v[i]] + w[i]
当我们选择22ii件物品时,即k=2k=2f[i][j]=f[i1][j2v[i]]+2w[i]f[i][j]=f[i-1][j - 2 * v[i]] + 2*w[i]
...
当我们选择kkii件物品时,即k=kk=kf[i][j]=f[i1][jkv[i]]+kw[i]f[i][j]=f[i-1][j - k * v[i]]+k*w[i]

我们所要求的是最大的价值,所以取以上所有情况的最大值即可。

综上所述,我们的状态转移方程就出来了,不妨记当前物品的价值为:

f[i][j]=max(f[i1][jkv[i]]+kw[i]),k=0,1,...f[i][j]=max(f[i-1][j-k*v[i]]+k*w[i]),k=0,1,...

实现代码:

    /**
     *
     * @param n 物品个数
     * @param c 背包的总容量
     * @param w 每种物品的价值
     * @param v 每种物品的容量
     * @return 能将物品放进背包的最大价值
     */
    public int cknapsack1(int n, int c, int[] w, int[] v) {
        //状态定义f[i][j]表示最大价值
        int[][] f = new int[n][c + 1];
        //确定初始状态
        for (int j = 1; j <= c; j++) {
            int k = j / v[0];
            f[0][j] = k * w[0];
        }
        //状态转移f[i][j]=max(f[i-1][j - k * v[i]]+k*w[i]
        for (int i = 1; i < n; i++) {
            int val = w[i];
            for (int j = 1; j <= c; j++) {
                int cur = 0;
                for (int k = 0; j >= k * v[i]; k++) {
                    int t = f[i - 1][j - k * v[i]] + k * val;
                    cur = Math.max(t, cur);
                }
                f[i][j] = cur;
            }
        }
        return f[n - 1][c];
    }

时间复杂度:O(nck)O(n*c*k)kk的值不会大于cc,因为最低物品价值为11,最多选择的件数不会超过cc
空间复杂度:O(nc)O(n*c)

🍭滚动数组优化空间

根据观察我们知道第ii行的状态仅依赖与第i1i-1行的状态,因此我们可以使用滚动数组进行优化。

实现代码:

    /**
     *
     * @param n 物品个数
     * @param c 背包的总容量
     * @param w 每种物品的价值
     * @param v 每种物品的容量
     * @return 能将物品放进背包的最大价值
     */
    public int cknapsack2(int n, int c, int[] w, int[] v) {
        //状态定义f[i][j]表示最大价值 滚动数组优化
        int[][] f = new int[2][c + 1];
        //确定初始状态
        for (int j = 1; j <= c; j++) {
            int k = j / v[0];
            f[0][j] = k * w[0];
        }
        //状态转移f[i][j]=max(f[i-1][j - k * v[i]]+k*w[i]
        for (int i = 1; i < n; i++) {
            int val = w[i];
            int ci = i & 1;
            int pi = (i - 1) & 1;
            for (int j = 1; j <= c; j++) {
                int cur = 0;
                for (int k = 0; j >= k * v[i]; k++) {
                    int t = f[pi][j - k * v[i]] + k * val;
                    cur = Math.max(t, cur);
                }
                f[ci][j] = cur;
            }
        }
        return f[(n - 1) & 1][c];
    }

对于时空复杂度,只是优化了空间而已,所以时间复杂度不发生改变,空间复杂度优化到O(c)O(c)。 时间复杂度:O(nck)O(n*c*k)kk的值不会大于cc,因为最低物品价值为11,最多选择的件数不会超过cc。 空间复杂度:O(c)O(c)

🍭一维数组优化空间

首先我们对状态转移方程进行一个简单的推导:

f[i][j]=max(f[i1][j],f[i1][jv[i]+w[i],f[i1][j2v[i]]+2w[i],...,f[i1][jkv[i]]+kw[i])f[i][j]=max(f[i-1][j],f[i-1][j-v[i]+w[i],f[i-1][j-2*v[i]]+2*w[i],...,f[i-1][j-k*v[i]]+k*w[i])
f[i][jv[i]]=max(f[i1][jv[i]],f[i1][j2v[i]]+w[i],f[i1][j3v[i]]+2w[i]...,f[i1][jkv[i]]+(k1)w[i])f[i][j-v[i]]=max(f[i-1][j-v[i]],f[i-1][j-2*v[i]]+w[i],f[i-1][j-3*v[i]]+2*w[i]...,f[i-1][j-k*v[i]]+(k-1)*w[i])

其中kv[i]<=jk*v[i]<=j

通过观察上面两个状态的式子,我们发现后面一部分式子是差了一个w[i]w[i]如下图:

2

所以我们可以进一步优化状态转移方程,即:

f[i][j]=max(f[i1][j],f[i][jv[i]]+w[i]f[i][j]=max(f[i-1][j],f[i][j-v[i]]+w[i]

对于新推导出来的状态转移方程,它的状态转移仅仅只依赖与上一行同列状态与同一行元素前面的元素,所以我们可以将原来的二维数组优化为一维,由于它只依赖左边与正上方的元素,我们可以采取从小到大遍历背包容量状态来求背包中所放物品最大值。

1

只保留【背包容量】维度,状态转移方程为:

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

实现代码:

    /**
     *
     * @param n 物品个数
     * @param c 背包的总容量
     * @param w 每种物品的价值
     * @param v 每种物品的容量
     * @return 能将物品放进背包的最大价值
     */
    public int cknapsack3(int n, int c, int[] w, int[] v) {
        //状态定义f[i][j]表示最大价值 一维数组优化
        int[] f = new int[c + 1];
        //确定初始状态f[0]=0
        //状态转移
        for (int i = 0; i < n; i++) {
            for (int j = 0; j <= c; j++) {
                //不选择物品
                int nopt = f[j];
                //选择物品
                int opt = j >= v[i] ? f[j - v[i]] + w[i] : nopt;

                f[j] = Math.max(opt, nopt);
            }
        }
        return f[c];
    }

时间复杂度:O(nc)O(n*c) 空间复杂度:O(c+1)O(c+1)

🌱总结

以上我们介绍了【完全背包问题】的朴素解法和优化方案,其中一维优化是最复杂的,因为使用了一些数学上的推导,比较抽象,不是特别容易理解,我建议自己尝试推导一遍,这样能够更快的理解并且更深刻。 相比于0-1背包问题的优化,形式上,我们只需要将 01 背包问题的「一维空间优化」解法中的「容量维度」遍历方向从「从大到小 改为 从小到大」就可以解决完全背包问题。 但本质是因为两者进行状态转移时依赖了不同的格子: 0 -1 背包依赖的是「上一行正上方的格子」和「上一行左边的格子」。 完全背包依赖的是「上一行正上方的格子」和「本行左边的格子」。