背包九讲详解

#背包九讲详解

##0-1背包问题
有n个重量和价值分别为 wi,viw_i, v_i 的物品。从这些物品中挑选出总重量不超过 WW 的物品, 求所有挑选方案中价值总和的最大值。

样例:

n=4(w,v)={(2,3),(1,2),(3,4),(2,2)}W=5n = 4\\ (w, v) = \{(2,3), (1,2), (3,4), (2,2)\}\\ W = 5

n个物品,每种物品只有两种选择,放进背包,或者不放进背包。n个物品对应的,最大的所有可能的总数为 2n2^n 种不同的放法。
最朴素的,我们可以枚举所有可能的放法,找到最大值。

Rec(n,W)Rec(n, W) 表示剩余容量为WW 的背包,还有1n1~nnn 个物品可以选择, 所能取得的最大的背包价值。

还剩ii 个物品可以选, 背包容量剩余jj 的时候,可以分为两种情况:

  1. ii 个物品不选,只考虑前i1i-1 个物品。则 Rec(i,j)=Rec(i1,j)Rec(i, j) = Rec(i-1, j) ,此时背包容量j不变
  2. ii 个物品被选择, 接下来要考虑的就是, 如何在前i1i-1 个物品中选择物品放入容量为jw[i]j-w[i] 的背包, 则 Rec(i,j)=Rec(i1,jw[i])+v[i]Rec(i, j) = Rec(i-1, j-w[i]) + v[i]

总和两种可能,取较大值。可以给出递推式:
Rec(i,j)=max(Rec(i1,j),Rec(i1,jw[i])+v[i])Rec(i, j) = max(Rec(i-1, j), Rec(i-1, j-w[i])+v[i])
还需要考虑一些细节:

  1. 终止条件, i=0i = 0 , 无物品可选 Rec(0,j)=0Rec(0, j) = 0
    0个物品可以选择,放入容量为j的背包, 得到的最大价值只能为0
  2. j < w[i],背包剩余容量不足以放下第i个物品,Rec(i,j)=Rec(i1,j)Rec(i, j) = Rec(i-1, j)

综上,得到递推式:
Rec(i,j)={0i=0Rec(i1,j)j<w[i]max(Rec(i1,j),Rec(i1,jw[i])+v[i])other Rec(i, j) = \begin{cases} 0 & i = 0\\ Rec(i-1, j) & j < w[i]\\ max(Rec(i-1, j), Rec(i-1, j-w[i])+v[i]) & other \end{cases}
###递归方法:

#include <iostream>
#define MAXN 10000
using namespace std;

int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int Rec(int i, int j) {
    int res;
    if (i == 0) {
        // 终止条件, 无物品可选 Rec(0, j) = 0
        // 0个物品可以选择,放入容量为j的背包, 得到的最大价值只能为0
        res = 0;
    }
    else if (j < w[i]) {
        // 背包剩余容量不足以放下第i个物品
        res = Rec(i-1, j);
    }
    else {
        // 抉择,第i个物品选或者不选,都试一下,取较大值
        res = max(Rec(i-1, j), Rec(i-1, j-w[i]) + v[i]);
    }
    return res;
}

int main() {
    cout << Rec(n, W) << endl;
    return 0;
}

大概画个递归过程:

                                        Rec(4, 5)
                               /                        \
                      Rec(3, 3)                             Rec(3, 5)
                    /        \                            /            \
            Rec(2, 0)     Rec(2, 3)              Rec(2, 2)             Rec(2, 5)  
            /             /     \                /     \                /       \
      Rec(1, 0)     Rec(1, 2)  Rec(1, 3)   Rec(1, 1)    Rec(1, 2)   Rec(1, 4)    Rec(1, 5)
       /          /    \         /    \         /       /    \      /      \      /     \
   (0,0)     (0,0)   (0,2)   (0,1)    (0,3)  (0,1)  (0,0)   (0,2) (0,2)   (0,4) (0,3)  (0,5)

从上图可以看出,我们枚举了所有可能的情况,即对于每个物品i,物品i选还是不选,我们都考虑了一遍。递归的层数是n+1层,最后一层是判定终止。

再来重申一下 Rec(i,j)Rec(i, j) 的含义,表示有编号为1~ ii 的这前 ii 个物品可以选择,放入容量为jj 的背包,所能达到的最大价值。终止条件是i=0i = 0,时,无物品可选,Rec(0,j)=0Rec(0, j) = 0 ,题目要求的是 Rec(n,W)Rec(n, W)

在求解Rec(n,W)Rec(n, W) 的过程中,有些情况可能会被重复计算。比如上图Rec(1,2)Rec(1,2) 在第四行计算了2次。这种重复计算是随着nn 的增大,指数级增长的,所以n,Wn, W 较大时,问题就是不可解的。时间和空间复杂度太高 ,为O(2n)O(2^n)

###记忆化搜索
我们可以利用递归求解过程中的重复计算。如果Rec(i,j)Rec(i, j) 已经计算过,则记录下来,下次需要的时候直接拿来用即可。

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int dp[MAXN][MAXN]; //记录搜索过的结果
int W = 5, n = 4;

int Rec(int i, int j) {
    //Rec(i, j)计算过,直接拿来用
    if (dp[i][j] != -1) return dp[i][j];

    int res;
    if (i == 0) {
        res = 0;
    }
    else if (j < w[i]) {
        res = Rec(i-1, j);
    }
    else {
        res = max(Rec(i-1, j), Rec(i-1, j-w[i]) + v[i]);
    }
    return dp[i][j] = res; //记录
}

int main() {
    memset(dp, -1, sizeof(dp));
    cout << Rec(n, W) << endl;
    return 0;
}

对于任意的i,j,Rec(i,j)i, j, Rec(i,j) 只会计算一次,所以复杂度为O(nW)O(nW)

###动态规划
上面的递归过程,是把大问题分解成小问题,最后由小问题的解合并成大问题的解。

动态规划的方法,就是先把小问题的解计算好,存在表里,等计算大问题的时候,需要用到小问题的解,就过来查一下表。

由递推式:
Rec(i,j)={0(i=0)Rec(i1,j)(j<w[i])max(Rec(i1,j),Rec(i1,jw[i])+v[i])(other) Rec(i, j) = \begin{cases} 0 & (i = 0)\\ Rec(i-1, j) & (j < w[i])\\ max(Rec(i-1, j), Rec(i-1, j-w[i])+v[i]) & (other) \end{cases}
转化为动态规划的写法:
dp[i][j]={0(i=0)dp[i1][j](j<w[i])max(dp[i1][j],dp[i1][jw[i]]+v[i])(other) dp[i][j] = \begin{cases} 0 & (i = 0)\\ dp[i-1][j] & (j<w[i])\\ max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) & (other) \end{cases}
代码:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) { //i从1开始,因为i=0的值已经确定为0
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {
                dp[i][j] = dp[i-1][j];
            }
            else {
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
  cout << solve(n, W) << endl;
  return 0;
}

dp 优化空间复杂度

输出一下上面的二维数组 dp[][]dp[][]

0 0 0 0 0 0

0 0 3 3 3 3

0 2 3 5 5 5

0 2 3 5 6 7

0 2 3 5 6 7

我们是填dpdp 表的顺序是从上到下,从左到右,从递归式可以看出,dp[i][j]dp[i][j] 是由 dp[i1][j]dp[i-1][j]dp[i1][jw[i]]dp[i-1][j-w[i]] 的值推来的。填写第ii行的值,只依赖于上一行,第i1i-1行的值。下次填写第i+1i+1 行的时候,只会用到第ii 行的值,第i1i-1 行的值以后都不会再用到了。

所以我们可以从这里入手优化空间复杂度。没有必要存下整个二维数组,我们只需要存2行,然后不断更新这2行就可以了。

实际上,dp[i][j]dp[i][j] 的值只依赖于第i1i-1 行的 dp[i1][0...j]dp[i-1][0...j] 这前 j+1j+1 个元素, 与dp[i1][j+1...W]dp[i-1][j+1 ... W] 的值无关。

所以,我们可以只存1行,就能完成整个dpdp过程。用dp[0...W]dp[0...W] 存储当前行,更新dp[0...W]dp[0...W] 的时候,我们按照 j=W...0j = W...0 的递减顺序计算dp[j]dp[j],这样可以保证计算dp[j]dp[j] 时用到的dp[j]dp[jw[i]]dp[j]和 dp[j-w[i]] 的值和原本的二维数组中的第i1i-1 行的值是相等的。更新完dp[j]dp[j] 的值后,对dp[0...j1]dp[0...j-1] 的值不会产生影响。

伪代码:

for i = 1 to n
    for j = W to w[i]
        dp[j] = max(dp[j], dp[j-w[i]] + v[i])

代码:

#include <iostream>
#include <cstring>
#define MAXN 10000
using namespace std;

int dp[MAXN];
int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) { // i从1开始,递增
        for (int j = W; j >= 0; j--) { // j按递减顺序填表
            if (j < w[i]) {
                dp[j] = dp[j];
            }
            else {
                dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
            }
        }
    }
    return dp[W];
}

int main() {
    cout << solve(n, W) << endl;
    return 0;
}

####初始化的细节问题
我们看到的求解最优解的背包问题中,事实和桑有两种不太相同的问法。

  1. 要求”背包恰好装满“ 时的最优解
  2. 不要求背包一定要被装满时的最优解

我们上面所讨论的就是第2种, 不要求背包一定要被装满时的最优解。

一种区别这两种问法的实现方法是在初始化的时候有所不不同。

如果是第一种问法,要求恰好装满背包,那么在初始化时除了 dp[0]dp[0] 为0, 其他dp[1...W]dp[1...W]均设为 -\infty ,这样就可以保证最终得到 dp[W]dp[W] 是一种恰好装满背包的最优解

如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将dp[0...W]dp[0...W] 全部设为0。

这是为什么呢?可以这样理解:初始化的dpdp 数组事实上就是在没有任何物品可以放入背包时的合法状态。如果要求背包恰好装满,那么此时只有容量为0的背包可以在什么也不装的状态下被 “恰好装满” ,此时背包价值为0。其他容量的背包均没有合法的解,属于未定义的状态,所以都应该被赋值为 -\infty当前的合法解,一定是从之前的合法状态推得的

如果背包并非必须被装满,那么任何容量的背包都有一个合法解 “什么也不装”,这个解的价值为0,所以初始化时状态的值也就全部为0了。


##完全背包问题
nn 种重量和价值分别为wi,viw_i, v_i 的物品。从这些物品中挑选总重量不超过WW 的物品,求出挑选物品价值总和的最大值。在这里,每种物品可以挑选任意多件。

在0-1背包问题中,每种物品只有不选和选两种可能。在完全背包问题中,每个物品可以选0, 1, … W/wi\lfloor W/w_i \rfloor 个。
dp[i][j]:=ij令 dp[i][j] := 从前i种物品中挑选总重量不超过j的物品的最大总价值。 那么递推关系为:

dp[i][j]={0i=0max(dp[i1][jk×w[i]]+k×v[i])0kj/wi1in dp[i][j] = \begin{cases} 0 & &i = 0\\ max(dp[i-1][j-k×w[i]] + k×v[i]) & 0 \le k \le \lfloor j/w_i \rfloor& 1 \le i \le n\\ \end{cases}
代码:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            for (int k = 0; k <= j/w[i]; k++) {
                dp[i][j] = max(dp[i][j], dp[i-1][j-k*w[i]] + k*v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

最坏情况下复杂度为 O(nW2)O(nW^2) , 不够好。这里面还是有很多多余的重复计算。第三层循环里,对于每种物品,都计算 j/wi\lfloor j/w_i \rfloor 次,这是不必要的。动态规划就是要利用已经计算过的小规模的问题的解,来求解更大规模的问题的解。让我们来探寻一下,还有什么我们没有利用的重复计算。

再重申一下,dp[i][j]:=ijdp[i][j] := 从前i种物品中挑选总重量不超过j的物品的最大总价值。

再来看一下上面的递推公式:

dp[i][j]={0i=0max(dp[i1][jk×w[i]]+k×v[i])0kj/wi1in dp[i][j] = \begin{cases} 0 & &i = 0\\ max(dp[i-1][j-k×w[i]] + k×v[i]) & 0 \le k \le \lfloor j/w_i \rfloor& 1 \le i \le n\\ \end{cases}
kk=0k0,i1这里的k可分为两种情况,k = 0和k \ne 0 ,也就是第i种物品不选,或者至少选1个。

  • k=0idp[i][j]=dp[k = 0 时,即不选择第i种物品, dp[i][j] = dp[i1i-1][j],][j],
  • k0idp[i][j]=dp[k \ne 0 时, 即至少选一个第i种物品, dp[i][j] = dp[ii][jw[i]]+v[i]][j-w[i]] + v[i]

注意上面红色标识的 i1i-1ii

k=0k0ik=0时,比较容易理解,k \ne 0时,先强行往背包里塞一个第i种物品,然后把问题转化成更小规模的问题。

ijdp[i][j]dp[i][jw[i]]i和j都是按递增顺序循环的,所以求解dp[i][j]时, dp[i][j-w[i]]的值是已经求过了的,可以直接拿来用。

综上,可以得出递推公式:
dp[i][j]=max(dp[i1][j],dp[i][jw[i]]+v[i])1 dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i]) (1)

更正式地推导一下:

dp[i][j]=max(dp[i1][jk×w[i]]+k×v[i]),0kj/wi=max(dp[i1][j],max(dp[i1][jk×w[i]]+k×v[i])),1kj/widp[i][j] = max(dp[i-1][j-k×w[i]] + k×v[i]) ,0 \le k \le \lfloor j/w_i \rfloor \\ = max(dp[i-1][j], max(dp[i-1][j-k×w[i]] + k×v[i])), 1 \le k \le \lfloor j/w_i \rfloor
=max(dp[i1][j],=max(dp[i-1][j],max(dp[i1][(jw[i])(k1)×w[i]]+(k1)×v[i])max(dp[i-1][(j-w[i]) - (k-1)×w[i]] + (k-1)×v[i])+v[i]),+v[i]),$0 \le (k-1) \le \lfloor j/w_i \rfloor -1\$

=max(dp[i1][j],=max(dp[i-1][j],dp[i][jw[i]]dp[i][j-w[i]]+v[i])+ v[i])

刚好和上面的(1)式一样。

代码:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {// 不能塞的时候也不能硬塞,要注意一下
                dp[i][j] = dp[i-1][j];
            }
            else {
                dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

类似0-1背包问题, 完全背包问题也可以在空间上进行优化。

dp[i][j]ii1,我们发现dp[i][j]的值只依赖于第i行和第i-1行的值, 可以只用一个一维数组来存所有需要的值

伪代码:

for i = 1 to n
    for j = 0 to W
        dp[j] = max(dp[j], dp[j-w[i]] + v[i])

我们可以发现,完全背包的伪代码和0-1背包的伪代码非常像,只是第二层循环j的顺序不同,0-1背包是逆序循环,完全背包是正序循环。0-1背包问题为何逆序,之前已经讲得非常清楚了。完全背包问题第二层的j正序循环,因为dp[i][j]dp[i][j] 需要用到dp[i][jw[i]]dp[i][j-w[i]] , 只用一维数组保存的话,按j正序循环,就能保证计算dp[j]dp[j] 时,旧的dp[j]dp[j] 的值就等于dp[i1][j]dp[i-1][j] 的值,dp[jw[i]]dp[j-w[i]] 已经计算过,且对应的就是二维数组中dp[i][jw[i]]dp[i][j-w[i]] 的值。

代码:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {
                dp[j] = dp[j];
            }
            else {
                dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
            }
        }
    }
    return dp[W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

##多重背包问题
nn 种物品和一个容量为WW 的背包。第ii 种物品有mim_i 个,每件重量为wiw_i , 价值为viv_i ,求从这nn 种物品中挑选重量总和不超过WW 的物品的最大价值。

###转化成0-1背包问题
多重背包问题,最简单的解法,就是转化成0-1背包问题。第ii 个物品有 mim_i 个, 等价于有mim_i 个相同的物品。但直接拆分成 mim_i 件物品并不是最好的方法。我们可以利用二进制来拆分。例如 m1=13=20+21+22+6m_1 = 13 = 2^0 + 2^1 + 2^2 + 6 ,我们将第一种物品共13件,拆分成 20,21,22,62^0, 2^1, 2^2, 6 这四件, 13以内的任何数字都可以通过这四种数字组合而成。

下面给出一个二进制拆分的多重背包模板:

const int N = 100, W = 100000;
int cost[N], weight[N], number[N];
int dp[W + 1];

int knapsack(int n, int w)
{
    for (int i = 0; i < n; ++i)
    {
        int num = min(number[i], w / weight[i]);
        for (int k = 1; num > 0; k*=2)
        {
            if (k > num) k = num;
            num -= k;
            for (int j = w; j >= weight[i] * k; --j)
                dp[j] = max(dp[j], dp[j - weight[i] * k] + cost[i] * k);
        }
    }
    return  dp[w];
}

时间复杂度为 O(nlogM×W)O(nlogM × W) , 实际应用已经足够好了。

###单调队列优化
多重背包问题的递归式为:
dp[i][j]={0i=0max(dp[i1][jk×w[i]]+k×v[i])0km[i]1kn dp[i][j] = \begin{cases} 0 & &i = 0\\ max(dp[i-1][j-k×w[i]] + k×v[i]) & 0 \le k \le m[i] & 1 \le k \le n \end{cases}
根据递推式,很容易能写出三重循环版本的多重背包代码。但这明显不是我们想要的。是否有方法能像完全背包问题一样,能有O(nW)O(nW) 的解法呢?

是的,有。

通过巧妙的构造,我们就可以用单调队列来优化多重背包问题, 使得复杂度降为 O(nW)O(nW)

什么是单调队列: http://blog.csdn.net/justmeh/article/details/5844650
用单调队列优化: http://blog.csdn.net/flyinghearts/article/details/5898183

多重背包 单调队列模板:

#include <iostream>
#include <deque>
#include <algorithm>

using namespace std;

struct Pack
{
    int sum, cost;
    Pack(int s, int c) : sum (s), cost(c) {}
};

const int Maxv = 1001;
deque <Pack> Q;
int N, V, F[Maxv];

int main()
{
    cin >> N >> V;
    for (int i = 1, p, w, c; i <= N; i ++)
    {
        cin >> p >> w >> c; p = min(p, V / w);
        for (int j = 0; j < w; j ++)
        {
            Q.clear();
            for (int k = 0; k <= (V - j) / w; k ++)
            {
                int y = F[k * w + j] - k * c;
                while (Q.size() && Q.back().cost <= y) Q.pop_back();
                Q.push_back(Pack(k, y));
                if (Q.front().sum < k - c) Q.pop_front();
                F[k * w + j] = Q.front().cost + k * c;
            }
        }
    }
    cout << F[V] << endl;
    return 0;

目前第三讲刚开了个头。
未完待续,我会尽快补全9讲的。
补全随缘吧。。工作后没太多时间了(捂脸)
优秀的背包问题模板:http://blog.csdn.net/libin56842/article/details/9396649;

展开阅读全文
©️2020 CSDN 皮肤主题: 大白 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值