DP之背包问题 | 豆包MarsCode AI刷题;

110 阅读6分钟

简介

背包问题(英语:Knapsack problem)是一种组合优化的NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。问题的名称来源于如何选择最合适的物品放置于给定背包中,背包的空间有限,但我们需要最大化背包内所装物品的价值。背包问题通常出现在资源分配中,决策者必须分别从一组不可分割的项目或任务中进行选择,而这些项目又有时间或预算的限制。

背包问题历史悠久,甚至可以追溯到1897年。“背包问题” 一词最早出现于数学家托比阿斯·丹齐格的早期研究中,他研究的问题是如何打包行李,要求最大化所选行李的价值且不能超载。

背包问题出现在现实世界很多领域的决策过程中,诸如寻找节约原料的生产方式、选择投资项目及投资组合、选择证券化的资产以及为默克尔-赫尔曼和其他背包密码系统生成密钥。

背包问题中基础的大致分为一下几类:

1.png

0-1背包

解释

例题中已知条件有第 ii 个物品的重量 wiw_i ,价值 viv_i ,以及背包的总容量 WW

设 DP 状态 fi,jf_{ i , j } 为在只能放前 ii 个物品的情况下,容量为 jj 的背包所能达到的最大总价值。

考虑转移。假设当前已经处理好了前 i1i-1 个物品的所有状态,那么对于第 i 个物品,当其不放入 背包时,背包的剩余容量不变,背包中物品的总价值也不变,故这种情况的最大价值为 fi1,jf_{i-1, j} ;当 其放入背包时,背包的剩余容量会减小 wiw_i,背包中物品的总价值会增大 viv_i ,故这种情况的最大价 值为 fi1,jwi+vif_{i-1,j-w_{i}} + v_i 。由此可以得出状态转移方程:

fi,j=max(fi1,j,fi1,jwi+vi)f_{i,j}= max(f_{i-1,j},f_{i-1,j-w_{i}}+ v_i)

这里如果直接采用二维数组对状态进行记录,会出现 MLE。可以考虑改用滚动数组的形式来优 化。(将二维数组优化写成一维数组)

由于对 fif_i 有影响的只有 fi1f_{i-1},可以去掉第一维,直接用 fif_i 来表示处理到当前物品时背包容量为 ii 的最大价值,得出以下方程:

fj=max(fj,fjwi+vi)f_j=max(f_j, f_{j-w_{i}} + v_i)

核心代码:

for (int i = 1; i <= n; i++)
{
    for (int j = W; j >= w[i]; j--)
    {
        f[j] = max(f[j], f[j - w[i]] + v[i]);
    }
}

需要注意的是,第二个循环的枚举的顺序是从 WW 枚举到 wiw_i 。为什么呢?

如果从 wiw_i 枚举到 WWj>wij>w_i 时,fi,jf_{i,j} 是会被 fi,jwif_{i,j-w_{i}}所影响的,如果前面 fi,jwif_{i,j-w_{i}} 放入物品 ii 是价值更大,我们会将其放入并更新,在后面 fi,jf_{i,j} 时如果再次选择放入会用到 fi,jf_{i,j} 这就相当于物品 ii 可以多次被放入背包,与题意不符。(事实上,这正是完全背包问题的解法)。

如果二维 DP 不会爆 MLE 我们也可以用二维 DP 来处理,此时第二个循环的枚举顺序不影响结果,因为对于任意的 fi,jf_{i,j} 并不会修改 fi1,jf_{i-1,j} 或者 fi1,jwif_{i-1,j-w_{i}} 的结果。代码如下:

for (int i = 1; i <= n; i++)
{
    for (int j = W; j >= w[i]; j--)
    {
        f[i][j] = max(f[i - 1][j], f[i - 1][j - w[i]] + v[i]);
    }
}

完全背包

解释

完全背包模型与 0-1背包 类似,与 0-1背包 的区别仅在于一个物品可以选取无限次.。

我们可以借鉴 0-1背包的思路,进行状态定义:设 fi,jf_{i,j} 为只能选前 ii 个物品时,容量为 jj 的背包可 以达到的最大价值。

可以考虑一个朴素的做法:对于第 ii 件物品,枚举其选了多少个来转移。这样做的时间复杂度是 O(n3)O(n^3) 的。状态转移方程如下:

fi,j=maxk=0+(fi1,jkwi+vik)f_{i,j} = \max_{k=0}^{+\infty}(f_{i-1,j-k*w_{i}}+v_{i}*k)

考虑做一个简单的优化。可以发现,对于 fi,jf_{i,j} ,只要通过 fi,jwi+vif_{i,j-w_{i}}+v_{i} 转移就可以了。当我们这样转移时,fi,jwif_{i,j-w_{i}} 已经由 fi,j2wif_{i,j-2*w_{i}} 更新过,那么 fi,jwif_{i,j-w_{i}} 就是充分考虑了第i件物品 所选次数后得到的最优结果。换言之,我们通过局部最优子结构的性质重复使用了之前的枚举过 程,优化了枚举的复杂度。因此状态转移方程为:

fi,j=max(fi1,j,fi1,jwi+vi)f_{i,j}= max(f_{i-1,j}, f_{i-1,j-w_{i}}+ v_i)

与 0-1背包相同,我们可以将第一维去掉来优化空间复杂度。

核心代码:

for (int i = 1; i <= n; i++)
{
    for (int j = w[i]; j >= W; j--)
    {
        f[j] = max(f[j], f[j - w[i]] + v[i]);
    }
}

多重背包

解释

多重背包是 0-1背包的一个变式。与 0-1背包的区别在于每种物品有 kik_i 个,而非一个。

一个很朴素的想法就是:我们把 kik_i 个相同的物品看成 kik_i 个重量价值相同但是不同的物品,即对 i=1nki\sum_{i=1}^{n} k_{i} 个物品做 0-1背包。

时间复杂度 O(Wi=1nki)O(W \sum_{i=1}^{n} k_{i})

核心代码:

for (int i = 1; i <= n; i++)
{
    for (int weight = W; weight >= w[i]; weight--)
    {
        // 多遍历一层物品数量
        for (int k = 1; k * w[i] <= weight && k <= cnt[i]; k++)
        {
            dp[weight] = max(dp[weight], dp[weight - k * w[i]] + k * v[i]);
        }
    }
}

二进制分组优化

显然,复杂度中的 O(nW)O(nW) 部分无法再优化了,我们只能从 O(i=1nki)O(\sum_{i=1}^{n} k_{i}) 处入手。

为了表述方便,我们用 Ai,jA_{i,j} 代表第 ii 种物品拆分出的第 jj 个物品。

在朴素的做法中,jki\forall j \le kiAi,jA_{i,j} 均表示相同物品。那么我们效率低的原因主要在于我们进行了大量的重复性的工作。举例来说,我们考虑了同时选 Ai,1A_{i,1}Ai,2A_{i,2} 与同时选 Ai,2A_{i,2}Ai,3A_{i,3} 这两个完全等效的情况。这样的重复性工作我们进行了许多次。那么优化拆分方式就成为了解决问题的突破口。

我们可以通过二进制分组的方式使拆分方式更加优美。 具体地说就是令 Ai,j,(j[0,log2(ki+1)1])A_{i,j},(j\in [0,\lfloor log_2(k_i+ 1) \rfloor - 1]) 分别表示由 2j2^j 个单个物品捆绑而成的大物 品。特殊地,若 ki+1ki+1 不是 22 的整数次幂,则需要在最后添加一个由 ki2log2(ki+1)1k_i-2^{\lfloor log_2(k_i+ 1) \rfloor - 1} 个单个物品捆绑而成的大物品用于补足。

例如:

  • 8=1+2+4+1
  • 18=1+2+4+8+3
  • 31=1+2+4+8+16

显然,通过上述拆分方式,可以表示任意 ki\le k_i 个物品的等效选择方式。将每种物品按照上述方式 拆分后,使用 0-1背包的方法解决即可。

时间复杂度 O(Wi=1nlog2ki)O(W\sum_{i=1}^{n} log_2k_{i})

index = 0;
for (int i = 1; i <= m; i++)
{
    int c = 1, p, h, k;
    cin >> p >> h >> k;
    while (k > c)
    {
        k -= c;
        list[++index].w = c * p;
        list[index].v = c * h;
        c *= 2;
    }
    list[++index].w = p * k;
    list[index].v = h * k;
}

分组背包

解释

ii 件物品和一个大小为 mm 的背包,第 ii 个物品的价值为 viv_i ,体积为 wiw_i 。同时,每个物品只属于一个组,同组内最多只能选择一个物品,求背包能装载物品的最大总价值。

其实是从在所有物品中选择一件变成了从当前组中选择一件,于是就对每一组进行一次 0-1 背包就可以了,再说一说如何进行存储。我们可以将t表示第k组的第i件物品的编号是多少,再用 cntcnt 表示第 kk 组物品有多少个。

核心代码:

for (int k = 1; k <= ts; k++) // 循环每一组
{
    for (int i = m; i >= 0; i--)
    {
        for (int j = 1; j <= cnt[k]; j++) // 循环该组的每一个物品
        {
            if (i >= w[t[k][j]])
            {
                dp[i] = max(dp[i],dp[i - w[t[k][j]]] + c[t[k][j]]);
            }
        }
    }
}

混合背包

解释

混合背包是 0-1背包、完全背包以及多重背包的组合,会出现其中的几种背包。有些可以无限取,有些物品只可以取有限次。

把不同情况分开:

  • 能无限选 → 按完全背包处理。
  • 选择次数有限 → 按多重(0-1)背包处理。

分情况分别解决,最后合并即可。

for (int i = 1; i <= n; i++)
{
    if (是 0 - 1 背包)
    {
        //套用 0 - 1 背包代码;
    }
    else if (是完全背包)
    {
        //套用完全背包代码;
    }
    else if (是多重背包)
    {
        //套用多重背包代码;
    }
}

总结

背包问题是一类经典的动态规划问题,具有非常广泛的应用,在很多实际问题中都有相应的变种。通过分析不同类型的背包问题,我们可以看到它们之间的不同,并且通过优化技巧来提升解法的效率。

0-1 背包问题

这是背包问题中最基本的一种形式。每个物品只能选择一次。核心的思想是使用动态规划来计算最优解。通过定义状态 f[i][j] 或者使用滚动数组优化为一维 f[j],我们能够高效地找到在限制重量下可以获得的最大价值。

滚动数组优化

由于在每一轮的状态转移中,只依赖于上一轮的状态,所以可以使用滚动数组的方式来优化空间复杂度。这样做能够将时间复杂度保持在 O(nW),并减少内存开销。

完全背包问题

与 0-1 背包的不同之处在于,每种物品可以选择多次。通过动态规划的方式,类似于 0-1 背包的做法,依然采用滚动数组优化空间,核心在于转移方程中的更新顺序:从小到大的顺序保证了每个物品在同一轮次内的多次选择。

多重背包问题

多重背包是 0-1 背包的扩展。每种物品有数量限制,而不是只能选择一次。通过二进制分组的优化,可以将复杂的选择方式转化为更简洁的形式,从而避免重复计算。这种方法使得时间复杂度减少到 O(W * log(k)),大大提升了效率。

分组背包与混合背包问题

分组背包是指物品分组后每组只能选择一个物品,而混合背包则是不同种类背包的组合问题(0-1、完全、多重背包混合出现)。解决方法通常是根据每一类物品的特点分别使用不同的背包解法,再将结果合并。

思考与感悟

  1. 动态规划的共性与差异:无论是 0-1 背包、完全背包还是多重背包,核心思路都是通过逐步构建状态来达到最优解。不同的背包问题通过不同的状态转移方式和约束条件来区分,动态规划的本质在于分解问题,逐步求解每个子问题。
  2. 滚动数组优化:对于空间复杂度的优化,滚动数组是非常实用的技巧。它减少了二维数组的使用,使得背包问题能够以更低的内存开销进行计算。
  3. 二进制分组的创新:多重背包中的二进制分组技巧解决了如何高效表示多种物品选择的问题,它避免了重复计算并使问题得到优化。这是一种通过数学拆分来降低时间复杂度的好方法。
  4. 混合背包的复杂性:混合背包问题虽然可以通过组合不同类型的背包来处理,但也带来了更高的复杂度。每次解法都需要根据物品的特性做出判断,选择适当的策略来求解。

总之,背包问题不仅仅是一个理论上的问题,它在实际应用中具有极高的价值,尤其是在资源分配、预算规划等领域。通过灵活运用动态规划、滚动数组、二进制分组等技巧,可以高效解决这些问题,并为更多实际问题提供解决方案。