算法学习笔记(43)——背包问题

背包问题

背包是线性 DP 中一类重要而特殊的模型,本文将其作为单独一部分进行总结整理。

0/1 背包问题

0/1 背包问题的模型如下:
给定 N 个物品,其中第 i 个物品的体积为 Vi ,价值为 Wi 。有一容积为 M 的背包,要求选择一些物品放入背包,使得物品总体积不超过 M 的前提下,物品的价值总和最大。
注意:每件物品最多只用一次

DP问题一般从以下两方面入手:

  • 状态表示 f(i,j) :考虑清楚需要几维来表示状态,每一个状态的含义是什么
    • DP问题的集合是什么?
      • 所有选法
      • 需要满足以下两个条件:
        1. 只从前 i 个物品中选
        2. 选出来的物品总体积 j
    • 集合的属性是什么?(Max,Min,数量等)
      • f(i,j) 存的是所有选法集合中的价值的最大值
  • 状态计算:考虑清楚如何一步一步计算出状态,对应于集合的划分
    • 考虑如何将当前的集合划分为更小的子集,能否通过子问题求解出原问题的最优解?划分集合需要遵从"不重不漏"的原则,不漏一定要满足,不重有时可不满足。
    • 0/1背包问题中将 f(i,j) 表示的所有选法分为两大类:
      • 不包含第 i 个物品的选法:从 1i1 中选,总体积不超过 j 的选法集合,用 f(i1,j) 表示
      • 包含第 i 个物品的选法:从 1i 中选,包含第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 i 选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,jVi)+Wi 表示。

则0/1背包问题的状态转移方程为

f(i,j)=max[f(i1,j),f(i1,jVi)+Wi]

题目链接:AcWing 2. 01背包问题

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N]; // 存储每件物品的体积和价值
int f[N][N];    // 状态表示

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    // 考虑初始的边界情况,f[0][0~m]表示从前0个物品选,总体积不超过0~m的选法,默认为0
    // 此处f[N][N]声明为全局变量,默认初始化为0,所以无需操作
    
    // 状态计算 f[i][j] = max(f[i-1][j], f[i-1][j-v[i]] + w[i])
    for (int i = 1; i <= n; i ++ )
        for (int j = 0; j <= m; j ++ ) {
            f[i][j] = f[i - 1][j];
            // 当v[i] > j时,代表背包放不下第i件物品,此种选法集合为空集
            if (v[i] <= j) f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i]);
        }
        
    // 最后输出从前n件物品选择,所占体积不超过m的最大物品价值
    cout << f[n][m] << endl;
    
    return 0;
}

DP的优化:一般都是对DP问题的代码或计算方程做等价变形

本题中由于状态转移方程为:

f(i,j)=max[f(i1,j),f(i1,jVi)+Wi]

f(i,j) 在计算过程中只用到了 f(i1) 这一层,所以我们可以用滚动数组来做优化。

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N]; // 存储每件物品的体积和价值
int f[N];    // 状态表示

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    // 考虑初始的边界情况,f[0][0~m]表示从前0个物品选,总体积不超过0~m的选法,默认为0
    // 此处f[N][N]声明为全局变量,默认初始化为0,所以无需操作
    
    // 状态计算 f[i][j] = max(f[i-1][j], f[i-1][j-v[i]] + w[i])
    for (int i = 1; i <= n; i ++ )
        // j 与 j-v[i] 严格小于等于 j,所以我们可以从v[i]开始计算
        // 但是v[i]从前向后枚举会导致新的状态不会由旧状态更新
        // 因为计算f[j]时,f[j-v[i]]在之前第i轮循环已经计算过,为了避免此问题,需要倒序循环更新
        for (int j = m; j >= v[i]; j -- ) {
            // 当v[i] > j时,代表背包放不下第i件物品,此种选法集合为空集
            // f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);
            f[j] = max(f[j], f[j - v[i]] + w[i]);
        }
        
    // 最后输出从前n件物品选择,所占体积不超过m的最大物品价值
    cout << f[m] << endl;
    
    return 0;
}

完全背包

完全背包问题的模型如下:
给定 N 种物品,其中第 i 种物品的体积为 Vi ,价值为 Wi ,并且有无数个。有一容积为 M 的背包,要求选择若干个物品放入背包,使得物品总体积不超过 M 的前提下,物品的价值总和最大。
注意:每件物品可使用无限多次
此问题与0/1背包问题非常相似,思考方式如下:

  • 状态表示 f(i,j) :考虑清楚需要几维来表示状态,每一个状态的含义是什么
    • DP问题的集合是什么?
      • 所有选法
      • 需要满足以下两个条件:
        1. 只从前 i 个物品中选
        2. 选出来的物品总体积 j
    • 集合的属性是什么?(Max,Min,数量等)
      • f(i,j) 存的是所有选法集合中的价值的最大值
  • 状态计算:考虑清楚如何一步一步计算出状态,对应于集合的划分
    • 考虑如何将当前的集合划分为更小的子集,能否通过子问题求解出原问题的最优解?划分集合需要遵从"不重不漏"的原则,不漏一定要满足,不重有时可不满足。
    • 完全背包问题中将 f(i,j) 表示的所有选法分为 k 类(此处的 k 根据物品体积不同会动态变化):
      • 不包含第 i 个物品的选法:即从 1i1 中选,总体积不超过 j 的选法集合,用 f(i1,j0×v[i])+0×w[i] 表示
      • 包含 1 个第 i 个物品的选法:即从 1i 中选,包含 1 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 1 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,j1×Vi)+1×Wi 表示。
      • 包含 2 个第 i 个物品的选法:即从 1i 中选,包含 2 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 2 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,j2×Vi)+2×Wi 表示。
      • 以此类推...
      • 直到最终状态,包含 k 个第 i 个物品的选法,此时 k×v[i]j,  k+1×v[i]>j,即从 1i 中选,包含 k 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 k 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,jk×Vi)+k×Wi 表示。

所以我们得到状态转移方程:

f[i][j]=max(f[i][j],f[i1][jkv[i]]+kw[i])

题目链接:AcWing 3. 完全背包问题

利用上面的方程我们可以写出如下的朴素做法(一般会超时):

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N][N];

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    for (int i = 1; i <= n; i ++ )
        for (int j = 0; j <= m; j ++ )
            for (int k = 0; k * v[i] <= j; k ++ )
                f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i]);
    
    cout << f[n][m] << endl;
    
    return 0;
}

对其进行优化,我们观察到状态转移方程展开后:

f[i][j]=max(f[i][j],f[i1][jkv[i]]+kw[i])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][jv]=max(f[i1][jv[i]],f[i1][j2v[i]]+w[i],++f[i1][jkv[i]]+(k1)w[i])

观察得到 f[i][j] 除了第一项之外的部分,与 f[i][jv[i]] 一一对应,并且每一项多一个 w[i],于是得到如下状态转移方程:

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

于是得到两重循环的优化版代码:

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N][N];

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    for (int i = 1; i <= n; i ++ )
        for (int j = 0; j <= m; j ++ ) {
            f[i][j] = f[i - 1][j];
            if (v[i] <= j) f[i][j] = max(f[i][j], f[i][j - v[i]] + w[i]);
        }
    
    cout << f[n][m] << endl;
    
    return 0;
}

又通过观察状态转移方程我们可以看出 f[i][j] 的更新只依赖于 f[i] 这一层的值,所以我们可以将代码的二维优化为一维。不同于0/1背包的一点是,完全背包状态转移过程中不涉及违背线性DP原则的问题,即不需要用前一个阶段的值更新下一阶段,所以不需要倒序循环。优化思路与0/1背包问题相同,不再加以赘述,直接上代码:

#include <iostream>

using namespace std;

const int N = 1010;

int n, m;
int v[N], w[N];
int f[N];

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i];
    
    for (int i = 1; i <= n; i ++ )
        for (int j = v[i]; j <= m; j ++ )
            f[j] = max(f[j], f[j - v[i]] + w[i]);
    
    cout << f[m] << endl;
    
    return 0;
}

多重背包

多重背包问题的模型如下:
给定 N 种物品,其中第 i 种物品的体积为 Vi ,价值为 Wi ,并且Ci。有一容积为 M 的背包,要求选择若干个物品放入背包,使得物品总体积不超过 M 的前提下,物品的价值总和最大。

注意:每件物品可使用有限多次
此问题与完全背包问题非常相似,思考方式如下:

  • 状态表示 f(i,j) :考虑清楚需要几维来表示状态,每一个状态的含义是什么(枚举第 k 个物品选几个)
    • DP问题的集合是什么?
      • 所有选法
      • 需要满足以下两个条件:
        1. 只从前 i 个物品中选
        2. 选出来的物品总体积 j
    • 集合的属性是什么?(Max,Min,数量等)
      • f(i,j) 存的是所有选法集合中的价值的最大值
  • 状态计算:考虑清楚如何一步一步计算出状态,对应于集合的划分
    • 考虑如何将当前的集合划分为更小的子集,能否通过子问题求解出原问题的最优解?划分集合需要遵从"不重不漏"的原则,不漏一定要满足,不重有时可不满足。
    • 完全背包问题中将 f(i,j) 表示的所有选法分为 k 类(此处的 k 根据物品体积不同会动态变化):
      • 不包含第 i 个物品的选法:即从 1i1 中选,总体积不超过 j 的选法集合,用 f(i1,j0×v[i])+0×w[i] 表示
      • 包含 1 个第 i 个物品的选法:即从 1i 中选,包含 1 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 1 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,j1×Vi)+1×Wi 表示。
      • 包含 2 个第 i 个物品的选法:即从 1i 中选,包含 2 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 2 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,j2×Vi)+2×Wi 表示。
      • 以此类推...
      • 直到最终状态,包含 k 个第 i 个物品的选法,此时 k×v[i]j,  k+1×v[i]>j,  k<=Ci,即从 1i 中选,包含 k 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 k 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,jk×Vi)+k×Wi 表示。

所以我们得到状态转移方程:

f[i][j]=max(f[i][j],f[i1][jkv[i]]+kw[i])

利用上面的方程我们可以写出如下的朴素做法(一般会超时):
题目链接:AcWing 4. 多重背包问题
时间复杂度:O(NMi=1NSi)

#include <iostream>

using namespace std;

const int N = 110;

int n, m;
int v[N], w[N], s[N];
int f[N][N];

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i ++ ) cin >> v[i] >> w[i] >> s[i];
    
    for (int i = 1; i <= n; i ++ )
        for (int j = 0; j <= m; j ++ )
            for (int k = 0; k <= s[i] && k * v[i] <= j; k ++ )
                f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i]);
    
    cout << f[n][m] << endl;
    
    return 0;
}

当数据范围较大时,上述的朴素做法会TLE,所以考虑如何优化。

二进制拆分法

众所周知,20,21,22,,2k1k2 的整数次幂中选出若干个相加,可以表示出 0~2k1之间的任意整数。进一步地,我们求出满足 20+21++2pCi 的最大整数 p,设 Ri=Ci20212p,那么:

  1. 根据 p 的最大性,有 20+21++2p+1>Ci,可推出 2p+1>Ri,因此 20,21,,2p 中选出若干个相加可以表示出 0~Ri 之间的任何整数。
  2. 20,21,,2p 以及 Ri 中选出若干个相加,可以表示出 RiRi+2p+11 之间的任何整数。而根据 Ri 的定义,Ri+2p+11=Ci,因此 20,21,,2p,Ri 选出若干个相加可以表示出 Ri~Ci 直接的任何整数。

综上所述,我们可以把数量为 Ci 的第 i 种物品拆成 p+2 个物品,他们的体积分别为:

20Vi,21Vi,,2pVi,RiVi

p+2 个物品可以凑成 0~CiVi 之间所有能被 Vi 整除的数,并且不能凑成大于 CiVi 的数。这等价于原问题中体积为 Vi 的物品可以使用 0~Ci 次。该方法仅把每种物品拆成了 O(logCi) 个,效率很高。

题目链接:AcWing 5. 多重背包问题 II

时间复杂度:O(NMi=1NlogSi)

#include <iostream>

using namespace std;

// 1000 * log2000 , N 取 12000
const int N = 12010, M = 2010;

int n, m;
int v[N], w[N];
int f[M];

int main()
{
    cin >> n >> m;
    
    // cnt统计拆分出的物品数量编号
    int cnt = 0;
    // 将n个物品进行二进制拆分
    for (int i = 1; i <= n; i ++ ) {
        int a, b, s;
        cin >> a >> b >> s;
        
        // 将数量为s的物品拆分1,2,4..2^{p-1},c
        int k = 1;
        while (k <= s) {
            cnt ++;
            v[cnt] = a * k;
            w[cnt] = b * k;
            s -= k;
            k *= 2;
        }
        if (s > 0) {
            cnt ++;
            v[cnt] = a * s;
            w[cnt] = b * s;
        }
    }
    
    // 将原问题转换为具有cnt个物品的0/1背包问题
    n = cnt;
    
    // 等价于0/1背包问题(cnt个物品,体积限制为m),进行处理
    for (int i = 1; i <= n; i ++ )
        for (int j = m; j >= v[i]; j -- )
            f[j] = max(f[j], f[j - v[i]] + w[i]);
            
    cout << f[m] << endl;
    
    return 0;
}

分组背包

分组背包问题的模型如下:
给定 N 种物品,其中第 i 组有 Ci 个物品。第 i 组的第 j 个物品的体积为 Vij ,价值为 Wij 。有一容积为 M 的背包,要求选择若干个物品放入背包,使得每组至多选择一个物品并且物品总体积不超过 M 的前提下,物品的价值总和最大。
注意:每组物品中至多只能选择一个

  • 状态表示 f(i,j) :考虑清楚需要几维来表示状态,每一个状态的含义是什么
    • DP问题的集合是什么?
      • 所有选法
      • 需要满足以下两个条件:
        1. 只从前 i 物品中选
        2. 选出来的物品总体积 j
    • 集合的属性是什么?(Max,Min,数量等)
      • f(i,j) 存的是所有选法集合中的价值的最大值
  • 状态计算:考虑清楚如何一步一步计算出状态,对应于集合的划分(枚举第 i 组物品选哪个)
    • 考虑如何将当前的集合划分为更小的子集,能否通过子问题求解出原问题的最优解?划分集合需要遵从"不重不漏"的原则,不漏一定要满足,不重有时可不满足。
    • 完全背包问题中将 f(i,j) 表示的所有选法分为 k 类(此处的 k 根据物品体积不同会动态变化):
      • 不包含第 i 组物品的选法:即从前 i1 组中选,总体积不超过 j 的选法集合,用 f(i1,j) 表示
      • 包含第 i 组第 1 个物品的选法:即从 1i 中选,包含 1 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 1 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,jVi1)+Wi1 表示。
      • 包含第 i 组第 2 个物品的选法:即从 1i 中选,包含 2 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 2 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,jVi2)+Wi2 表示。
      • 以此类推...
      • 直到最终状态,包含第 i 组第 k 个物品的选法,此时 k×v[i]j,  k+1×v[i]>j,  k<=Ci,即从 1i 中选,包含 k 个第 i 个物品的选法,总体积不超过 j 的选法集合。直接求不好求,但其等价于先把 k 个第 i 个物品选上,再考虑剩下的体积对应的选法集合,可以用 f(i1,jVik)+Wik 表示。

所以我们得到状态转移方程:

f[i][j]=max(f[i1][j],max{f[i1][jv[i][k]]+w[i][k]})

题目链接:AcWing 9. 分组背包问题

朴素写法:

#include <iostream>

using namespace std;

const int N = 110;

int n, m;
int v[N][N], w[N][N], s[N];
int f[N][N];

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i ++ ) {
        cin >> s[i];                        // 输入第i组物品的数量
        for (int j = 1; j <= s[i]; j ++ )   // 读入每组中的每一个物品体积与价值
            cin >> v[i][j] >> w[i][j];
    }
    
    // 枚举每一组物品
    for (int i = 1; i <= n; i ++ )
        // 从0开始枚举背包容量
        for (int j = 0; j <= m; j ++ ) {
            // 不选这一组的物品的情况
            f[i][j] = f[i - 1][j];
            // 枚举当前组内的每一件物品
            for (int k = 1; k <= s[i]; k ++ )
                // 当物品体积小于背包容积时才有更新的必要
                if (v[i][k] <= j) 
                    f[i][j] = max(f[i][j], f[i - 1][j - v[i][k]] + w[i][k]);
        }
            
    
    cout << f[n][m] << endl;
    
    return 0;
}

维数优化:利用“阶段”线性增长的特征,把物品组数作为DP的“阶段”,只要使用了第 i 组物品,就从第 i 个阶段的状态转移到第 i+1 个阶段的状态。用 j 的倒序循环来控制“阶段 i”的状态只能从“阶段 i-1”转移而来。

#include <iostream>

using namespace std;

const int N = 110;

int n, m;
int v[N][N], w[N][N], s[N];
int f[N];

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i ++ ) {
        cin >> s[i];
        for (int j = 1; j <= s[i]; j ++ )
            cin >> v[i][j] >> w[i][j];
    }
    
    for (int i = 1; i <= n; i ++ )
        for (int j = m; j >= 0; j -- )
            for (int k = 1; k <= s[i]; k ++ )
                if (v[i][k] <= j) 
                    f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);
            
    
    cout << f[m] << endl;
    
    return 0;
}
posted @   S!no  阅读(45)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示