zoukankan      html  css  js  c++  java
  • 再谈单调队列优化 & 背包九讲

    CSDN同步

    前置知识:

    这篇文章我们主要研究 单调队列优化 ( ext{dp}) 如何用于背包问题部分特殊背包问题的优化方式

    ( ext{01}) 背包问题

    (n) 个物品,背包体积为 (V),每个物品有 (v_i)(价值)和 (w_i)(重量),每个物品只有 (1) 个。求在不超过背包体积的情况下能获得的最大价值。
    (n,V,v_i,w_i leq 1 imes 10^3),时间限制 (1s),空间限制 (16MB).

    简单的考虑,用 (f_{i,j}) 表示前 (i) 个物品,背包体积为 (j) 的答案,则易得:

    [f_{i,j} = max(f_{i-1,j} , f_{i-1 , j-w_i} + v_i) ]

    这样可以在 (mathcal{O}(nV)) 的时间内解决问题。

    但是你发现空间只有 (16 ext{MB}),由于 (i) 的决策只取决于上一行同一列,所以可以直接降维,得:

    [f_j = max(f_j , f_{j-w_i} + v_i) ]

    注意 (j geq w_i) 的隐约限制。

    伪代码如下:

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

    (mathcal{O}(nV)) 是最优的算法。今天我们所要实现的,仅仅是,把所有的背包算法复杂度都降为 (mathcal{O}(nV)).

    倒叙枚举 - 小细节

    你可能会在代码里注意到一行:

    for (j=V;j>=w[i];j--)
    

    为什么是倒序枚举呢?它和

    for(j=w[i];j<=V;j++)
    

    等价吗?

    这是一个初学者常常犯的错误,这两种写法并不等价。可以运用这种写法去解决完全背包。

    如果你是正序枚举的话,你会发现,(i) 号物品会被重复使用很多次。比方说 (w_i = 2 , v_i = 3) 时,那么 (j=4) 就会从 (j=2) 转移过来,此时 程序已经把 (i) 物品用了 (2) 次,这样是不可取的。而反之在完全背包中则是可取的,因为完全背包问题是 无限个数 ,可以这样写。

    当然如果你倒序枚举的话,此时对于所有 (j = w_i , j = 2 imes w_i cdots cdots) 中,就只会更新 (j=w_i) 的,因为倒序的时候 (j)(j-w_i) 来,前一个还没有推出来就推导后一个,肯定没有答案——这样正好是我们所求的。而 (j=w_i) 有答案的原因就是因为本身的一次更新。

    之后遇到的各种 ( ext{dp}) 都会出现类似此类的问题,大家一定要小心!

    完全背包问题

    (n) 个物品,背包体积为 (V),每个物品有 (v_i)(价值)和 (w_i)(重量),每个物品有无限个。求在不超过背包体积的情况下能获得的最大价值。
    (n,V,v_i,w_i leq 1 imes 10^3),时间限制 (1s),空间限制 (16MB).

    这样你会发现一个问题,你不知道有多少个!

    当然你可以用 (lfloor frac{V}{v_i} floor) 来表示数量,考虑枚举。

    同样的 (f_{i,j}),我们可以知道:

    [f_{i,j} = max_{k=0}^{lfloor frac{V}{v_i} floor}(f_{i-1,j-k imes w_i} + k imes v_i) ]

    大前提是 (j geq k imes w_i),这样转移的复杂度是 (mathcal{O}(nVw_i)),降维之后应该可以通过。

    考虑一个简单的加强:

    (n,V,v_i,w_i leq 1 imes 10^4),空间限制 (128MB).

    我们将在下面的研究中,解决这个问题。

    多重背包问题

    (n) 个物品,背包体积为 (V),每个物品有 (v_i)(价值)和 (w_i)(重量),每个物品有 ( ext{num}_i) 个。求在不超过背包体积的情况下能获得的最大价值。
    (n,V,v_i,w_i leq 1 imes 10^4),时间限制 (1s),空间限制 (128MB).

    显然我们按照完全背包的方法就可以了,但是 (mathcal{O}(nVw_i)) 是不可能通过 (10^4) 的!所以我们需要考虑,如何优化这个问题。

    实际上不用单调队列也可以优化,我们先来讲著名的背包优化的几个方法。

    二进制拆分

    二进制!这是个熟悉的东西。

    实际上我们背包的瓶颈在于两个:背包物品过多,背包容积过大,背包物品数量过多。

    容积过大可能会想到离散,但是这不是排序之类只用知道大小的问题啊!容积是不能突破的,考虑优化物品个数。物品个数怎么可能优化呢?数量?

    那你可能会说,这和二进制又有什么关系?

    下面我们来说一说吧。

    用二进制表示数

    用集合 (s) 表示二的幂次集,则 (s = { 2^0 , 2^1 cdots 2^{infty}}).每个正整数都可以用若干个 (s) 集合内的元素相加而成。就是说每个数一定能被分解成若干二的幂次的数之和。

    (10 = 2 +8)
    (100 = 64 + 32 +4)
    (1000 = 512 + 256 + 128 + 64 + 32 + 8)

    如何证明?

    显而易见,每个数都能被二进制表示。比方说 (10_{10} = 1001_2).

    那么,用计数原则,(1001_2 = 1 imes 2^0 + 0 imes 2^1 + 0 imes 2^2 + 1 imes 2^3)

    这样就证明结束了,你没有发现吗?

    拆分原理

    将一个 ({ v_i , w_i , num_i}) 的物品,通过 (num_i) 进行拆分。

    本来假设一个物品有 (7) 个,你可以把它拆开成 (1,2,4) 个,对这三个物品进行 (01) 背包,你就可以得到 (1 - 7) 所有可能的答案。最后你把不取的答案单独做一遍就可以了。

    原理就是, (n) 最多被拆成 (log n) 个二的幂次的数之和,这样保证了时间复杂度是 (mathcal{O}(nV log w_i)) 的。

    伪代码
    for(i=1;i<=n;i++) {
    	read(x) , read(y) , read(z);
    	for(j=1;j<=z;j<<=1) v[++cnt]=x*j,w[cnt]=y*j,z-=j;
    	if(z) v[++cnt]=x*z,w[cnt]=y*z;
    }
    // use array v and w to do 01 backpack
    

    单调队列

    显然,(10^4) 的数据可以把二进制拆分卡死。我们需要严格去掉 (log).

    再看一眼这个状态转移:

    [f_{i,j} = max_{k=0}^{ ext{num}_i}(f_{i-1,j-k imes w_i} + k imes v_i) ]

    你会发现,这不是一段连续的决策!这是断续的。

    但是,你会发现这样一个问题。

    (f_{i-1 , j} , f_{i-1 , j - w_i} , f_{i-1 , j - 2 imes w_i} cdots f_{i-1 , j - ext{num}_i imes w_i}),这是所有 (f_{i,j}) 的决策点。

    你会发现,这些决策点是同行等差列的,每两个决策点隔着一个 (w_i). 这非常好!

    余数构造连续决策

    你会发现,你可以把 (V) 按照模 (w_i) 进行分类,余数相同的肯定是一类的。

    那样我们就可以把这一段当成连续的决策去做,因为实际上枚举余数 ( ext{mo}) 和当前周期编号 (k). 因为 (k) 是连续的,那么 ( ext{mo} + k imes w_i) 实际上就是当前的决策点。这样我们构造出了若干段连续的决策。

    时间复杂度分析

    (n) 个物品每次做一次,所以 (mathcal{O}(n)).

    然后同时枚举余数和 (k),因为 ( ext{mo} imes k leq V),所以本质上这两重循环的 (sum) 不会超过 (V),是 (mathcal{O}(V)).

    按照单调队列每个节点进出一次的原理,(mathcal{O}(nV)) 的目标已然达成。

    for(i=1;i<=n;i++) {
    		read(v) , read(w) , read(num); 
    		num=min(num,V/v);
    		for(mo=0;mo<v;mo++) {
    			l=r=0;
    			for(k=0;k<=(V-mo)/v;k++) {
    				x=k,y=f[k*v+mo]-k*w;
    				while(l<r && q[l].pos<k-num) l++;
    				while(l<r && q[r-1].val<=y) r--;
    				q[r].val=y,q[r++].pos=x;
    				f[k*v+mo]=q[l].val+k*w;
    			}
    		}
    	}
    

    完全背包问题 - 单调队列

    这里我们发现,直接把 (lfloor frac{V}{v_i} floor) 作为 ( ext{num}) 就可以直接通过。这样我们实现了完全背包和多重背包的 (mathcal{O}(nV)).

    下面,有了多重背包和完全背包的基础,我们将来解决更套路性的问题。

    混合背包问题

    (n) 个物品,背包体积为 (V),每个物品有 (v_i)(价值)和 (w_i)(重量),每个物品的个数用符号 (t) 表示。(t=-1) 表示该物品只有 (1) 个,(t=0) 表示该物品有无限个,(t>0) 表示该物品有 (t) 个。求在不超过背包体积的情况下能获得的最大价值。
    (n,V,v_i,w_i leq 1 imes 10^4),时间限制 (1s),空间限制 (128MB).

    很显然,我们可以把 (01) 背包的物品看成是 ( ext{num}_i = 1) 的多重背包物品,完全背包的物品看成是 ( ext{num}_i = lfloor frac{V}{v_i} floor) 的多重背包物品,最后用 (mathcal{O}(nV)) 跑一遍多重背包即可。

    代码略。

    二维费用背包问题

    (n) 个物品,背包体积为 (V),所能承受的最大重量为 (W).每个物品有 (v_i)(价值)和 (w_i)(重量)和 (g_i)(体积)。求在不超过背包体积 且 不超过最大载重的情况下能获得的最大价值。(每个物品只有 (1) 个)
    (n,V,v_i,w_i leq 5 imes 10^2),时间限制 (1s),空间限制 (128MB).

    显然,我们出现了另一层限制,使得我们无法用 (mathcal{O}(nV)) 的时间解决。实际上时间限制也暗示了这一点。我们应该从头开始,重新推导。其实大多过程是类似的。

    基础推导过程

    (f_{i,j,k}) 表示前 (i) 个物品,体积为 (j),载重为 (k) 的最大价值。

    那么可得:

    [f_{i,j,k} = max(f_{i-1,j,k} , f_{i-1,j-g_i , k - w_i} + v_i) ]

    按照这个方式,(mathcal{O}(nVW)) 足以解决 (5 imes 10^2) 的数据。

    伪代码如下:

    for(i=1;i<=n;i++) {
    	read(g) , read(w) , read(v);
    	for(j=V;j>=g;j--)
    	for(k=W;k>=w;k--)
    		f[j][k]=max(f[j][k],f[j-g][k-w]+v); //降维同 01 背包操作
    }
    

    拓展二维背包问题

    假设,每个物品有 ( ext{num}_i) 个,其余按照二维背包条件不变,(5 imes 10^2) 仍不变,如何追求一个 (mathcal{O}(nVW)) 的算法?

    实际上单调队列仅仅是让我们 完全地把背包的复杂度寄托于物品的个数与限制条件,而与具体数值大小无关,所以单调队列是可以做到的。

    [f_{i,j,k} = max_{l=0}^{ ext{num}_i} (f_{i-1,j-l imes w_i , k-l imes g_i} + l imes v_i) ]

    但是,这个你和一维的背包问题比较一下呢:

    [f_{i,j} = max_{k=0}^{ ext{num}_i}(f_{i-1,j-k imes w_i} + k imes v_i) ]

    一维背包的周期是 (w_i).按照这个说法,二维背包的周期应该是 (operatorname{lcm}(w_i , g_i)),可以认为是 (w_i imes g_i).

    我们需要同时枚举两个模数 ( ext{mo1})( ext{mo2}),用 ( ext{mo1} imes w_i + ext{mo2}) 来表示当前节点,然后用单调队列做即可。

    时间复杂度仍然是 (mathcal{O}(nVW)),一个道理,每个节点入队出队一次。

    但是严格意义上这个做法是不对的。为什么呢?

    拓展多维背包问题

    在拓展二维的基础上,把每个物品的限制加到 (k) 个,能否做到 (mathcal{O}(nVW))?答案是不能。(mathcal{O}(nVWk))?这当然可以。

    这样对于 (k) 个限制的相乘,单调队列的优化已经基本无用。因为在完全随机的,(k geq 3) 的情况之下,(k) 个数的乘积很容易就超过了 (max_{i=1}^n w_i) 使得优化失败,无法进行单调队列操作。

    所以,(2) 维也是一个道理,两个随机的 (leq 5 imes 10^2) 的数相乘超过 (5 imes 10^2) 的概率很大。一旦出现这样的物品,我们的单调队列等同于大暴力。而大暴力的复杂度恰恰是 (mathcal{O}(nVWk)),对于 (k) 个限制还需要进行循环判断,常数较大。

    所以,一般的题目会给定 (k) 的值(并且一般来说 (k leq 3)),把 (k) 当做常数来看!

    分组背包问题

    (n) 个物品,背包体积为 (V).每个物品有 (v_i)(价值)和 (w_i)(重量),只有 (1) 个。
    每个物品属于第 (s_i) 组。求不超过背包体积 且 每组只选一个物品的最大价值。
    (n,V,v_i,w_i leq 1 imes 10^3),时间限制 (1s),空间限制 (128MB).

    很明显,我们应该考虑 ( ext{dp}) 的推导。用 (f_{k,j}) 表示前 (k) 组物品重量为 (j) 所得的最大价值,易得:

    [f_{k,j} = max(f_{k-1,j} , max_{s_i=k} f_{k-1,j-w_i} + v_i) ]

    显然我们已经得到了一个 (mathcal{O}(n^2V)) 的算法,因为最大组数和 (n) 是同阶的,就算成 (n) 吧。

    这里是没有办法优化的,因为 (w_i) 不连续,单调队列不行。

    伪代码如下:

    for(i=1;i<=n;i++) {
    	read(s);
    	for(j=1;j<=s;j++) read(w[j]) , read(v[j]);
    	for(j=V;j>=0;j--)
    	for(k=1;k<=s;k++)
    		if(j>=w[k]) f[j]=max(f[j],f[j-w[k]]+v[k]);
    }
    

    有依赖的背包问题

    这一道题目将是背包问题中最难的一种。

    (n) 个物品,背包体积为 (V).每个物品有 (v_i)(价值)和 (w_i)(重量),只有 (1) 个。
    每个物品有一个依赖物品 (p_i),如果选了 (i) 物品,则必须选 (p_i) 号物品。求不超过背包体积的最大价值。数据保证 每个物品最多依赖于一个物品。(注:因此此类题常常用树形结构给出,儿子节点和父节点呈依赖关系)
    (n,V,v_i,w_i leq 1 imes 10^3),时间限制 (1s),空间限制 (128MB).

    显然,这种有依赖的背包并不容易,这和树形 ( ext{dp}) 有些类似。简单的,如果选了 (i),所有 (i) 的祖先都会被选。

    一个非常显然的思路是,直接把所有子树的答案计算,合并即可。对于每一个子树,先不考虑根节点,进行背包,然后最后加上根节点的值即可。(因为根节点无论如何会被选,除非全不选)

    代码略。

    泛化物品

    背包中比较玄乎的东西,选看选做。

    泛化物品的精髓就是,每个物品没有固定的价值和重量,其价值随着分配的重量而变化。简单的,你用 (x) 的重量去放它,它就会产生 (h_x) 的价值贡献。而 (h_x) 的具体计算会给你一个多项式。这是非常好的一类问题。这一类问题的解决相当繁琐。

    比方说吧,若背包容量为 (10)(h_1 = 3x + 2),一个物品 (h_2 = 2x+3),显然你直接放 (1)(h1),价值就可以达到 (32)。这意思不是说贪心可以解决背包,而是这类问题不好解决。

    简单的透露一下,该问题的复杂度应当是 (mathcal{O}(V^2)),具体内容可以参考文末的 泛化物品_百度百科.

    求方案数 & 具体方案

    非常简单,只需要再开一个,算出 (f) 后,用 与其同样维数的 (g) 记录当前最优解的方案数。只需要把所有的决策点的 (g) 统计一遍即可得到新的 (g).

    求具体方案也一样,一般来说会让你求字典序最小的,那么你用 (f) 同样维数的 (g) 记录当前最优解且字典序最小的 那一个编号,这样就可以解决问题。

    如果让你求全部的最优方案,你需要开一个 ( ext{vector})(f) 维数一样,类似于 vector<int> h[N][M] 的操作,开二维数量个 ( ext{vector}),记录所有答案,最后迭代返回。

    由于方案数的增加呈指数性,一般只会求特殊的具体方案 / 方案数取模。

    附录:背包问题的搜索解法

    以最简单的 (01) 背包为例,如何用朴素搜索解决?

    大力搜索

    枚举每个物品选或不选,这样复杂度是 (mathcal{O}(2^n)) 的,可以加上剪枝(最主要的:就是当前答案加上所有未选的价值之和也不如之前得到的答案,或者是重量超出限制),但也只能 勉强 通过 (n=25) 的数据。

    一个基于贪心的优化,将重量小 / 价值大 / 性价比高的物品先搜索,会适当提高效率。但是哪怕剪枝到极限,最多也是 (n=26,27) 的样子。

    ( ext{NP}) 完全问题

    考虑这是一个 ( ext{NP}) 完全,所以从模板题出发:

    给定集合 (S),求是否存在集合 (X) 使得 (X) 中的元素之和为 (k). (|S| leq 42).

    显然大力枚举 (2^{42}) 是不可能通过的。我们考虑一个简单的优化。

    (S) 等分 为两部分 (S1)(S2),然后枚举 (S1)(S2) 内所有的可能,并两两相加验证。这样复杂度是 (mathcal{O}(2^{lfloor frac{n}{2} floor})) 的,可以通过 (n=42) 的数据。

    当然作者可以自行尝试分更多份,实际上大概率效率不会更高。

    折半搜索

    实际上上面解决 ( ext{NP}) 完全的方法就是折半搜索的精髓了,对两边分别大力搜索即可实现 (mathcal{O}(2^{lfloor frac{n}{2} floor})) 的复杂度。

    搜索 ( ext{VS DP})

    一般来说,设计到多重,完全,肯定是 ( ext{dp}).只有 (01) 背包需要进行讨论!

    如果 (w_i,V leq 10^{16} , n leq 40),那明显是折半搜索。

    如果 (w_i,V,n leq 5 imes 10^3),那明显是 ( ext{dp}).

    总之,面向数据编程是没有毛病的,具体情况具体分析吧。

    课后习题

    ( ext{Acwing}) 上面 (9) 道题,( ext{洛谷})(1) 道,一共 (10) 道不算多吧)

    ( ext{01}) 背包

    完全背包

    多重背包 - 单调队列

    多重背包 (1) - 大暴力

    多重背包 (2) - 二进制拆分

    多重背包 (3) - 单调队列

    混合背包

    二维费用背包

    分组背包

    有依赖的背包

    参考资料

    泛化物品_百度百科43693379/article/details/89432283)

  • 相关阅读:
    Apache Ant 1.9.1 版发布
    Apache Subversion 1.8.0rc2 发布
    GNU Gatekeeper 3.3 发布,网关守护管理
    Jekyll 1.0 发布,Ruby 的静态网站生成器
    R语言 3.0.1 源码已经提交到 Github
    SymmetricDS 3.4.0 发布,数据同步和复制
    beego 0.6.0 版本发布,Go 应用框架
    Doxygen 1.8.4 发布,文档生成工具
    SunshineCRM 20130518发布,附带更新说明
    Semplice Linux 4 发布,轻量级发行版
  • 原文地址:https://www.cnblogs.com/bifanwen/p/13290049.html
Copyright © 2011-2022 走看看