背包问题

背包

背包问题形如使用多种物品凑出某个值一类的问题。我们可以从不同角度将其分类。

从物品选择方面可以分为:

  • 0/1背包:每种物品最多选择一个。

  • 多重背包:第 \(i\) 物品最多选择 \(c_i\) 个。

  • 完全背包:所有物品选择数量无上限。

容易发现,0/1 背包属于多重背包的特殊情况 。

从目标方面可以分为:

  • 可行性背包:计算是否存在选择方案使得体积和为 \(k\)

  • 计数背包:计算存在多少种方案使得体积和为 \(k\)

  • 最优化背包:计算体积和为 \(k\) 的方案中最大的价值和。

同样的,计数背包完全包含可行性背包。

某些情况下,可能会涉及特殊的背包,例如循环背包(体积在取模运算下进行)。

循环背包一般只涉及可行性以及计数问题。

记号约定

若无特殊说明,我们约定 \(n\) 为物品数量。\(m\) 为重量上限。

对于第 \(i\) 个物品,\(c_i\) 为其数量,\(v_i\) 为其大小,\(w_i\) 为其价值。

背包问题的dp解法

大部分背包问题都可以使用 dp 解决。时间复杂度在大多情况下已经足够优秀。

但本文中 dp 解法不是终点。我们会将主要篇幅留给更优秀的解法。

基础dp算法

0/1背包

最简单也是最基础的背包问题。

以当前容量为状态容易设计出最朴素的 dp:\(f_{i,j}\) 代表,前 \(i\) 个数中体积和为 \(j\) 的答案。

转移时判断是否选择,使用 \(f_{i-1, j - v_i}\) \(f_{i-1, j}\) 和 的数据更新 \(f_{i, j}\) 的数据。

这种方法适用于所有三种目标。可行性背包转移为 or 运算,计数背包为加法,最优化背包为 max。

时间空间复杂度均为 \(O(nm)\)

多重背包

最简单的想法是将一个数量为 \(c_i\) 的物品通过暴力拆分成 \(c_i\) 个数量为 \(1\) 的物品,转化为 0/1 背包。

时间空间复杂度为 \(O(m\sum_i c_i)\)

另一种做法是,在更新答案时额外枚举选取的物品数量。

对于 \(f_{i,j}\) 状态,我们枚举 \(k=0,\dots,c_i\) 代表选取的物品数量,从 \(f_{i-1, j - kv_i}\) 更新答案。

空间复杂度为 \(O(nm)\),时间复杂度不变。

完全背包

对于完全背包,我们选择的每个物品的数量实际仍然有限。因为当多个同一物品总大小超过 m 则无意义。

所以我们可以将其转化为多重背包。

使用多重背包的做法二,空间复杂度为 \(O(nm)\),时间复杂度为 \(O(m\sum_i{\frac{m}{v_i}})\)。由于相同的 \(v_i\) 可以合并,复杂度最坏为 \(O(m^2\ln m)\)

dp解法优化

空间优化

0/1背包在二维状态下时间复杂度已经达到最优,但我们仍然可以优化空间复杂度。

类似于滚动数组,但由于 0/1 背包的状态转移只会使用第二维更小的位置,我们可以通过钦定循环顺序,完全舍弃第一维。达到 \(O(m)\) 空间复杂度。

一般背包循环顺序为 \(j\) 从高到低。

实际上0/1背包空间优化的技巧也可以同样适用于其他类型背包。方法类似。

这就是一般博客所讲的0/1背包。

完全背包时间优化

这里需要用到一些前缀和的思想。此处我们暂时使用计数背包作为例子。本方法也可以应用到最优化背包。

我们考虑完全背包的转移为:\(f_{i,j} = \sum_{k\geq 0} f_{i-1,j-kv_i}\)。注意 \(k\) 没有数量上限。

如果将 \(\dots, f_{i-1,j-2v_i}, f_{i-1,j-v_i}, f_{i-1,j}, f_{i-1,j+v_i}, \dots\) 看成一个序列。则 \(f_{i,j}\) 可以看作其的一个前缀和。

(这个序列相邻两项在原数组上位置差为 \(v_i\),也就是说我们把原数组分为 \(v_i\) 个序列,下标 \(\bmod v_i\) 各不相同。这个序列在后面还需要多次遇到)

然而前缀和的计算复杂度为 \(O(n)\),这是因为我们不需要每次枚举一个前缀,而是使用上一次计算的结果 \(s_i = s_{i-1}+a_i\)

所以对于完全背包的转移我们可以使用相同的做法。\(f_{i,j} = f_{i,j-v_i} + f_{i-1,j}\)

此时时间复杂度为 \(O(nm)\)

完全背包空间优化

对于时间优化后的完全背包,我们有一种全新的空间优化。

\(f_{i,j} = f_{i,j-v_i} + f_{i-1,j}\),同样从该式子进行滚动数组优化。

同样我们发现,\(f_j\) 只用到 \(f_{j-v_i}\) 的新值,以及 \(f_j\) 处的旧值。我们可以同样可以舍弃第一维,而这一次循环顺序和上次相反。

空间复杂度 \(O(m)\)

dp循环顺序是很多初学者搞不明白的点,因为大部分博客都没有讲清楚循环顺序的真正原因。

多重背包二进制拆分优化

对于多重背包的时间复杂度我们仍然没有一个满意的结果。二进制拆分能够将其优化到 \(O(m\sum_i\log c_i)\),也就是 \(O(mn\log \max c_i)\)

原理十分简单,众所周知,\({1,2,4,8,\dots,2^k}\) 的集合的子集和能够表示出所有 \([1,2^k-1]\) 的数。

也就是说我们在将多重背包转变成0/1背包时,不需要每个 \(c_i\) 拆成 \(c_i\) 个大小为 \(1\) 的物品,而是拆成 \(\log c_i\) 个物品。

复杂度也就优化到 \(O(m\sum_i\log c_i)\)

多重背包前缀和优化

多重背包的复杂度比0/1背包低很正常,但却也比同样是选择多个的完全背包低。

我们重新思考一下为什么。多重背包是否也能同样借用完全背包的前缀和?

答案是可以。

对于计数多重背包。我们有 \(f_{i,j} = \sum_{k=0}^{c_i} f_{i-1,j-kv_i}\)。这是一个在余数序列上的区间和,从 \(j-c_iv_i\)\(j\) 的和。

也就是说我们可以使用前缀和相减的方法计算。设 \(s_{i,j} = \sum_{k \geq 0} f_{i,j-kv_i}\)。则 \(f_{i,j} = s_{i-1, j} - s_{i-1,j-c_i-1}\)

\(s_{i,j}\) 可以在计算 \(f_{i, j}\) 时递推。如果不想使用 \(s_{i,j}\) 也可以 \(f_{i,j} = f_{i,j - v_i} - f_{i-1, j - (c_i+1)v_i} + f_{i-1,j}\)

优化后复杂度与其他背包相同,\(O(nm)\)

多重背包单调队列优化

对于计数背包我们可以使用前缀和相减,但最优化背包的运算是 max,\(f_{i,j} = \max_{k=0}^{c_i}(f_{i-1,j-kv_i} + kw_i)\)

区间 max 无法使用前缀 max 相减计算。

重新分析一下问题,我们需要计算的是多组区间 \(max\),并且满足每次询问的区间 \([j-c_iv_i, j]\),其长度固定,每次询问区间位置递增。

这个问题与滑动窗口类似,我们使用单调队列计算。我们对当前区间维护一个队列,只维护所有后缀区间的最大值,满足从左往右单调递减。

每次区间右移,我们从右侧推入新加入的元素,从左侧删除离开区间的元素。这样能保证时刻队列左侧为区间最大值。推入元素 \(u\) 时为了满足单调递减的性质,需要不断弹出队首,直到队首比 \(u\) 大。

每个元素只被推入一次弹出一次,时间复杂度均摊为 \(O(推入元素个数)\)

那么对每个 \(f_{i,j}\) 我们只会推入一次元素,查询复杂度也为 \(O(1)\),故复杂度也是 \(O(nm)\)

退背包技巧

对于计数背包,我们可以实现从当前背包集合中删除某个物品,并计算答案,时间复杂度与加入某个物品相同。

我们考虑使用一维的计数背包模型, \(f_i\) 代表当前大小和为 \(i\) 的方案数。

以0/1背包举例,在加入某个大小为 \(v\) 的物品时,我们从大到小遍历 \(i\),执行 \(f_{i} = f_{i} + f_{i-v}\)

所以如果我们要撤销最后一次加入操作,我们只需要知道该操作的物品大小,反过来从小到大遍历 \(i\),执行 \(f_{i} = f_{i} - f_{i-v}\)

但不止如此,我们知道背包中物品加入顺序对最终答案没有影响。所以我们可以钦定任何物品为最后加入,即用上面的方法删除任何一个背包集合内的物品。

加入和删除的时间复杂度均为 \(O(m)\)

多重背包和完全背包的退背包

对于多重背包和完全背包而言,同样可以使用该方法,复杂度不变,只不过加入和删除的式子和顺序需要做出改变。

完全背包只需要反向遍历顺序。多重背包就麻烦很多。多重背包的加入不能通过一次遍历完成,而是需要通过旧前缀和更新 f 数组之后,再更新前缀和。

多重背包中,我们将长度为 \(c\) 的区间 \([i-cv, i-v]\) 贡献加到区间末尾的 \(i\) 上。那么退背包时,我们需要把产生的贡献还原。

也就是从 \(i\) 的位置上,扣去 \([i-c_iv_i, i-v_i]\) 的贡献。

注意到 \(f\) 中靠右的 \(f\) 值,已经被左侧位置更新过,所以我们从小到大(从左到右)遍历 \(i\),每次通过已经被还原的值还原当前的 \(f_i\)

bitset优化可行性0/1背包

使用一维背包模型,注意到0/1背包的每次操作相当于把整个数组右移 \(v_i\) 格,与原数组进行操作。

这与二进制位运算很相似,正好可行性背包储存的也是0/1代表是否可行,很容易想到可以用bitset优化该算法。

一个bitset类型是一个二进制数,或者叫做01串。可以以更快的速度执行位运算操作。若 bitset 长度为 \(n\)。则一次位运算复杂度为 \(O(\frac{n}{\omega})\)。其中 \(\omega\) 可以视为 \(64\)

总复杂度 \(O(\frac{nm}{\omega})\)。可惜可行性完全背包和可行性多重背包的 \(O(nm)\) 做法无法用 bitset 优化。

可行性多重背包使用 bitset 优化仍可以做到 \(O(\frac{nm\log m}{\omega})\)

计数背包和最优化背包皆不能使用 bitset 优化。

背包与多项式

接下来进入重点。多项式科技给背包问题带来的极大的优化,可以做到 \(O(n\log^2 n)\) 甚至 \(O(n\log n)\) 的极致复杂度。

代价是难写的代码和较大的常数。

一般而言多项式解决的背包都是计数背包。因为一般多项式描述的都是 \((+,x)\) 卷积。而可行性背包是 \((|,?)\) 卷积,最优化背包是 \((\max, +)\) 卷积。

使用多项式描述背包问题

多项式的定义和基本运算可以从这里学习,本文不再赘述:从多项式入门到形式幂级数

多项式的乘法运算 \(F\cdot G = H\) 满足 \(f_ig_j\) 贡献到 \(h_{i+j}\)。考虑我们的 \(G\) 函数只有两个系数有值:\(g_0=1,g_{v_i}=1\)。那么 \(F\cdot G\) 时,\(f_i\) 会贡献到 \(f_i\)\(f_{i+v_i}\),符合0/1计数背包。

也就是说,0/1计数背包答案的 \(f\) 数组,表示成多项式 \(F\),则有:

\[F(x)=\prod_{i=1}^{n}(1+x^{v_i}) \]

对于多重背包,单个物品的函数 \(G\) 如下,我们可以通过等比数列求和优化。

\[\begin{gathered} G(x)=\sum_{j=0}^{c_i} x^{jv_i}=\frac{1-x^{(c_i+1)v_i}}{1-x^{v_i}}\\ F(x)=\prod_{i=1}^{n}\frac{1-x^{(c_i+1)v_i}}{1-x^{v_i}} \end{gathered} \]

完全背包则涉及一个无限求和,我们有:

\[\begin{gathered} G(x)=\sum_{j=0}^{+\infty} x^{jv_i}=\frac{1}{1-x^{v_i}}\\ F(x)=\prod_{i=1}^{n}\frac{1}{1-x^{v_i}} \end{gathered} \]

反而比多重背包简单。

背包问题的多项式解法。

我们只需要找到几种方式快速计算得到上述三个式子的结果即可。

计数完全背包

非常典的一个背包问题,付公主的背包

根据上面,我们有答案多项式 \(F(x)\) 为:

\[\begin{gathered} F(x)=\prod_{i=1}^{n}\frac{1}{1-x^{v_i}} \end{gathered} \]

我们求 \(\prod_{i=1}^{n}(1-x^{v_i})\) 之后将其取逆即可。

注意到乘法不是非常好做,我们考虑取 \(\ln\) 之后加起来再 \(\exp\)

\[\begin{gathered} F(x)=1-x^{v}\\ (\ln F(x))' = \frac{F'(x)}{F(x)} = -\frac{vx^{v-1}}{1-x^{v}} \end{gathered} \]

我们可以把 \(\frac{1}{1-x^{v}}\) 拆开变成无限和式。

\[\begin{gathered} F(x)=1-x^{v}\\ (\ln F(x))' = -\sum_{i\geq 0} vx^{v(i+1)-1}\\ \ln F(x) = -\sum_{i\geq 0} \frac{vx^{v(i+1)}}{v(i+1)}\\ \ln F(x) = -\sum_{i\geq 0} \frac{x^{v(i+1)}}{i+1}\\ \ln F(x) = -\sum_{i\geq 1} \frac{1}{i}x^{vi}\\ \end{gathered} \]

那么我们就得到了 \(\ln (1-x^{v_i})\) 的所有系数。我们可以枚举所有 \(v\),再枚举其倍数 \(vi \leq m\),得到求和后每一项系数的复杂度为 \(O(m\ln m)\)

在做一次多项式 \(\exp\) 和多项式求逆即可。复杂度 \(O(m\log m)\)

计数0/1背包

\[\begin{gathered} F(x)=\prod_{i=1}^{n}(1+x^{v_i}) \end{gathered} \]

乘法依然不好处理,我们考虑继续使用 \(\ln\) 转加法。

\[\begin{gathered} (\ln(1+x^{v}))'=\frac{vx^{v-1}}{1+x^{v}}\\ (\ln(1+x^{v}))'=\sum_{i\geq 0}(-1)^ivx^{v(i+1)-1}\\ \ln(1+x^{v})=\sum_{i\geq 0}\frac{(-1)^ivx^{v(i+1)}}{v(i+1)}\\ \ln(1+x^{v})=\sum_{i \geq 1}\frac{(-1)^{i-1}}{i}x^{vi} \end{gathered} \]

同样的可以得到答案。

计数多重背包

计数多重背包的分数上方和下方分别可以用与之前相同的方法得到结果。

至此,计数背包的复杂度被我们优化到了 \(O(n\log n)\)

背包问题的其他解法以及一些优化技巧。

多项式解法存在常数大,难写等问题。所以这里我们讲一些其他的解法。

线段树+hash 可行性0/1循环背包

该做法为 \(O(m\log^2 m + n)\)

可行性背包存在每个位置最多被更新一遍的性质,所以这意味着总共有用的更新量为 \(O(m)\)。我们考虑利用这点。

使用hash+分治,每次使用物品 \(v\) 时,使用哈希判断 \([l, r]\) 区间和 \([l + v, r + v]\) 是否相同,相同则返回,不相同则递归到左右儿子判断。

这样每个位置被当前修改更新的位置会消耗 \(O(\log m)\) 次比较,每次比较使用线段树维护哈希值,得到 \(O(\log^2 m)\) 的复杂度。

但仔细一想这个均摊似乎有问题,我们只有在 \([l, r]\) 区间对应位置为 \(1\)\([l + v, r + v]\) 对应位置为 \(0\) 时,才会修改,左侧为 \(0\),右侧为 \(1\) 虽然不同,但不会修改。

但实际上,由于我们维护的是循环背包,那么在 \(x + v, x + 2v, x + 3v, ...\)\(m\) 构成的环上,每一个左侧为 \(0\) 右侧为 \(1\) 都会对应一个左侧为 \(1\) 右侧为 \(0\),所以复杂度仍然是正确的。

\(O(\sum v_i \sqrt{\sum v_i})\) 0/1背包

注意到0/1背包可以转化为多重背包,而多重背包的复杂度优化后和 0/1背包一致。所以某些情况下我们可以合并相同大小的物品再进行多重背包。

不同 \(v_i\) 最多 \(\sqrt{\sum v_i}\) 种,也就是说我们化成多重背包后复杂度为 \(O(\sum v_i \sqrt{\sum v_i})\)

posted on 2025-12-19 19:03  Evan_song  阅读(19)  评论(0)    收藏  举报