【20170923】2017暑假北京学习 day 3 - 1 动态规划之背包问题
前言:
背包问题是动态规划(DP)中一类很基础的模型,也正是由于它的基础性,成为 noiper 训练【构建DP模型、寻找DP方程、写DP代码】的一类不错的练手题。
一、01背包
-
问题引入:Luogu 1048 采药
- 辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:“孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”
- 考虑使用动态规划 (DP)算法:只记录状态的最优值,并用最优值来推导出其他的最优值。
- 状态设计:记录 F[i][j] 为—— 已经【决定前 i 件物品】的情况,在【总重量为 j 】的情况下,物品总价值的最大值。同样也是有两种方法可以推导:
@ 顺推:
“我这个状态的下一步去哪里”:我现在要决定下一件物品取还是不取。
> 如果不取的话,可以达到状态 F [i+1] [j];
> 如果取的话,可以达到状态 F[i+1] [ j + w [i+1]](需要满足重量约束);
1 // T6 : 采药( DP/顺推)
2 for(int i = 0; i < n; ++ i)
3 for(int j = 0; j <= t; ++ j)
4 {
5 // 不取
6 f[i+1][j] = max(f[i+1][j], f[i][j]);
7 // 取
8 if(j + w[i] <= t)
9 f[i+1][j+w[i]] = max(f[i+1][j+w[i]], f[i][j]+v[i]);
10 }
11 // 答案
12 ans = 0;
13 for(int i = 0; i <= t; ++ i)
14 ans = max(ans, f[n][i]);
@ 逆推:
“从什么状态可以到达我这里” :考虑我这件物品取不取。
> 如果是不取的,那可以从 F [i-1][j] 推导而来;
> 如果是取的,可以从 F [i-1][j - w [i]] 推导而来的(同样需要满足重量约束)。
1 // T7 : 采药( DP/逆推)
2 for(int i = 1; i <= n; ++ i)
3 for(int j = 0; j <= t; ++ j)
4 {
5 // 不取
6 f[i][j] = f[i-1][j];
7 // 取
8 if(j >= w[i]) f[i][j] = max(f[i][j], f[i-1][j-w[i]] + v[i]);
9 }
10 // 答案
11 ans = 0;
12 for(int i = 0; i <= t; ++ i)
13 ans = max(ans, f[n][i]);
//====顽皮的分割线============================//
* 总结:
01背包问题的【特点】 是,每个物体 i 有且仅有一份,不可分割,要求我们在某一条件A (如 体积/重量/etc)的限制下,对物体做出 【“选”- 1】 或 【“不选”- 0】的抉择,以取得某种条件B(如 价值)的最值 。
简单来说:【”不可分,限重量,价值最大“】
惯用的解题模式为:
- 状态设计:以 F[i][j] 代表【已经完成了对 i 个物体的抉择】【目前 条件A 数值为 j 】下的 条件B 的最值,
- 循环条件:以【物品数量 i】为基本枚举量,用【条件A】作为判断边界的条件,最后以【完成对所有物体做出抉择】后的答案最值 来得出 【条件B】的最值。
- 通用DP方程:
正推:
1 F[i+1][j] = max( F[i][j] , F[i+1][j] ) ; //不取第i个
2 if (/*条件A不超出限制*/)
3 F[i+1][j+A[i]] = max( F[i][j]+B[i] , F[i][j+A[i]] ) ; //取第i个
逆推:
1 F[i][j]=max( F[i-1][j] , F[i-1][j-A[i]]+B[i]);
//====顽皮的分割线============================//
二、完全背包
* 问题引入:POJ 1384 Piggy-Bank
现在有 n 种硬币, 每种硬币有特定的重量 cost[i] 和它对应的价值 val[i]. 每种硬币可以无限使用。
已知现在一个储蓄罐中所有硬币的总重量正好为 m 克, 问你这个储蓄罐中最少有多少价值的硬币?
如果不可能存在 m 克的情况, 那么就输出 “ This is impossible.”
* 我们也把这类问题归入到背包问题当中: 【“有物品,重量限制,价值最大”】。
* 但与采药问题不同的是,每件物品可以无限使用。
* 转移方程:状态之间的推导公式
- 当前状态: F[i][j] 为, 已经决定前 i 件物品的情况,在总重量为 j 的情况下,物品总价值的最大值。
@ 顺推:
“我这个状态的下一步去哪里” :考虑我这件物品取多少件。
> 如果是不取的,那可以推导到 F[i+1][j];
> 如果是取一件,那可以推导到 F[i+1][j + w[i]];
> 如果是取 k 件,那可以推导到 F[i+1][j + w[i]*k] . //此处用一个for来枚举k
1 // T8 : Piggy-Bank( DP/顺推)
2 // 初值处理:由于问题求的是最小值,所以先把所有状态赋值为最大值
3 for(int i = 1; i <= n + 1; ++ i)
4 for(int j = 0; j <= m; ++ j) f[i][j] = INF;
5 // 第一件还没取,重量为 0
6 f[1][0] = 0;
7 for(int i = 1; i <= n; ++ i) // i:已经决定的物品
8 for(int j = 0; j <= m; ++ j) // j:总重量
9 for(int k = 0; j + w[i] * k <= m; ++ k) // k:这件物品取多少件
10 {
11 f[i+1][j+w[i]*k]=
12 min( f[i+1][j+w[i]*k], f[i][j] + p[i]*k );
13 }
14 // w 重量; p 价值
@ 逆推:
“从什么状态可以到达我这里” :考虑我这件物品取多少件。
> 如果是不取的,那可以从 F[i-1][j] 处推导得到;
> 如果是取一件,那可以从 F[i-1][j - w[i]] 处推导得到;
> 如果是取 k 件,那可以从 F[i-1][j - w[i]*k] 处推导得到;
1 // T9 / work1 : Piggy-Bank( DP/逆推)
2 for(int i = 0; i <= n; ++ i)
3 for(int j = 0; j <= m; ++ j) g[i][j] = INF;
4 g[0][0] = 0;
5 for(int i = 1; i <= n; ++ i)
6 for(int j = 0; j <= m; ++ j)
7 for(int k = 0; j >= w[i] * k; ++ k)
8 g[i][j] = min( g[i][j], g[i-1][j-w[i]*k] + p[i]*k );
####【补充重点】########################################
*逆推优化——
>未优化的DP代码
*观察和逆推相关的状态。
-假设 w[i]=3,
>则 F[i][6] 与 F[i-1][0,3,6] 相关;F[i][7] 与F[i-1][1,4,7] 相关;
>与此同时, F[i][3] 与 F[i-1][0,3] 相关;F[i][4] 与 F[i-1][1,4] 相关。
如下图:
* 则可以得到, 实际上与 F[i][j] 相关的状态只比 F[i][j - w[i]] 多一个。
* 所以我们可以这样推导:
> 如果是不取的,那可以从 F[i-1][j] 处推导得到;
> 如果是取一件或更多,那可以从 F[i][j-w[i]] 处推导得到; (因为是可以取任意件,所以从 F[i] 中取最优而不是从 F[i-1] 中取)
从而减少我们在计算黄色格子时所需的计算量(如图中红线条数)。
>优化后的DP代码
* 而从这种逆推也可以方便的写出数组压缩。
*数组压缩
根据以上两幅图,我们发现,当计算某一个F[i]时,我们只需要知道F[i]正上方and正左方的元素值,
∵只需要知道正上方的元素,∴两行数组可以合并成一行;
∵只需要知道正左方的元素,∴循环时i应从左向右枚举。
>优化+数组压缩后的DP
Warning:这种数组压缩只能用于 完全背包模型。
* 一般来说,使用 二行滚动数组 所节省的空间已经足够,不需要再优化为难以调试的 一行数组 。
二行滚动数组简易代码:f[i][] ==改为==> f[i&1][]
####################################################
* 总结:
完全背包的特点是:【“有物品、重量限制,价值最大”】
与01背包的不同点在于,完全背包还需要对每种物品选多少个进行抉择,从而朴素的完全背包代码比01背包的代码多一层for循环。
//====顽皮的分割线============================//
三、背包计数问题
——简单背包计数
* Luogu 1466 : 集合
-
- 对于从 1 到 N (1 <= N <= 39) 的连续整数集合,能划分成两个子集合,且保证每个集合的数字和是相等的。举个例子,如果 N=3,对于[1, 2, 3] 能划分成两个子集合,每个子集合的所有数字和是相等的:
- - [3] 和 [1,2]
- 这是唯一一种分法(交换集合位置被认为是同一种划分方案,因此不会增加划分方案总数)如果 N=7,有四种方法能划分集合 [1, 2, 3, 4, 5,6, 7],每一种分法的子集合各数字和是相等的:
- [1,6,7] 和 [2,3,4,5] (注: 1+6+7=2+3+4+5)
- [2,5,7] 和 [1,3,4,6]
- [3,4,7] 和 [1,2,5,6]
- [1,2,4,7] 和 [3,5,6]
- 给出 N,你的程序应该输出划分成两个集合的方案总数,如果不存在这样的划分方案,则输出 0。程序不能预存结果直接输出(不能打表 '> n <')<
- 物品:可以把所有的数字看作物品。对于数字 i,其对应的重量为 i,则我们需要求出装满载重为 M 的背包的方案数(其中 M 为所有数总和的一半)。
- 状态:(仿照之前的方法)设 F[i][j] 为已经考虑完数字 1-i 了,当前数字总和为 j 的总方案数。
- 状态转移方程 - 顺推:考虑有没有取数字 i。
- 没取: F[i+1][j] += F[i][j]
- 取了: F[i+1][j+i] += F[i][j] (j+i<=M)
- 状态转移方程 - 逆推:考虑有没有取数字 i。
- F[i][j] = F[i-1][j] + F[i-1][j-i] (j>=i)
——完全背包计数
* Luogu 1474 : 货币系统
- 母牛们不但创建了它们自己的政府而且选择了建立了自己的货币系统。由于它们特殊的思考方式,它们对货币的数值感到好奇。
- 传统地,一个货币系统是由 1,5,10,20 或 25,50, 和 100 的单位面值组成的。
- 母牛想知道有多少种不同的方法来用货币系统中的货币来构造一个确定的数值。
- 举例来说, 使用一个货币系统 [1,2,5,10,...] 产生 18 单位面值的一些可能的方法是:18x1, 9x2, 8x2+2x1, 3x5+2+1, 等等其它。写一个程序来计算有多少种方法用给定的货币系统来构造一定数量的面值。
- 物品:可以把所有的货币看作物品。对于每种货币,其对应的重量为它的面值(注意到与前一道题目相比,每个物品是可以取任意件的)。
- 状态:(仿照之前的方法)设 F[i][j] 为已经考虑完前 i 种货币了,当前钱的总和为 j 的总方案数。
- 状态转移方程 - 逆推:考虑货币 i 取了多少件。F[i][j] = ∑ F[i-1][j - w[i]*k]
//====顽皮的分割线============================//
多重背包和完全背包类似,只是在物品个数上有选择限制,我就不详述了。
//====顽皮的分割线============================//
四、单调队列优化多重背包
朴素的多重背包算法 需要多出一个for来进行物品数量的枚举(1 to n[i]),而当n[i]很大的时候,这个for对程序运行效率的影响是不容小觑的,急需一个良好的方法来解决这个问题。
* 方法1. “二进制分解”来存储物品个数
如15=>1+2+4+8
18=>1+2+4+8+3
大家会疑惑了:17是怎么分解成这样的呢?
- 我们要明白“二进制分解”的目的:
多重背包复杂性在于对每种物品进行的n[i]次的枚举,而我们将n[i]进行“二进制分解”得到 P ={p1,p2,p3...}(元素个数<=logn),使得P内元素的任意组合能够组成1<= <=n[i]的任意自然数,从而转化为01背包进行处理,能够节省大量时间。
(即将原来需要n[i]次的循环操作,降低到logn附近)
- 接下来揭示如何对一个数n=18进行“二进制分解”:
2^0=1,18-1=17,于是我们挑出数1;
2^1=2,17-2=15,于是我们挑出数2;
2^2=4,15-4=11,于是我们挑出数4;
2^3=8,11-8=3,于是我们挑出数8;
2^4=16,而3<16,于是我们挑出数3;
综上,n=18被分解为1,2,4,8,3.
证明:由于数列中含1,2,4,8,根据二进制数的性质,我们可以自由组合出1<= <=(1+2+4+8=15)的任意自然数;
由于数列中含3,结合上一行结果,我们可以自由组合出(1+3=4)<= <=(15+3=18)的任意自然数;
∴我们可以自由组合出1<= <=18的任意自然数。
- 这样分解以后,我们就可以肆无忌惮地把多重背包当做01背包来写方程了。
* 方法2. 单调队列优化
- 【定理】形如d[i]=max(f[k])+g[i] ( k<i , g[i]与k无关 , max可改为min )的DP方程,都能使用单调队列优化,将转移复杂度降为O(1)。其中f[k]可能与d[1...k]有关,并且f[k]与g[i]可在O(1)内算出。(此处不包含单调队列的维护开销)
- 知道单调队列如何工作后,上述定理便不难证明。于是我们用单调队列优化该dp方程(多重背包)。
- 多重背包的DP方程:
- f[i][v]=max( f[i-1][ v-d*c[i] ]+d*w[i] ) 1<=d<=min( n[i] , v/c[i] )
- f[i][v]=max( f[i-1][ v-d*c[i] ]+d*w[i] ) 1<=d<=min( n[i] , v/c[i] )
- 其中前i种物品,背包容积v,单个物品体积c[i],价值w[i],共n[i]件。
- 用滚动数组优化i这维的空间后,在每一轮中c,w,n是固定的,得新方程:
- f[v]=max(f[v-d*c]+d*w) 1<=d<=min(n,v/c)
- f[v]=max(f[v-d*c]+d*w) 1<=d<=min(n,v/c)
- 若要用单调队列优化,则需要将新方程转化为【定理】中的形式——分离常数g(很开心终于能用上我熟悉的数学语言了)
为了让求最大值的区间连续,简化计算,可令m = v/c , r = v mod c .
即f[v]=f[m*c+r]=
= max(f[(m-d)*c+r]+d*w), 1<=d<=min(n,m) ,令k = m-d得
= max(f[k*c+r]+m*w-k*w), m-n <= k < m
= max(f[k*c+r]-k*w)+m*w, m-n <= k < m
记D[m] = f[m*c+r], F[k] = D[k]-k*w, G[m] = m*w
于是D[m] = max(F[k]) + G[m] (m-n <= k < m)
此式与定理的区别仅为下限m-n。用单调队列排除不满足条件的k值即可。
以上科学而严谨的数学证明实在不是我这种蒟蒻能做出来的,没错我引用了百度贴吧上某位dalao的回答,但一时找不到ID了,若侵权请原po速联系我{ > u < }
//====顽皮的分割线============================//
五、总结的总结
- 解决动态规划问题,需要注意以下几点:
- 顺序/状态:与搜索类似,依然需要考虑问题的先后顺序,以及状态的表示
- 转移方程:有顺推与逆推两种;需要注意初值、边界情况。
- 同时在实现背包问题中,可以使用数组压缩来优化空间。