简单的动态规划:背包问题
一.问题描述:
背包问题是一类离散最优化问题的统称.这样的问题一般给定n个可选"物品"
S
e
l
e
c
t
i
o
n
=
s
1
,
s
2
,
.
.
.
,
s
n
Selection={s_1,s_2,...,s_n}
Selection=s1,s2,...,sn,以及其中每一个物品的"价值"
V
a
l
u
e
=
v
1
,
v
2
,
.
.
.
,
v
n
Value=v_1,v_2,...,v_n
Value=v1,v2,...,vn和每一个物品的"代价"
C
o
s
t
=
c
1
,
c
2
,
.
.
.
,
c
n
Cost=c_1,c_2,...,c_n
Cost=c1,c2,...,cn.
问题是:假如你手中握有一定的代价C(也就是背包大小),需要你求出通过选择
s
1
,
s
2
,
.
.
.
,
s
n
{s_1,s_2,...,s_n}
s1,s2,...,sn中的某一些"物品",所能够达到的最大"价值"总和,而这些物品的 "代价"总和不能超过C.
二.思路与思想:
背包问题的求解思路:
对于背包问题这样的 “代价” - “价值” 最优化问题,首先考虑能否使用贪心选择的方法来求解.假若不能,那么就要考虑使用动态规划来求解该问题.
而使用动态规划的思想,就是要维护一个最优值的数组,可以将其看作一个大小逐步递增的背包序列,使得在通过不断增加"子问题"规模以求解"原问题"的过程中,这个最优值数组是当前子问题的最优解.
- s 1 , s 2 , . . . , s n {s_1,s_2,...,s_n} s1,s2,...,sn中的子序列 S u b S e q = s i , s j , . . . , s k SubSeq={s_i,s_j,...,s_k} SubSeq=si,sj,...,sk视为原问题的一个子问题.其中i,j,k随意选取.
- 使用数组元素 D P [ c ] DP[c] DP[c]代表当前"子问题" s i , s j , . . . , s k {s_i,s_j,...,s_k} si,sj,...,sk下花费代价 c ∈ [ 0 , C ] c\in[0,C] c∈[0,C]所能产生的最优解.
- 将子问题规模增大,将 s p s_p sp插入到 s i , s j , . . . , s k {s_i,s_j,...,s_k} si,sj,...,sk后,形成新的"子问题" s i , s j , . . . , s k , s p {s_i,s_j,...,s_k,s_p} si,sj,...,sk,sp
- 更新
D
P
DP
DP数组:
对于 ∀ c ⩾ c p \forall c\geqslant c_p ∀c⩾cp,要更新 D P [ c ] DP[c] DP[c],也就是考虑对于 D P [ c p ] , D P [ c p + 1 ] , . . . , D P [ C ] DP[c_p],DP[c_p+1],...,DP[C] DP[cp],DP[cp+1],...,DP[C]的背包序列中的每一个每一个背包,将 s p s_p sp加入背包中是否会提高该背包的价值.
对于一般的问题来说,将 s p s_p sp加入背包 D P [ c q ] DP[c_q] DP[cq]中是否会提高它的价值,取决于 D P [ c q − c p ] + v p DP[c_q-c_p]+v_p DP[cq−cp]+vp的值是否大于此时的 D P [ c q ] DP[c_q] DP[cq].
更新数组时要注意顺序,有时可能会在更新时破坏原数组,从而出错.这种情况下一般都要调整更新顺序,使得大容量"背包"先获得更新,小容量"背包"后更新.
背包问题的求解思想:
- 1.转化思想:
很多复杂的背包问题可以转化为简单的背包问题. - 2.排序与预处理:
背包问题可以将 s 1 , s 2 , . . . , s n {s_1,s_2,...,s_n} s1,s2,...,sn序列任意排序/预处理,再进行计算求解. - 3.扩展子问题:
一切动态规划算法都需要维护子问题的信息,但是背包问题的求解,将原问题"求容量为C的背包 D P [ C ] DP[C] DP[C]的最大价值"扩展为"分别求容量不大于C的背包序列 D P [ 0 , 1 , . . . , C ] DP[0,1,...,C] DP[0,1,...,C]的最大价值".
三.简单的背包问题与算法:
[a.] 0-1背包问题1:
电子科大本部食堂的饭卡有一种很诡异的设计,即在购买之前判断余额。如果购买一个商品之前,卡上的剩余金额大于或等于5元,就一定可以购买成功(即使购买后卡上余额为负),否则无法购买(即使金额足够)。所以大家都希望尽量使卡上的余额最少。
某天,食堂中有n种菜出售,每种菜可购买一次。已知每种菜的价格以及卡上的余额,问最少可使卡上的余额为多少。
显然这是一个类似于0-1背包的问题.给定一个大小为m的背包(饭卡),一个有着价值序列
v
1
,
v
2
,
.
.
.
,
v
n
v_1,v_2,...,v_n
v1,v2,...,vn的菜品序列
s
1
,
s
2
,
.
.
.
,
s
n
{s_1,s_2,...,s_n}
s1,s2,...,sn.想要从中得到一个序列使得"卡上的余额最少",也就是序列价值和最大.
然而该问题与0-1背包有一个明显的不同之处,那就是在针对"物品"
s
p
s_p
sp来更新背包序列
D
P
[
0
,
1
,
.
.
.
,
m
]
DP[0,1,...,m]
DP[0,1,...,m]时,只要背包的容量
c
⩾
5
c\geqslant5
c⩾5就可以进行更新,不需要满足
c
⩾
c
p
c\geqslant c_p
c⩾cp的条件.
而显然通过这种"超额"方式更新后的容量
c
′
<
5
c'<5
c′<5的背包以及原本容量
c
<
5
c<5
c<5的背包,就不能再次以任何方式进行更新,但是它们的值显然可能是最小值.
如果使用一个变量存储"超额"更新的最大值:
因为"超额"之后的背包就不能再次获得更新,那么假若每次更新"子问题"时选取的
s
p
s_p
sp的价值不是严格递增的,即如果
v
i
>
v
j
v_i>v_j
vi>vj而有
s
i
,
.
.
.
,
s
j
s_i,...,s_j
si,...,sj是"子问题"序列"增长"的方式.
则可能存在一个背包
D
P
[
c
x
]
DP[c_x]
DP[cx],使得它的价值最高是
D
P
[
c
x
−
v
j
]
+
v
j
+
v
i
DP[c_x-v_j]+v_j+v_i
DP[cx−vj]+vj+vi(加入
v
j
v_j
vj不超额,加入
v
i
v_i
vi超额).那么显然先使用
v
i
v_i
vi更新这个背包后,
v
j
v_j
vj会因为背包"超额"而无法被更新,从而丢掉最优解.
这样一来,就必须要事先对"物品"(菜品)进行排序,然后按菜品价值递增的顺序对"子问题"进行更新.
改进(1)一定要使用临时变量,一定要排序吗?
为什么要使用一个变量代表使用菜品
s
p
s_p
sp更新后所有"超额"背包价值和的最大值,而不是去真正"超额"更新这些背包呢?因为整个算法是按照菜品价值递增顺序进行更新的,因而对于任意非最大价值菜品
s
p
s_p
sp,它所"超额"更新的背包必定比其后续更大价值的菜品"超额"更新的背包小.所以不能真正"超额"更新这些背包,要留给后续菜品去更新.
那么既然这样,我们甚至也不需要用临时变量储存这个"超额"更新的最大值了,因为已经知到它肯定小于后续菜品的"超额"更新值.而这个值又不参与更新迭代,那么只需要计算一下最后一道菜品(价值最大的菜品)超额更新值即可.
那么我们甚至可以不需要进行排序了,因为"超额"更新只能发生一次,那么它肯定发生在最大价值菜品处,而且只要保证这个最大价值菜品在最后进行处理即可.
这样一来,这个问题整个就转化为了一个0-1背包问题,只不过我们一是要保证更新后的背包容量不能小于5否则不更新,二是要首先找到所有"物品"中价值最大者,并保证最后更新它.
int n,m,dishes[1005];
vector<int> dp(1005); // 背包序列
memset(&dp.front(), 0, 1005*sizeof(int));
// 找到最大值,并且将其置为0,最后处理
int max=-1,max_index=0;
for(int i=0;i<n;i++)
{
if(dishes[i]>max)
{
max=dishes[i];
max_index=i;
}
}
dishes[max_index]=0; // 置为0
int take_i;
for (int i = 0; i < n; i++)
{
// 按照背包容量从大到小更新,使原有数据不被破坏
for (int j = m; j >= dishes[i] + 5; j--)
{
take_i = dp[j - dishes[i]] + dishes[i];
// 只有更新后背包容量仍然不小于5,才更新
if (j - take_i >= 5 && take_i > dp[j])
dp[j] = take_i;
}
}
// dishes[max_index]=max // 还原(可选操作)
// 最后考虑所有"物品"中的最大价值者,它可以"超额"更新:
if(m>=5)
return m-(dp[m]+max);
else
return m;
改进(2)一定要完全按照背包问题的方法处理它吗?
这个问题中"代价"==“价值”,实际上不一定需要0-1背包那样用int数组来存储背包序列
D
P
[
0
,
1
,
.
.
.
,
m
]
DP[0,1,...,m]
DP[0,1,...,m]的最优值和更新时的暂时值.完全可以使用一个bool型数组表示子问题的最优值.
定义
D
P
[
i
]
,
i
∈
[
0
,
m
]
DP[i],i\in[0,m]
DP[i],i∈[0,m]代表一个bool值序列,代表当前"子问题"下余额是否能够到达
i
i
i值,以及一个int型变量least代表当前"子问题"下余额可到达的(包含负数)的最小值.
根据以上两节的论述,只要保证最大价值的菜品在最后处理(更新),这个算法就可以正确执行,并且省掉一些空间需求.
int n,m,dishes[1005];
vector<bool> dp(1005); // bool型空间需求更小
memset(&dp.front(), 0, 1005*sizeof(bool));
// 找到最大值,并且将其与数组末尾交换,最后处理
int max=-1,max_index=0;
for(int i=0;i<n;i++)
{
if(dishes[i]>max)
{
max=dishes[i];
max_index=i;
}
}
// 交换最大价值元素和最后一个元素的位置
dishes[max_index]=dishes[n-1];
dishes[n-1]=max;
// 首先m余额是可以得到的(哪件菜品都不选)
dp[m]=true;
int least=m,nxt_residue;
for(int i=0;i<n;i++)
{
for(int j=5;j<=m;j++)
{
// 代表这个余额可以达到
if(dp[j]==true)
{
nxt_residue=j-dishes[i];
if(nxt_residue>=0)
{
dp[nxt_residue]=true;
}
// least存储所有可达余额的最小值
if(nxt_residue<least)
least=nxt_residue;
}
}
}
// 已经处理完成,直接返回即可
return least;
[b.]0-1背包问题2:
这个问题是一个NP完全问题,所以不存在现有多项式时间解法,但是因为限定了整数是正整数,且大小固定,则可以使用背包模型动态规划解决该问题.
问题转化: 原问题是"是否能够将数组划分为和相等的两个子数组",并不是一个最优化问题.但是可以将其转换为:“给定n个数,从中选择任意多个,且其和不大于n个数的和的一半,要求得这些数的和的最大值”.在求得这个最大值后,判断它是不是正好等于n个数的和的一半即可.
int max_dp[10001];
class Solution
{
public:
bool canPartition(vector<int>& nums)
{
memset(max_dp, 0, sizeof(max_dp));
int sum=0;
int size=nums.size();
for(int i=0;i<size;i++)
{
sum+=nums[i];
}
if(sum%2==1)
return false;
sum=sum/2;
for(int i=0;i<size;i++)
{
for(int j=sum;j>=nums[i];j--)
max_dp[j]=std::max(max_dp[j],max_dp[j-nums[i]]+nums[i]);
}
return max_dp[sum]==sum;
}
};
[c.] 二维0-1背包问题:
假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。
你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。
输入: strs = [“10”, “0001”, “111001”, “1”, “0”], m = 5, n = 3
输出: 4
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 “10”,“0001”,“1”,“0” 。
这就是一个最经典的二维背包的案例,它和普通的0-1背包的区别只在于它的背包序列是二维的.
// 一个二维背包序列:一共有101*101种背包大小
int dps[101][101];
class Solution
{
public:
int findMaxForm(vector<string>& strs, int m, int n)
{
memset(dps, 0 , 10201*sizeof(int));
// 按原顺序遍历每一个字符串,用它更新每一个背包即可
for(int i=1;i<=strs.size();i++)
{
int m_need=count_m(strs[i-1]);
int n_need=strs[i-1].size()-m_need;
// 背包的更新要从大至小,防止原数据被破坏
for(int mi=m;mi>=m_need;mi--)
for(int ni=n;ni>=n_need;ni--)
dps[mi][ni]=std::max(dps[mi][ni],dps[mi-m_need][ni-n_need]+1);
}
//返回最大背包的最优解即可
return dps[m][n];
}
// 获得一个单一字符串中0的个数的方法:
public:
inline int count_m(string& s)
{
int res=0;
for(int i=0;i<s.size();i++)
if(s[i]=='0')
res++;
return res;
}
};