浅谈几类背包题

摘要

背包问题作为一个经典问题在动态规划中是很基础的一个部分,然而以 0-1 背包问题为原题,衍生转变出的各类题目,可以说是千变万化,当然解法也各有不同,如此就有了继续探究的价值。 本文就 4 道背包变化的题做一些探讨研究,提出本人的一些做法,希望能起到抛砖引玉的作用。

关键字

动态规划 背包 优化

正文

一、 引言

背包问题是运筹学中的一个经典的优化难题,是一个 NP-完全问题,但其有 着广泛的实际应用背景,是从生活中一个常见的问题出发展开的: 一个背包,和很多件物品,要在背包中放一些物品,以达到一定的目标。

在信息学中,把所有的数据都量化处理后,得到这样的一个问题: 0-1 背包问题:给定 n 件物品和一个背包。物品 i 的价值是 Wi ,其体积为 Vi,背包 的容量为 C。可以任意选择装入背包中的物品,求装入背包中物品的最大总价值。

在选择装入背包的物品时,对每件物品 i ,要么装入背包,要么不装入背包。 不能将物品 i 多次装入背包,也不能只装入部分物品 i (分割物品 i)。 因此,该 问题称为 0-1 背包问题。 用于求解 0-1 背包问题的方法主要有回溯算法、贪婪算法、遗传算法、禁忌 搜索算法、模拟退火算法等。

在高中阶段,我们所谓的经典 0-1 背包问题,保证了所有量化后的数据均为 正整数,即是一个特殊的整数规划问题,本文中如无特殊说明均以此为前提。其 经典的 \(O(n*C)\)动规解法是: 状态是在前 i 件物品中,选取若干件物品其体积总和不大于 j,所能获得的最大价值为 \(F_i[j]\),当前的决策是第 i 件物品放或者不放,最终得到转移方程:

\[F_i[j] = F_{i-1}[j] \color{yellow}\quad \left( \rm V_i > j\ge 0\right)
\]

\[F_i[j] = \max\{F_{i-1}[j],F_{i-1}[j-\rm{V_i}]+W_i \}\quad \color{yellow}(C\ge j\ge V_i)
\]

其中由于 Fi只与 Fi-1 有关,可以用滚动数组来节省程序的空间复杂度。以下 就是经典算法的伪代码。

FOR i: = 1 TO n
    FOR j: = C DOWNTO V_i
        MAX (F[j],F[j-V_i]+W_i)
    END FOR
END FOR

二、 背包的基本变换

1. 完全背包

完全背包问题:
给定 n 种物品和一个背包。第 i 种物品的价值是 \(W_i\) ,其体积为 \(V_i\),背包的容量为 C,同一种物品的数量无限多。可以任意选择装入背包中的物品, 求装入背包中物品的最大总价值。

这个问题完全可以转化为 0-1 背包问题来解决,即把第 i 种物品分割成 \((C div Vi)\)件物品,再用 0-1 背包问题的经典动规实现。 但是,这个算法的时间复杂度太高,并不能作为一种实用的方法来实现。

很容易注意到,这个问题对于 0-1 背包来说,是另一个极端,每种物 品都可以无限制地取,只要改变转移方程,就可以构造出新的算法: 状态是在前 i 种物品中,选取若干件物品其体积总和不大于 j,所能获得的最大 价值为 \(F_i[j]\),当前的决策是第 i 件物品放(多件)或者不放,转移方程是

\[F_i[j] = F_{i-1}[j] \color{yellow}\quad \left( \rm V_i > j\ge 0\right)
\]

\[F_i[j] = \max\{F_{i-1}[j],F_{i}[j-\rm{V_i}]+W_i \}\quad \color{yellow}(C\ge j\ge V_i)
\]

//注意第二个是 Fi而不是 Fi-1,与 0-1 背包区别仅在于此,因为允许在放过的基础上再增加一件。

这样,这个问题就有了与 0-1 背包一样时间复杂度\(O(n*C)\)的解决方法, 同样的可以用滚动数组来实现。

FOR i: = 1 TO n
    FOR  j: =V_i TO C
        MAX(F[j], F[j-V]+W[i]) → F[j]
    END FOR
END FOR

2. 多次背包

多次背包问题:给定 n 种物品和一个背包。第 i 种物品 的价值是 \(W_i\) ,其体积为 \(V_i\),数量是 \(K_i\)件,背包的容量为 C。可以任意选择装入背包中的物品,求装入背 包中物品的最大总价值。

和完全背包一样,可以直接套用 0-1 背包问题的经典动规实现,但是 效率太低了,需要寻找更高效的算法。

首先对于第 i 种物品,不能确定放多少件才是最优的,因为并没有什么 可以证明放一件或者全放一定会更优。换句话说,最优解所需要的件数, 可能是 0 到 \(K_i\)中的任何数。

在日常生活中,如果需要能拿得出 1 到 Ki的任意整数数额的钱,往往不会带 \(K_i\)个一元钱,因为那实在是太不方便了,取而代之的是带一些 1 元 和其他一些面值各不相同的非 1 数额的钱。 这种思想完全可以运用到这道题上!

不需要把一种物品拆分成 \(K_i\) 份,而是只要物品拆分到能凑出 1 到 \(K_i\) 之间任意数量的程度就可以了。

可以证明,按照二进制的拆分能使件数达到最小,把 \(K_i\) 拆分成 \(1,2,4,\dots,2^t,K_i-2^{t-1}+1\color{yellow}\ \ \ (2^{t-2}>K_i\ge2^{t-1}\),就一定可以满足最优解要求了。下一步, 还是用 0-1 背包的经典算法。

如此,我们得到了一个时间复杂度为\(\texttt{O}(C*\Sigma(\log_2K_i))\)的算法。

FOR i: = 1 TO n
1 → m
WHILE K_i > 0
    IF m > K_i
        THEN K_i → m
    K_i - m → K_i
    FOR j: = C DOWNTO V_i * m
        MAX( F[j] , F[j-V_i*m]+W_i*m ) → F[j]
    END FOR
    m * 2 → m
    END WHILE
END FOR

此算法还能加上一个优化,判断如果 \(K_i\)大于 C/Vi,则多出的部分是没 有意义的,可以舍去。时间复杂度就可以优化到 \(O(n*C*\log_2C)\)。由于在高中阶段碰到的题中 C 的值很有限,所以这个算法在实际应用上的效果已经 可能满足一般的需求了。 但是在下一节中,有一个更高效的解决方法.

单调队列优化☆

长度限制最大连续和问题:给出长度为 n 的序列 \(X_i\),求这个序列中长度不超过 \(l_{\max}\)的最大连续和。

首先考虑最简单的做法,就是直接用 \(O(n*l_{\max})\)的二次循环求最大值

FOR i: = 1 TO n 
    0 → s 
    FOR j: = i DOWNTO Max( i-lmax+1 ,1) 
        s + Xj → s 
        IF s > ans THEN s → ans 
    END FOR 
END FOR 

\(S_i\)\(X_1\)\(X_i\) 的总和,就可以看到,如果确定一个端点后,要做的 就是在 S 数组的一个连续区间取一个最值,区间最值问题完全可以用线段树来实现。 但是这个题目的另一个特性是区间的长度是固定的,而且每个区间都 只需要取一次,所以我们可以用更简单的数据结构来实现——单调队列。

先来研究一下单调队列,以维护最大值为例,在满足序列中的编号递增以后,还要满足元素的值的递减。

L L+1 R-1 R
编号 \(A_L\) \(A_{L+1}\) \(A_{R-1}\) \(A_R\)
数值 \(B_L\) \(B_{L+1}\) \(B_{R-1}\) \(B_R\)

满足\(A_{i+1}>A_i>A_{i-1}\operatorname{and} B_{i-1}>B_i>B_{i+1}\ \ \color{yellow}(R>i>L)\)

单调队列除队列首元素出队列外,还需要用一定的操作来维护队列的 特殊性质。如果进入了一个新的元素(a,b),其中 a 必然大于 \(A_R\),但是 b 可 能会大于等于 \(B_R\)。既然 b 大于等于 \(B_R\),而元素 R 又是要先于新元素出队 列,那么元素 R 就已经失去价值,因为接下来新元素必然都会比元素 R 更 优。所以现在就可以删除元素 R 了。

重复上面的步骤,直到前面没有元素或者满足 \(B_R>b\) 为止,再让新元素 进队列。显然,当前的队列首元素,必定是这个区间的最大值

PROCEDURE INSERT a , b  
    WHILE R >= L AND b > B[ R ] 
        DO R - 1 → R 
    R + 1 → R 
    a → A[ R ]  
    b → B[ R ] 
END 

如此完成的单调队列,虽然不能保证每一次的操作是 O(1),但是因为 每个元素只进队列一次,并出队列一次,所以总效率是 O(n)。

当然,这道题其实就是单调队列的基本功能,而我们希望的是能把它用来优化背包问题,所以现在重新考虑 多次背包问题 。

对于第 i 种物品来说,已知体积 v,价值 w,数量 k,那么可以按照当 前枚举的体积 j 对 v 的余数把整个动规数组分成 v 份,以下是 v=3 的情况:

j 0 1 2 3 4 5 6 7 8
\(j \mod v\) 0 1 2 0 1 2 0 1 2

我们可以把每一份分开处理,假设余数为 d。

编号 j 0 1 2 3 4 5
对应体积 d d+v \(d+2*v\) \(d+3*v\) \(d+4*v\) \(d+5*v\)

现在看到分组以后,编号 j 可以从 j-k 到 j-1 中的任意一个编号转移而 来(因为相邻的体积正好相差 v),这看上去已经和区间最大值有点相似了。 但是注意到由于体积不一样,显然体积大的价值也会大于等于体积小的, 直接比较是没有意义的,所以还需要把价值修正到同一体积的基础上。比 如都退化到 d,也就是说用\(F[j*v+d]- j*w\) 来代替原来的价值进入队列。 对于第 i 件物品,转移伪代码

FOR d: = 0 TO v-1                         //枚举余数,分开处理 
    清空队列   
    FOR j: = 0 TO (C-d) div v             //j 枚举标号,对应体积为 j*v+d 
        INSERT j , F[ j*v+d ] – j * w     //插入队列 
        IF A[ L ] < j - k THEN L + 1 → L  //如果队列的首元素已经失效 
        B[ L ] + j * w → F[ j*v+d ]       //取队列头更新 
    END FOR 
END FO

已知单调队列的效率是 O(n),那么加上单调队列优化以后的多次背包, 效率就是 O(n*C)了。

三、 其他几类背包问题

1. 树形依赖背包(选课)☆

树形依赖背包问题:给定 n 件物品和一个背包。第 i 件物品 的价值是 \(W_i\) ,其 体积为 \(V_i\),但是依赖于第 \(X_i\)件物品(必须选取 \(X_i\)后才能取 i,如果无依赖则 \(X_i=0\)), 依赖关系形成森林,背包的容量为 C。可以任意选择装入背包中的物品,求装入背包中物品的最大总价值。

这道题需要在 treedp 的基础上用背包实现。

泛化物品——定义: 考虑这样一种物品,它并没有固定的费用(体积)和价值,而是它的价值随着 你分配给它的费用(体积)变化而变化。

泛化物品可以用一个一维数组来表示体积与价值的关系 Gj表示当体积 为 j 的时候,相对应的价值为 \(G_j\ \ \color{yellow}(C>=j\ge0)\)。 显然,之前的背包动规数组 \(F_i\),就是一件泛化物品,因为 \(F_i[j]\)表示的 正是体积为 j 的时候的最大价值。同样的,多件物品也是可以合并成一件泛化物品。

泛化物品的和:

把两个泛化物品合并成一个泛化物品的运算,就是枚举体积分配给两个泛化物 品,满足: \(G[j] = \max\{ G_1[j-k] + G_2[k] \}\ \ \color{yellow} (C\ge j\ge k\ge0)\)

把两个泛化物品合并的时间复杂度是 O(C^2)。

对于具有树形依赖关系的背包问题,我们可以把每棵子树看作是一个 泛化物品,那么一棵子树的泛化物品就是子树根节点的这件物品的泛化物 品与由根所连的所有子树的泛化物品的和。 整个动规过程就是从叶子进行到根,对于每一棵子树的操作就是:

PROCEDURE DEAL i , v , w      //第 i 个节点 体积为 v 价值为 w 
    FOR j: = v TO C           //初始化这件泛化物品 
        w → F_i[ j ] 
    END FOR 
    FOR s: = 1 TO n 
        IF s 是 i 的儿子 THEN 
        F_i 与 F_s 的和 → F_i 
        END IF 
    END FOR 
END

一次只能把两个泛化物品合并,那么要把 n 个泛化物品合并成一个就 需要 n-1 次合并,所以这个算法效率是 \(O(n*C^2+n^2)\) ,当然,其中的 \(O(n^2)\) 可以用邻接表的记边方法变成 \(O(n)\),最终的效率就是 \(O(n*C^2)\)

回顾经典 0-1 背包问题,在那个经典算法中,求泛化物品与一件物品 的和,只需要 O(C)的时间复杂度,推论出:

泛化物品与一件物品的和: 把一个泛化物品与一件物品合并成一个泛化物品,可以用类似于 0-1 背包经典动 规的方法求出。

\[G[j] = G_1[j] \ \ \color{yellow}{(v>j\ge0)}
\]

\[G[j] = \max\{ G_1[j] , G_1[j-v]+w \}\ \ \color{yellow}(C\ge j\ge v)
\]

这样的合并,时间复杂度仅为 O(C),同样也是合并了一件物品,效率 比求两件泛化物品的和快很多。那么有没有办法用这种 O(C)的合并方式来 代替计算两个泛化物品的和来处理这道题呢?

泛化物品的并: 因为两个泛化物品之间存在交集,所以不能同时两者都取,那么我们就需要求 泛化物品的并,对同一体积,我们需要选取两者中价值较大的一者,效率 O(C)。

\(G[j] = \max\{ G_1[j] , G_2[j] \} \color{yellow}\quad(C\ge j\ge0)\)

重新考虑对以 i 为根的子树的处理,假设当前需要处理 i 的一个儿子 s。

如果我们在当前的 \(F_i\)中强制放入物品 s 后作为以 s 为根的子树的初始 状态的话,那么处理完以 s 为根的子树以后,\(F_s\)就是与 \(F_i\)有交集的泛化物 品(实际上是 \(F_s\)包含 \(F_i\)),同时,Fs必须满足放了物品 s,即 \(F_s[j]\color{yellow}\quad (V_s>j\ge0)\) 已经无意义了,而 \(F_s[j]\color{yellow}\quad(C>=j\ge V_s)\)必然包含物品 s。为了方便,经过处理以 后,在程序中规定只有\(F_s[j]\color{yellow}\quad(C-V_s\ge j\ge0)\)是合法的。

接下来只需要把 \(F_s\)\(F_i\)的并赋给\(F_i\),就完成了对一个儿子的处理。如 此,我们需要的总时间复杂度仅为 O(n*C)。

PROCEDURE DEAL i , C 
    FOR s: = 1 TO n 
        IF s 是 i 的儿子 THEN 
            Fi → Fs 
            DEAL s , C – Vs            //背包容量减小 Vs 
            FOR K: =Vs TO C            //求两者的并 
                Max ( Fi[ k ] , Fs[ k-Vs ] + Ws ) → Fi[ k ] 
            END FOR 
        END IF 
    END FOR 
END

2. PKU3093☆

PKU3093:给定 n 件物品和一个背包,第 i 件物品的体积为 Vi,背包的容量为 C。 要求把一些物品放入背包使得剩下的物品都放不下,求方案数。

暂时先不考虑“使剩下的物品都放不下”的条件,那就是求 0-1 背包 的所有可行方案。 用 \(F_i[j]\)表示前 i 件物品中选若干件总体积为 j 的方案数,初始为 \(F_0[0]=1\),转移方程是

\[F_i[j] = F_{i-1}[j]\color{yellow}\quad (Vi>j)
\]

\[F_i[j] = F_{i-1}[j] + F_{i-1}[j-Vi]\color{yellow}\quad(j>=Vi)
\]

显然这个算法的效率是 O(n*C)的,它计算了所有装放背包的方案数。

现在考虑“使剩下的物品都放不进去”的条件,如果剩下的物品中体 积最小为 v,那么方案数就是 \(\text{sum }\{ F_n[j] \}\color{yellow}\quad(C\ge j>C-v)\)。前提是我们事先确定 了剩下中体积最小的是哪个。 对体积排序后,下一步就是枚举 i 作为剩余物品中体积最小的一件。

对 于所有 \(s<i\) 的物品必须都要放入背包,对于 i 则不能放入背包,对于 s>i 的 物品做 0-1 背包可行方案的统计,将 \(\text{sum}\{ F_n[j]\}\color{yellow}\quad(C\ge j>C-V_i)\)累加到 ans。 由于每次都需要对 n-i 件物品做统计,一共统计 n 次,效率是 \(O(n^2*C)\)

0 → sum                      //sum 记 1 到 i-1 的物品体积总和 
FOR i: = 1 TO N 
    F 数组清零 
    1 → F[ sum ]                //初始化 
    FOR s: = i + 1 TO N       //统计可行方案数 
        FOR j: = C DOWNTO Vs + sum 
            F[ j ] + F[ j-Vs ] → F[ j ] 
        END FOR 
    END FOR 
    FOR k: = C DOWNTO C - Vi + 1    //累加总方案数 
        IF k >= sum THEN ans + F[ k ] → ans
    END FOR
    sum + Vi → sum 
END FOR

可以发现,同一个物品多次被考虑放入背包,这样会造成时间的浪费。 观察得到,第 i 件物品共考虑了 i-1 次。每一次循环都会少一件物品。如果 把整个过程倒置,每件物品是否可以只考虑一次呢?

由于初始状态不一样,我们还需要把初始状态统一。可以让每次 \(F[0]=1\),总容量为 \(C-sum\)。 但是只统一初始化状态还不够,因为每次的背包容量还是不同的,做 背包统计的时候,背包容量不可以是一个变值,也必须要统一,所以每次 考虑一件物品都要用最大容量 C 来更新背包。 一次操作之后要将 \(\text{sum}\{ F[j] \}\color{yellow}\quad(C-sum\ge j>C-sum-V_i , j\ge 0)\)累加到 ans。 现在,每件物品都只考虑一次,背包体积统一是 C,那么效率就变成 了 O(n*C)。

0 → sum 
1 → F[ 0 ] 
FOR i: = 1 TO n 
    sum + Vi → sum 
END FOR 
FOR i: = n DOWNTO 1 
    sum - Vi → sum 
    FOR j: = C - sum DOWNTO  Max( C – sum – Vi + 1 , 0 )//累加总方案数 
        ans + F[ k ] → ans 
    END FOR 
    FOR j: = C DOWNTO Vi             //考虑第 i 件物品放入背包 
        F[ j ] + F[ j – Vi ] → F[ j ] 
    END FOR
END FOR

四、 总结

回顾全文的四道背包题:

  • 对于完全背包问题,用转化方程就解决了;
  • 对于多次背包,使用了单调队列优化来实现 \(O(n*C)\)的效率;在树形依赖背包问题中,探索了新的概念,最终完成算法的转化;
  • 在 PKU3093 一题中,通过合并相似 操作来达到优化的效果。 虽然用到的方法各不相同,每个方法都不仅仅限于背包问题,完全可以灵 活运用到其他问题中。
  • 文中提到的问题最后都用时间复杂度 O(n*C)的算法解决了,这并不是 说所有背包题都可以优化到这个程度,但是,可以肯定的是不会有比这个更快的 效率了。
  • 就目前来说,背包类的题目还有很多没有得到很好的解决,等待着大家去 继续探索研究。

版权声明:本文为sweepy原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/sweepy/p/pack.html