动态规划(dynamic programming)之背包问题
(每一小节用________符号隔开。)
错误笔记&&个人感想
_________________________________错误笔记&&个人感想______________________________________
1. 算法要注意数据的初始化。(尽管全局数组会被初始化为0,但是处理多组数据的时候需要注意。memset(),fill(),初始化要注意注意再注意,如果不确定memset&&fill的用法,可以用for循环。)
2. 注意i和j的区别,别一不小心看错了,dp数组就完了。
3. 注意for循环的范围。不行的话就多试几个范围。0~n-1 && 1~n
4. 背包问题的for循环十分巧妙,得多多思考。
5. 个人感觉为了偷懒,只用理解记忆练习一维数组形式的01背包和完全背包形式即可。但是多重背包问题往往是比较复杂的,不可生搬硬套。
6. 要注意,输入数据的结果要跟算法对应上,不要拿01背包的结果去对应完全背包的结果。
7.全篇有一些许部分是看挑战程序竞赛的读书笔记,部分借鉴,不过原话很少。
_______________________________________________________________________________
01背包问题
目录:
1. 01背包基础含义
2. dp[i][j]数组含义
3. 状态转移方程思路来源
4. 01背包问题代码
5. 滚动数组优化01背包及其代码
6. 一维数组实现01背包原理 && 一维数组实现01背包代码
7. 重量总和W超大时的01背包处理问题(1<=W<=10^9)
_______________________________________________________________________________
完全背包问题
目录:
1. 基础含义
2. 完全背包问题代码
3. 滚动数组优化完全背包原理及其代码
4. 完全背包的变形
_______________________________________________________________________________
多重背包问题
目录:
1. 多重部分和问题
2. 最长上升子序列问题
3. 划分数
4. 多重集组合数
_______________________________________________________________________________
______________________________01背包基础含义___________________________________
给定 n 种物品:物品 i 的重量是 wi,其价值为 vi; 和一个容量为 W 的背包。
当前数据范围:1<=n<=100 1<=wi,vi<=100 1<=W<=10000
________________________*****需要涉及一个二维dp数组*****______________________
dp[i][j]
此数组的i代表的是种类,由n控制。(n种物品)
j代表的是背包容量,由W来控制。
整体dp[i][j]想要表达的意思是:面对第i件物品,且背包容量为j时所能获得的最大价值。
___________________________状态转移方程思路来源________________________________
针对这个数组基础上,有三种方法,开始建立状态转移方程。
(PS:因为方法有很多种,大概了解这三种基本上后面的另外的代码,看着内容就可以脑补出内容来了。)
(在之后的代码里,dp数组用的m数组来表示。)
1. 从第i个物品开始挑选容量小于j时,总价值的最大值。
dp[i][j]=dp[i+1][j] (j<w[i])
dp[i][j]=dp[i][j] = max(dp[i + 1][j], dp[i + 1][j - w[i]] + v[i]) (j >= w[i])
2. 从0到i这i+1个物品中选出总重量不超过j的物品时总价值的最大值。(PS:这个方法是第一种方法的正向进行推导,)
dp[i + 1][j] = dp[i][j]; (j<w[i])
dp[i + 1][j] = max(dp[i][j], dp[i][j - w[j]] + v[i]); (j >= w[i])
3. 前i个物品中选取总重不超过j时的状态。
dp[i+1][j] = max(dp[i+1][j], dp[i][j]); //单独的.(1,2两种方法都是配套的if,else,这里是单独的。)
dp[i + 1][j + w[i]] = max(dp[i + 1][j + w[i]], dp[i][j] + v[i]); (j + w[i] <= W)
_________________________________01背包问题代码________________________________
如下代码没有处理多组数据初始化的问题。在循环内部加个memset进行初始化即可。
题目还是刚才那个题目。如下代码分别对应1,2,3思路。
数据是这个:
4
2 3 1 2 3 4 2 2
5
解释数据:
4代表的是4种种类,n
(w,v)={(2,3),(1,2),(3,4),(2,2)}
W=5,不能超出的总容量。
答案是:
7
第一种思路的代码:1.
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100][100];
int main()
{
while (cin >> n)
{
for (i = 1; i <= n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = n - 1; i >= 0; --i)
{
for (j = 0; j <= W; ++j)
{
if (j < w[i])
m[i][j] = m[i + 1][j];
else
m[i][j] = max(m[i + 1][j], m[i + 1][j - w[i]] + v[i]);
}
}
cout << m[0][W] << endl;
}
return 0;
}
/*其实可以化简一下算法。之后的思路按道理都可以这么改,可以自己尝试折腾一下。
for (i = n - 1; i >= 0; --i){
for (j = w[i]; j <= W; ++j)//就单纯的把j的起始范围改了即可。{
m[i][j] = max(m[i + 1][j], m[i + 1][j - w[i]] + v[i]);
}}*/
第二种思路的代码:2.
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100][100];
int main()
{
while (cin >> n)
{
for (i = 0; i < n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = 0; i < n; ++i)//第i件物品是由n来控制的。
{
for (j = 0; j <= W; ++j)//背包容量为j是由W来控制的。
{
if (j >= w[i])
m[i + 1][j] = max(m[i][j], m[i][j - w[i]] + v[i]);
else
m[i + 1][j] = m[i][j];
}
}
cout << m[n][W] << endl;
}
return 0;
}
第三种思路的代码:3.
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100][100];
int main()
{
while (cin >> n)
{
for (i = 1; i <= n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = 0; i < n; ++i)//第i件物品是由n来控制的。
{
for (j = 0; j <= W; ++j)//背包容量为j是由W来控制的。
{
m[i + 1][j] = max(m[i + 1][j], m[i][j]);
if (j + w[i] <= W)
m[i + 1][j + w[i]] = max(m[i + 1][j + w[i]], m[i][j] + v[i]);
}
}
cout << m[n][W] << endl;
}
return 0;
}
_________________________滚动数组优化01背包及其代码___________________________
滚动数组优化的前提:
当你考虑第i个物品时,它只由上一个状态转移过来,而你并不在乎之前的所有状态,只在乎它前一个状态,也就是说只需存一个状态。
比较好理解的方法一:
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100][100];
int main()
{
int now = 1, pre = 0;
while (cin >> n)
{
for (i = 0; i < n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = n-1; i >=0; --i)
{
for (j = w[i]; j <= W; ++j)
m[pre][j] = max(m[now][j], m[now][j - w[i]] + v[i]);
swap(now, pre);
}
cout << m[now][W] << endl;
}
return 0;
}
暂时没去理解和实验的方法二:
for (i = 1; i <= n; ++i)
for (j = W; j >= w[i]; --j)
m[j] = max(m[j], m[j - w[i]] + value[i]);
_______________一维数组实现01背包原理 && 一维数组实现01背包代码______________
首先,我们来看一下二维数组形式的01背包。(这种方法不是之前引入的那三种办法,但是意思相近,可以脑补出来为何是这个递推公式。)
二维数组形式的01背包
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100][100];
int main()
{
while (cin >> n)
{
for (i = 1; i <= n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = 1; i <= n; ++i) {
for (j = 1; j <= W; ++j){
if (j >= w[i])
m[i][j] = max(m[i - 1][j], m[i - 1][j - w[i]] + v[i]);
else
m[i][j] = m[i - 1][j];
}
cout << m[i-1][j-1] << endl;
}
return 0;
}
重点是红色部分的代码。在此我们要去思考一个问题:
我们是如何在二维数组的形式上,去优化代码,使之成为空间变小的01背包的呢?
先来看看一维数组的状态转移方程,进行联想:
一维数组形式的01背包
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100];
int main()
{
while (cin >> n)
{
memset(m, 0, sizeof(m));
for (i = 1; i <= n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = 1; i <= n; ++i)
for (j = W; j >=w[i]; --j)
m[j] = max(m[j], m[j - w[i]] + v[i]);
cout << m[W] << endl;
}
return 0;
}
红色字体相互进行对比,我们可以发现,m[i],m[i-1]被消去了,此外我们j的循环方向也被改变了。
1. 针对m[i],m[i-1]:
m[i][j] = max(m[i - 1][j], m[i - 1][j - w[i]] + v[i]);
我们可以看到m[i][j]只与m[i - 1][j],m[i - 1][j - w[i]] + v[i]有关,
即只与i-1时刻有关,
所以只需用一个一维数组m[]来保存i-1时的状态和即可。
2. j的循环方向也被改变了。(在一维数组中,j为逆序递减的)
提出一个问题,为什么j是逆序递减而不是正序递增呢?
在此,我们继续引入一个知识:
j为逆序递减时,是01背包,j为正序递增时是完全背包。
因此,分析j正序递增时的一维数组情形:
for (int i = 0; i < n; i++)
for (int j = w[i]; j <= W; j++)
m[j] = max(m[j], m[j - w[i]] + v[i]);
首先正序递增这种做法对于01背包肯定是错误的,
很抽象的原因是:
正序递增里的关于j的for循环存在某个m[j]被改动过(结合着01背包和完全背包的不同点来说,也就是某个m[j]自己重复过元素。(最大的不同点,就是完全背包可以针对一种种类的事物,重复任意次。)),然后再次影响到更大的j。
解释完毕。
_______________重量总和W超大时的01背包处理问题(1<=W<=10^9)________________
之前的01背包数据大小:
1<=n<=100 1<=wi,vi<=100 1<=W<=10000
目前的01数据大小:
1<=n<=100 1<=wi<=10^7 1<=vi<=100 1<=W<=10^9
之前的01背包代码的复杂度普遍是O(nW),因此不够用。
之前的01背包代码里的dp[i][j]。
dp[i][j]
此数组的i代表的是种类,由n控制。(n种物品)
j代表的是背包容量,由W来控制。
整体dp[i][j]想要表达的意思是:面对第i件物品,且背包容量为j时所能获得的最大价值。
而现在W非常大,因此相比较W而言,价值v的范围较小,所以可以尝试改变DP的对象。
目前的01背包代码里的dp[i][j]
dp[i][j]
此数组的i代表的依旧是种类,由n控制。(n种物品)
j代表的是价值总和。
整体dp[i][j]想要表达的意思是:第i个物品->价值总和为j时的最小值。(不存在的时候就是一个充分大的数值INF)
_______________________________________________________________________________________
_______________________________完全背包基础含义________________________________
给定 n 种物品:物品 i 的重量是 wi,其价值为 vi; 和一个容量为 W 的背包。(同一种类的背包可以选择任意多件)
________________________________完全背包问题代码_______________________________
数据:
第一行n=3
第二行(w,v)={(3,4),(4,5),(2,3)}
第三行W=7
input:
3
3 4 4 5 2 3
7
output:
10 (0号物品选1个,2号物品选两个。)
方法一:一维数组形式完全背包:
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, i, j, w[1000], v[1000], W, dp[1000];
int main()
{
while (cin >> n) {
for (i = 0; i < n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = 0; i < n; ++i)
for (j = w[i]; j <= W; ++j)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
cout << dp[W] << endl;
}
return 0;
}
方法二:二维数组形式完全背包。
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100][100];
int main()
{
while (cin >> n)
{
for (i = 0; i < n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = 0; i < n; ++i)//第i件物品是由n来控制的。
{
for (j = 0; j <= W; ++j)//背包容量为j是由W来控制的。
{
if (j < w[i])
m[(i + 1)][j] = m[i][j];
else
m[(i + 1)][j] = max(m[i][j], m[(i + 1)][j - w[i]] + v[i]);
}
}
cout << m[n][W] << endl;
}
return 0;
}
____________________________滚动数组优化完全背包及其代码_______________________
滚动数组优化的前提:
当你考虑第i个物品时,它只由上一个状态转移过来,而你并不在乎之前的所有状态,只在乎它前一个状态,也就是说只需存一个状态。
这一递推式中,dp[i+1]计算时,只需要dp[i]和dp[i+1],所以可以结合奇偶性写出下面代码。&1只是用来弄奇偶的。但是感觉代码有点多此一举,因为不要&1,也是正确的。
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, w[100], v[100], W;//题目数据
int i, j, m[100][100];
int main()
{
while (cin >> n)
{
for (i = 0; i < n; ++i)
cin >> w[i] >> v[i];
cin >> W;
for (i = 0; i < n; ++i)//第i件物品是由n来控制的。
{
for (j = 0; j <= W; ++j)//背包容量为j是由W来控制的。
{
if (j >= w[i])
m[(i + 1) & 1][j] = max(m[i & 1][j], m[(i + 1) & 1][j - w[i]] + v[i]);
else
m[(i + 1)&1][j] = m[i][j];
}
}
cout << m[n&1][W] << endl;
}
return 0;
}
_______________________________完全背包的变形__________________________________
1. hdu上的1114题
_______________________________________________________________________________
_______________________________多重部分和问题__________________________________
有n种不同大小的数字ai,每种各m个。判断是否可以从这些数字值中选出若干使他们的和恰好为K。
限制条件:
1<=n<=100
1<=ai,mi<=100000
1<=K<=100000
input:
n=3
a={3,5,8}
m={3,2,2}
K=17
output:
YES(3*3+8=17)
方法一思路:
晃眼一看,感觉这个和完全背包很像,但是却限制了每种种类可以被挑选的个数和约束了最后的结果使之务必能等于K。
因此凑出K是主要目的。
可以设置一个 二维bool数组 dp[i][j]表示:用前i种种类的数字是否能加成j值。若能,bool数组为1,若不能则为0。
由此可以看出i的循环范围是0~n,j的循环范围是0~K。
但是就算这样,我们还缺少一个关于 个数 的循环。因此是三层循环,k,k表示含义:该种类的数值个数。因此它的限制范围也可推出,为k<=mi&&k*a[i]<=j。
针对于第三层k循环,进行思考。
是否,当前数值 可以从 当前数值 - 某种类的数值(之前数值 + (某种类数值)) 得到?而一旦可以得到j值,我们立刻标记j为true。
所以有
dp[0][0]=1时,
dp[i+1][j] |= dp[i][j-k*a[i]]
但是之后我们会发现这个算法,容纳不了限制条件那么大的数据,如果将dp[100000][100000]写出来的话,程序会主动报错说数组太大。
尽管思路是正确的。
给出代码:
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, a[10000], m[10000], K, i, j, k;
bool dp[10000][10000];
int main()
{
while (cin >> n) {
for (i = 0; i < n; ++i)
cin >> a[i];
for (i = 0; i < n; ++i)
cin >> m[i];
cin >> K;
dp[0][0] = 1;
for (i = 0; i < n; ++i)
for (j = 0; j <= K; ++j)
for (k = 0; k <= m[i] && k*a[i] <= j; ++k)
dp[i + 1][j] |= dp[i][j - k * a[i]];
if (dp[n][K] == 1)
cout << "YES" << endl;
else
cout << "NO" << endl;
}
return 0;
}
方法二思路:
由于方法一思路dp求取bool结果的话会有浪费,获取的信息较少。因此不光求出能否得到目标的和数,同时我们后还可以把得到ai时这个数还剩下多少个可以计算出来,达到减少复杂度的目的。
dp[i+1][j]:用前i种数加和得到j时 第i种数最多能剩多少个(不能加和得到i的情况下为-1(j<ai))
所以如下递推式:
dp[i+1][j]=mi (dp[i][j]>=0)
dp[i+1][j]=-1 (j<ai(不能加和得到i的情况)||dp[i+1][j-ai]<=0(种类个数不够))
dp[i+1][j]=dp[i+1][j-ai]-1; (其他,可得到时,个数-1)
这样,只要看最终是否满足dp[n][K]>=0即可。
时间复杂度为O(nK)
因此只需要一个二层循环即可,方法一中的k循环被dp[i][j]里的数值包括了。
因为只与i+1时刻有关,
所以只需用一个一维数组dp[]来保存i+1时的状态和即可。
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, a[100000], m[100000], K, i, j, dp[100000];
int main()
{
while (cin >> n){
for (i = 0; i < n; ++i)
cin >> a[i];
for (i = 0; i < n; ++i)
cin >> m[i];
cin >> K;
dp[0] = 0;
for (i = 0; i < n; ++i)
for (j = 0; j <= K; ++j)
{
if (dp[j] >= 0)
dp[j] = m[i];
else if (j < a[i] && dp[j - a[i]] <= 0)
dp[j] = -1;
else
dp[j] = dp[j - a[i]] - 1;
}
if (dp[K] >= 0)
cout << "YES" << endl;
else
cout << "NO" << endl;
}
return 0;
}
________________________________最长上升子序列问题_____________________________
题目:
有一个长为n的数列a0,a1,an-1。请求出这个序列中最长的上升子序列的长度。上升子序列指的是对于任意的i<j都满足ai<aj的子序列。
限制条件:1<=n<=1000 0<=ai<=1000000
输入
n=5
a={4,2,3,1,5}
输出
3(a1,a2,a4构成的子序列2,3,5最长)
方法一代码:
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int n, i, j, res;
int a[1000000], dp[1000000];
int main()
{
while (cin >> n) {
res = 0;
for (i = 0; i < n; ++i)
cin >> a[i];
for (i = 0; i < n; ++i)
{
dp[i] = 1;
for (j = 0; j < i; ++j)
{
if (a[i]<a[j])
dp[i] = max(dp[i], dp[j] + 1);
}
res = max(res, dp[i]);
}
cout << res << endl;
}
return 0;
}
另外一种不容易被思考的方法二:
首先针对于方法二就开始对这个问题有些个人的思考了:
1. 最长上升子序列可能不只有一条。
2. 方法二的方法最后dp数组中保留的结果是1 3 5,而不是2 3 5。
方法二思路:
例如1 5,1 2这两个序列哪个比较可能成为最小上升子序列呢,当然是1 2了。
于是思考用一个一维数组dp[i]来保存:上升子序列中末尾元素的最小值(比如说这里的2,不存在的话就是INF)
然后从前到后逐个考虑数列的元素,对于每个元素aj,如果i=0或者dp[i-1]<aj的话,就用dp[i]=min(dp[i],aj)进行更新。
进一步优化:因为dp数列保存的是末尾元素的最小值,因此不管怎么样,它都是单调递增的。因此在有序的情况下来说,我们便于使用二分搜索:复杂度为O(logn),整体复杂度为O(nlogn)
lower_bound()函数的介绍:
功能:从已排好序的序列a中利用二分搜索找出指向满足ai>=k的ai的最小的指针。类似的函数还有upper_bound,这一函数求出的是只想满足ai>k的ai的最小的指针。
小技巧:求长度为n的有序数组a中的k的个数:upper_bound(a,a+n,k)-lower_bound(a,a+n,k)。绝对值。
#include"stdafx.h"
#include<iostream>
#include<algorithm>
#include<math.h>
using namespace std;
int dp[1000000], a[1000000], n, i, j;
int main()
{
while (cin >> n)
{
for (i = 0; i < n; ++i)
cin >> a[i];
fill(dp, dp + n, 10000000);
for (i = 0; i < n; ++i)
*lower_bound(dp, dp + n, a[i]) = a[i];
cout << lower_bound(dp, dp + n, 10000000) - dp << endl;
}
return 0;
}
__________________________________划分数问题___________________________________
题目:
有n个无区别的物品,将它们划分成不超过m组,求出划分方法数模M的余数。
限制条件:
1<=m<=n<=1000
2<=M<=10000
input:
n=4
m=3
M=10000
output:
4 (1 1 2 1 3 2 2 4 )
这样的划分被称作n的m划分,特别地,m=n时称作n的划分数。
思路:我们用一个二维数组 dp[i][j] 代表:j的i划分。
在把j划分成i组时,可以先取出k个自成为一组,然后将剩下的j-k个分成i-1份,继续上述步骤。因此可得递推式:dp[i][j]= jΣ(k=0) dp[i-1][j-k]
但是这个递推式是错误的,因为它将1 1 2和1 2 1这样的划分分成了两种不同的划分来记数了(重复计数)。
因此寻找别的递推关系:
观察1 1 2 1 3 2 2 4。
填补1 1 2 1 3 0 2 2 0 4 0 0
发现数据可以分成两种:有0的 和 全部不为0的。
引入概念: