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)

  • 相关阅读:
    Kafka与Flume之集成比较
    Kafka之配置信息
    Kafka 之 Streams
    Kafka 之producer拦截器(interceptor)
    MySql(一)
    Android学习(一)
    Android学习(二)
    Android学习(三)
    Andoroid学习(四)
    Android学习(五)
  • 原文地址:https://www.cnblogs.com/bifanwen/p/13290049.html
Copyright © 2011-2022 走看看