背包九讲详解

来源:互联网 发布:单片机控制舵机程序 编辑:程序博客网 时间:2024/06/15 07:39

背包九讲详解


0-1背包问题

有n个重量和价值分别为 wi,vi 的物品。从这些物品中挑选出总重量不超过 W 的物品, 求所有挑选方案中价值总和的最大值。

样例:

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

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

Rec(n,W) 表示剩余容量为W 的背包,还有1nn 个物品可以选择, 所能取得的最大的背包价值。
还剩i 个物品可以选, 背包容量剩余j 的时候,可以分为两种情况:
1. 第i 个物品不选,只考虑前i1 个物品。则 Rec(i,j)=Rec(i1,j) ,此时背包容量j不变
2. 第i 个物品被选择, 接下来要考虑的就是, 如何在前i1 个物品中选择物品放入容量为jw[i] 的背包, 则 Rec(i,j)=Rec(i1,jw[i])+v[i]

总和两种可能,取较大值。可以给出递推式:

Rec(i,j)=max(Rec(i1,j),Rec(i1,jw[i])+v[i])

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

综上,得到递推式:

Rec(i,j)=0Rec(i1,j)max(Rec(i1,j),Rec(i1,jw[i])+v[i])i=0j<w[i]other

递归方法:

#include <iostream>#define MAXN 10000using 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) 的含义,表示有编号为1~ i 的这前 i 个物品可以选择,放入容量为j 的背包,所能达到的最大价值。终止条件是i=0,时,无物品可选,Rec(0,j)=0 ,题目要求的是 Rec(n,W)
在求解Rec(n,W) 的过程中,有些情况可能会被重复计算。比如上图Rec(1,2) 在第四行计算了2次。这种重复计算是随着n 的增大,指数级增长的,所以n,W 较大时,问题就是不可解的。时间和空间复杂度太高 ,为O(2n)

记忆化搜索

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

#include <iostream>#include <cstring>#define MAXN 1000using 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) 只会计算一次,所以复杂度为O(nW)

动态规划

上面的递归过程,是把大问题分解成小问题,最后由小问题的解合并成大问题的解。
动态规划的方法,就是先把小问题的解计算好,存在表里,等计算大问题的时候,需要用到小问题的解,就过来查一下表。
由递推式:

Rec(i,j)=0Rec(i1,j)max(Rec(i1,j),Rec(i1,jw[i])+v[i])(i=0)(j<w[i])(other)

转化为动态规划的写法:
dp[i][j]=0dp[i1][j]max(dp[i1][j],dp[i1][jw[i]]+v[i])(i=0)(j<w[i])(other)

代码:

#include <iostream>#include <cstring>#define MAXN 1000using 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[][]

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

我们是填dp 表的顺序是从上到下,从左到右,从递归式可以看出,dp[i][j] 是由 dp[i1][j]dp[i1][jw[i]] 的值推来的。填写第i行的值,只依赖于上一行,第i1行的值。下次填写第i+1 行的时候,只会用到第i 行的值,第i1 行的值以后都不会再用到了。
所以我们可以从这里入手优化空间复杂度。没有必要存下整个二维数组,我们只需要存2行,然后不断更新这2行就可以了。

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

伪代码:

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 10000using 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] 为0, 其他dp[1...W] ,这样就可以保证最终得到 dp[W] 是一种恰好装满背包的最优解
如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将dp[0...W] 全部设为0。

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

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


完全背包问题

n 种重量和价值分别为wi,vi 的物品。从这些物品中挑选总重量不超过W 的物品,求出挑选物品价值总和的最大值。在这里,每种物品可以挑选任意多件。

在0-1背包问题中,每种物品只有不选和选两种可能。在完全背包问题中,每个物品可以选0, 1, … W/wi 个。
dp[i][j]:=ij 那么递推关系为:

dp[i][j]={0max(dp[i1][jk×w[i]]+k×v[i])0kj/wii=01in

代码:

#include <iostream>#include <cstring>#define MAXN 1000using 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) , 不够好。这里面还是有很多多余的重复计算。第三层循环里,对于每种物品,都计算 j/wi 次,这是不必要的。动态规划就是要利用已经计算过的小规模的问题的解,来求解更大规模的问题的解。让我们来探寻一下,还有什么我们没有利用的重复计算。

再重申一下,dp[i][j]:=ij
再来看一下上面的递推公式:

dp[i][j]={0max(dp[i1][jk×w[i]]+k×v[i])0kj/wii=01in

kk=0k0,i1
* k=0idp[i][j]=dp[i1][j],
* k0idp[i][j]=dp[i][jw[i]]+v[i]

注意上面红色标识的 i1i
k=0k0i
ijdp[i][j]dp[i][jw[i]]

综上,可以得出递推公式:

dp[i][j]=max(dp[i1][j],dp[i][jw[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/wi
=max(dp[i1][j],max(dp[i1][(jw[i])(k1)×w[i]]+(k1)×v[i])+v[i]),0(k1)j/wi1
=max(dp[i1][j],dp[i][jw[i]]+v[i])

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

代码:

#include <iostream>#include <cstring>#define MAXN 1000using 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,

伪代码:

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][jw[i]] , 只用一维数组保存的话,按j正序循环,就能保证计算dp[j] 时,旧的dp[j] 的值就等于dp[i1][j] 的值,dp[jw[i]] 已经计算过,且对应的就是二维数组中dp[i][jw[i]] 的值。

代码:

#include <iostream>#include <cstring>#define MAXN 1000using 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;}

多重背包问题

n 种物品和一个容量为W 的背包。第i 种物品有mi 个,每件重量为wi , 价值为vi ,求从这n 种物品中挑选重量总和不超过W 的物品的最大价值。

转化成0-1背包问题

多重背包问题,最简单的解法,就是转化成0-1背包问题。第i 个物品有 mi 个, 等价于有mi 个相同的物品。但直接拆分成 mi 件物品并不是最好的方法。我们可以利用二进制来拆分。例如 m1=13=20+21+22+6 ,我们将第一种物品共13件,拆分成 20,21,22,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) , 实际应用已经足够好了。

单调队列优化

多重背包问题的递归式为:

dp[i][j]={0max(dp[i1][jk×w[i]]+k×v[i])0km[i]i=01kn

根据递推式,很容易能写出三重循环版本的多重背包代码。但这明显不是我们想要的。是否有方法能像完全背包问题一样,能有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;

1 0
原创粉丝点击