背包问题详解

一、简介

基础问题\((0, 1背包)\)

 有 \(N\) 件物品和一个容量是 \(V\) 的背包。每件物品只能使用一次。第 \(i\) 件物品的体积是 \(v_i\),价值是 \(w_i\)。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。输出最大价值。

问题变形

  • 完全背包:每种物品都有无限多个可用;
  • 多重背包:每种物品有 \(s[i]\) 个可用;
  • 分组背包:物品有 \(N\) 组,每一组里面只能选一个物品。

二、方法:dfs(记忆化) / dp

【0,1背包】解题思路

1、从 \(dfs\)\(dp\)

  • 考虑当前物品为 \(i\),有两种情况,选或则不选;
  • 不选物品 \(i\),则到物品 \(i-1\),且剩余\(背包体积不变\)
  • 选物品 \(i\),则到物品 \(i-1\),且\(背包体积-v[i]\)

 那么 \(dfs\) 应该有两个参数,即当前物品和当前背包体积,用 \(dfs(i, j)\) 表示,有 \(0-i\) 件物品,背包容积为 \(j\) 的情况下,输出的最大价值。根据上述推算:
\(dfs(i,j) = max\{dfs(i - 1, j), dfs(i - 1, j - v[i]) + w[i]\}\)
dfs代码

const int N = 1010; // 物品数量和背包体积最大值

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, int target) { // target表示背包体积
        int n = v.size();
        int cache[N][N]; memset(cache, -1, sizeof(cache));
        function<int(int, int)> dfs = [&](int i, int j) -> int {
            if (i == 0) return 0;
            if (cache[i][j] != -1) return cache[i][j];
            int res = dfs(i - 1, j);
            if (j >= v[i - 1]) res = max(res, dfs(i - 1, j - v[i - 1]) + w[i - 1]);
            cache[i][j] = res;
            return res;
        };
        return dfs(n, target);
    }
};

dfs->dp代码
 将 \(cache\) 数组改为 \(dp\) 数组,对 \(dfs\) 参数逆序计算,即 \(dfs\) 时, \(i\)\(j\):从大到小;现在改为从小到大。

const int N = 1010; // 物品数量和背包体积最大值

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, int target) {
        int n = v.size();
        int dp[N][N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = 0; j <= target; j ++ ) {
                dp[i][j] = dp[i - 1][j];
                if (v[i - 1] <= j) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i - 1]] + w[i - 1]);
            }
        }
        return dp[n][target];
    }
};

2、直接 \(dp\)
\(dp\) 需要确定两部分,状态表示 + 状态计算

  • 状态表示:即 \(dp\) 数组,在本题为 \(dp[i][j]\)
    • 集合:\(dp[i][j]\) 实际表示的是一个集合,该集合包含满足以下条件的所有情况。条件:从前 \(i\) 个物品中选,且总体积小于等于 \(j\)
    • 属性:即 \(dp[i][j]\) 所表示的含义,主要有 最大值\((max)\)、最小值\((min)\) 和 数量\((cnt)\)。针对本题其属性为最大值,即 \(dp[i][j]\) 存储上述集合中的最大价值。
  • 状态计算:
    • 实际就是集合划分的过程,即将集合划分为多个之前已经求过的较小集合,且要保证划分后的集合:不重、不漏(所有集合加起来等于原集合);
    • 本题中将集合 \(dp[i][j]\) 划分为两个集合,一个集合不含物品 \(i\),一个集合含物品 \(i\)。那么当前集合的值即为两个子集合值的最大值。\(dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i])\)

dp代码同上

  • 关于 \(dp\) 优化主要是针对 \(状态转移方程\)\(dp 代码\) 进行等价替换。

优化后代码

/*
    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i])
    1、对于每次循环的下一组i,只会用到i-1来更新当前值。于是可以在这次更新的时候,
       将原来的值更新掉,反正以后也用不到,所以对于i的更新,只需用一个数组。也就
       是滚动数组,每次根据上一层的值更新当前层的值。(优化掉一维)
    2、对于每次j的更新,只需用到之前i-1时的j或者j-v[i],不会用到后面的值,同时为
       了防止当前用于更新的之前i-1层的d[j] 或 d[j - v[i]]之前被更新过,采用从后
       往前的方式遍历j。(优化j的循环次数)
*/
const int N = 1010;

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, int target) {
        int n = v.size();
        int dp[N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = target; j >= v[i - 1]; j -- ) {
                dp[j] = max(dp[j], dp[j - v[i - 1]] + w[i - 1]);
            }
        }
        return dp[target];
    }
};

问: 为什么优化之前的 \(dp\) 写法中的 \(j\) 不能从 \(v[i - 1]\) 开始?
答: 优化之前为二维数组,就算是 \(j\) 的取值为 \([0, v[i - 1])\)时当前不会用到,也要根据 \(i - 1\) 层的值更新当前的值,如果不更新则默认为 \(0\),答案不对。而优化之后为一维数组,对于没有更新的值时,其本身表示的就是上一层同位置的值,所以不需要重复更新。

【完全背包】解题思路

1、利用直接 \(dp\) 解题模板

  • 状态表示:\(dp[i][j]\)
    • 集合:表示从前 \(i\) 个物品中选,且体积不超过 \(j\) 的所有选法;
    • 属性:所有选法的总价值中的最大价值。
  • 状态计算:
    • 集合划分: 由于每个物品有 \(∞\) 多个,所以对于当前物品 \(i\),可以选多个,因此可以进行如下划分。选取 \(0\) 个物品 \(i\),选取 \(1\) 个物品 \(i\),...、选取 \(k\) 个物品 \(i\) (\(k * v[i] <= j\))。那么 \(dp[i][j] = max\{dp[i - 1][j],dp[i - 1][j - 1 * v[i]] + w[i],...,dp[i - k][j - k * v[i]] + k * w[i]\}\)

dp代码

const int N = 1010;

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, int target) { // target表示背包体积
        int n = v.size();
        int dp[N][N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = 0; j <= target; j ++ ) {
                for (int k = 0; k * v[i - 1] <= j; k ++ ) {
                    dp[i][j] = max(dp[i][j], dp[i - 1][j - k * v[i - 1]] + k * w[i - 1]);
                }
            }
        }
        return dp[n][target];
    }
};

dp优化1
 可以看出朴素的 \(dp\) 解法的时间复杂度较高,现在看看能够如何进行优化。

/*
    v = v[i], w = w[i];
    观察下列两个式子区别:
    dp[i][j]=max(dp[i-1][j], dp[i-1][j-v]+w, dp[i-1][j-2*v]+2*w, ... , dp[i-1][j-k*v]+k*w);
    dp[i][j-v] = max(        dp[i-1][j-v],   dp[i-1][j-2*v]+w, ...,    dp[i-1][j-k*v]+(k-1)*w);
    => max(dp[i-1][j-v]+w, dp[i-1][j-2*v]+2*w, ... , dp[i-1][j-k*v]+k*w) = dp[i][j-v] + w;
    => dp[i][j] = max(dp[i-1][j], dp[i][j-v]+w).
    因此可以优化掉一重k循环。
*/    
    
const int N = 1010;

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, int target) { // target表示背包体积
        int n = v.size();
        int dp[N][N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = 0; j <= target; j ++ ) {
                dp[i][j] = dp[i - 1][j];
                if (j >= v[i - 1]) dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i - 1]] + w[i - 1]);
            }
        }
        return dp[n][target];
    }
};

dp优化2
 参考 \(0,1背包\) 优化方法,同样可以应用在完全背包问题上。

/*
    dp[i][j] = max(dp[i - 1][j], dp[i][j - v] + w)
    1、对于当前i,其之和i - 1 及本层计算过的i有关,因此可以去掉一维,将其变为滚动数组;
    2、为了使得当前使用的dp[j - v]等价于之前的dp[i][j - v],即更新为第i层的数据(本层),
       应该使得j从v - target遍历,这样当前用到的dp[j - v]就是本层已经更新过的了。
    
    与0,1背包区别:
    (1)0,1背包中的j从target - v遍历,是因为其使用的是上一层的值dp[i - 1][j - v];
    (2)完全背包中的j从v - target遍历,是因为其使用的是本层的值dp[i][j - v]。
    当从v - target遍历j时,当前使用的dp[j - v]实际已经在当前层被更新过了,也就相当于之前的dp[i][j - v]。
*/
const int N = 1010;

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, int target) { // target表示背包体积
        int n = v.size();
        int dp[N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = v[i - 1]; j <= target; j ++ ) {
                dp[j] = max(dp[j], dp[j - v[i - 1]] + w[i - 1]);
            }
        }
        return dp[target];
    }
};

【多重背包】解题思路

1、利用直接 \(dp\) 解题模板

  • 状态表示:\(dp[i][j]\)
    • 集合:表示从前 \(i\) 个物品中选,且体积不超过 \(j\) 的所有选法;
    • 属性:所有选法的总价值中的最大价值。
  • 状态计算:
    • 集合划分: 由于每个物品有 \(s[i]\) 个,所以对于当前物品 \(i\),可以选 \(0 - s[i]\) 个,因此可以进行如下划分。选取 \(0\) 个物品 \(i\),选取 \(1\) 个物品 \(i\),...、选取 \(k\) 个物品 \(i\) (\(k * v[i] <= j\) && \(k <= s[i]\))。那么 \(dp[i][j] = max\{dp[i - 1][j],dp[i - 1][j - 1 * v[i]] + w[i],...,dp[i - k][j - k * v[i]] + k * w[i]\}\)

dp代码

const int N = 110;

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, vector<int>& s, int target) {
        int n = v.size();
        int dp[N][N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = 0; j <= target; j ++ ) {
                for (int k = 0; k <= s[i - 1] && k * v[i - 1] <= j; k ++ ) {
                    dp[i][j] = max(dp[i][j], dp[i - 1][j - k * v[i - 1]] + k * w[i - 1]);
                }
            }
        }
        return dp[n][target];
    }
};

dp优化
先思考能不能像\(完全背包优化1\)\(k\) 循环优化掉:
v = v[i], w = w[i];
dp[i][j]=max(dp[i-1][j], dp[i-1][j-v]+w, dp[i-1][j-2*v]+2*w, ... , dp[i-1][j-k*v]+k*w);
dp[i][j-v]=max(dp[i-1][j-v], dp[i-1][j-2*v]+w, ..., dp[i-1][j-k*v]+(k-1)*w, dp[i-1][j-(k+1)*v]+k*w);
 当上面 \(dp[i][j]\)\(k\) 的确定是因为 \(k + 1 > s[i]\) 时,那么说明 \((k + 1) * v[i] <= j\),所以对于 \(dp[i][j-v]\) 仍最多可以取 \(k\) 个,此时就多出了一项 \(dp[i-1][j-(k+1)*v]+k*w\) ,就不能用\(完全背包优化1\)中的优化方式用 \(dp[i][j-v]+w\) 进行代替了。
 上述优化方法行不通,现介绍新的优化技巧:二进制优化

前提知识

  • 对于 \(s[i] = 255\)的所有选法(选 \(0-255\) 个),其可以由 \(1, 2, 4, 8, 16, 32, 64, 128\) 每个最多选一次组合而来。
    \(1 => 选0-1个;\)
    \(1, 2 => 0-1 + 2-3 => 选0-3个;\)
    \(1, 2, 4 => 0-3 + 4-7 => 选0-7个;\)
     ...
    \(1, 2, 4, ..., 128 => 选0-255个;\)
  • 若对于一个普通的 \(s[i] = 252\),其可以由 \(1, 2, 4, 8, 16, 32, 64, 125\) 每个最多选一次组合而来。
  • 一般的,\(s[i] = x\)\(x >\) \(2^k\)\(^+\)\(^1\)\(-1\)\(k\) 取最大值,其可以由 \(1, 2, 4, ..., 2^k, x - 2^k\)\(^+\)\(^1\)\(+ 1\) 每个最多选一次组合而来。

二进制优化

 根据描述,对于物品 \(i\),我们可以将其个数拆分为 \(k + 2\) 个新的物品,每个新物品的\(体积 v = x * v[i], 价值 w = x * w[i]\),其中 \(x\) 为新的物品包含的原物品的个数。对 \(n\) 个物品都进行上述拆分,就得到了新的 \(m\) 个物品,每个物品最多只能选一次,转换为了 \(0,1背包问题\)
dp二进制优化代码

const int N = 2020;

class Solution {
public:
    int Knapsack(vector<int>& v, vector<int>& w, vector<int>& s, int target) {
        int n = v.size();
        vector<int> newV, newW;
        for (int i = 0; i < n; i ++ ) { // 拆分
            int a = v[i], b = w[i], c = s[i];
            int k = 1;
            while (k <= c) {
                newV.push_back(a * k);
                newW.push_back(b * k);
                c -= k;
                k *= 2;
            }
            if (c > 0) {
                newV.push_back(a * c);
                newW.push_back(b * c);
            }
        }
        n = newV.size(); // 0,1背包问题
        int dp[N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = target; j >= newV[i - 1]; j -- ) {
                dp[j] = max(dp[j], dp[j - newV[i - 1]] + newW[i - 1]);
            }
        }
        return dp[target];
    }
};

【分组背包】解题思路

1、利用直接 \(dp\) 解题模板

  • 状态表示:\(dp[i][j]\)
    • 集合:表示从前 \(i\) 物品中选,且体积不超过 \(j\) 的所有选法;
    • 属性:所有选法的总价值中的最大价值。
  • 状态计算:
    • 集合划分: 由于每物品有 \(s[i]\) 个,所以对于当前 \(i\),可以选中的第 \(0 - k\) 个物品,因此可以进行如下划分。对于组 \(i\),不选择物品,选取第 \(0\) 个物品,选取第 \(1\) 个物品,...、选取第 \(k\) 个物品\((v[i][k] <= j)\)。那么 \(dp[i][j] = max\{dp[i - 1][j],dp[i - 1][j - v[i][0]] + w[i][0],...,dp[i - 1][j - v[i][k]] + w[i][k]\}\)

dp代码

const int N = 110;

class Solution {
public:
    int Knapsack(vector<vector<int>>& v, vector<vector<int>>& w, int target) { // target表示背包体积
        int n = v.size();
        int dp[N][N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) {
            for (int j = 0; j <= target; j ++ ) {
                dp[i][j] = dp[i - 1][j];
                for (int k = 0; k < v[i - 1].size(); k ++ )
                    if (v[i - 1][k] <= j)
                        dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i - 1][k]] + w[i - 1][k]);
            }
        }
        return dp[n][target];
    }
};

dp优化代码
 分组背包的朴素代码和0,1背包的朴素代码相似,都是只与前一层 \(i - 1\) 有关,因此可以进行同样的优化。

const int N = 110;

class Solution {
public:
    int Knapsack(vector<vector<int>>& v, vector<vector<int>>& w, int target) { // target表示背包体积
        int n = v.size();
        int dp[N]; memset(dp, 0, sizeof(dp));
        for (int i = 1; i <= n; i ++ ) 
            for (int j = target; j >= 0; j -- ) 
                for (int k = 0; k < v[i - 1].size(); k ++ ) 
                    if (v[i - 1][k] <= j) 
                        dp[j] = max(dp[j], dp[j - v[i - 1][k]] + w[i - 1][k]);
                        
        return dp[target];
    }
};

参考

动态规划入门:从记忆化搜索到递推【基础算法精讲 17】

yxc-算法基础课

posted @ 2023-04-06 22:39  lixycc  阅读(107)  评论(0)    收藏  举报