写在最前面,在写第七章之前,我以前在上算法课的时候也总结过一篇关于动态规划的文章,这次的总结也算是复习+学习新知,侧重点变成了算题。动态规划很多时候需要的是一种思想,一种能察觉到问题具有最优子结构,然后通过分析得到递推关系式的过程。而很多问题的最优子结构性质如果是刚接触过一般难以在短时间内解决,所有还是鼓励平时多算题,多积累,说不定哪天考场上就出现了你碰过的题目了呢,
首先关于背包问题的整理和相关的详细代码请见我的github
1.递推求解
跳台阶[推导,得到类似于斐波那契的递推关系式]
不容易系列之一(九度教程第 94 题)
需要通过数学推导得到错排公式
2.最长递增子序列
对于LIS问题可以得到如下递推公式:
F
[
i
]
=
{
max
1
≤
j
<
i
F
[
j
]
+
1
,
a
j
<
a
i
max
1
≤
j
<
i
F
[
j
]
,
a
j
≥
a
i
F[i] = \begin{cases} \max \limits_{1≤j<i}{F[j]+1}, &a_j<a_i \\ \max \limits_{1≤j<i}{F[j]}, &a_j≥a_i \end{cases}
F[i]=⎩⎨⎧1≤j<imaxF[j]+1,1≤j<imaxF[j],aj<aiaj≥ai
其中F[i]表示以第i个字符结尾的最长递增(后面的元素必须大于前面的元素,不能是等于)子序列的长度。
其实写到这里我想说的是,上面给的递推方程是错的!没错,你没看错,是错的,错的,不知道细心的你发现问题了没有,直观上来说如果不细看可能会觉得很有道理,但错就错在
a
j
≥
a
i
a_j≥a_i
aj≥ai的这种情况是不能考虑的,而且F[i]所代表的含义更加确切的说应该是,以第i个字符作为结尾元素的最长递增(后面的元素必须大于前面的元素,不能是等于)子序列的长度。而且此时,为了避免第i个字母之前的字母都
a
j
≥
a
i
a_j≥a_i
aj≥ai的情况,这里还要对最终的结果和1取最大值。
对于此例子只需要一个反例【这里举的是最长递减子序列的例子】:(注意标红的部分为错误的值,标绿的部分为改正之后的值)。
那么相应的正确的递推公式应该为:
F
[
i
]
=
max
{
1
,
max
1
≤
j
<
i
F
[
j
]
+
1
}
,
a
j
<
a
i
F[i] =\max\{1,\max \limits_{1≤j<i}{F[j]+1} \}, a_j<a_i
F[i]=max{1,1≤j<imaxF[j]+1},aj<ai
习题:
拦截导弹(九度教程第 95 题)
合唱队形(九度教程第 97 题)(需要从两个方向运用两次LIS)
在最长公共子序列里面还存在一种O(nlogn)的算法,其基本思想就是“当序列中末尾元素的值越小的时候,越有利于形成更长的递增序列”,因此我们可以采用一个数组L来维护相关的信息,数组元素
L
[
i
]
L[i]
L[i]表示长度为i的递增子序列末尾元素的最小值。具体的操作步骤见如下例子:

算法的伪代码如下所示:
LIS():
A[0] = L[0]
length = 1
for i = 1 to n-1
if L[length] < A[i]
L[length++] = A[i]
else
int idx = lower_bound(L,L+length,A[i])-L
L[idx] = A[i]
所求得的L数组的长度即为最长递增子序列的长度
3.最长公共子序列(LCS)
该博客有详细解答,这里就不展开了。
LCS的递推方程如下所示:
c
[
i
,
j
]
=
{
0
,
i=0 or j=0
c
[
i
−
1
,
j
−
1
]
+
1
,
i,j>0;xi =yj
max
{
c
[
i
−
1
,
j
]
,
c
[
i
,
j
−
1
]
}
,
i,j>0;xi !=yj
c[i,j] = \begin{cases} 0, & \text{i=0 or j=0} \\ c[i-1,j-1] + 1, & \text{i,j>0;xi =yj}\\ \max\{c[i-1,j],c[i,j-1]\}, & \text{i,j>0;xi !=yj} \end{cases}
c[i,j]=⎩⎪⎨⎪⎧0,c[i−1,j−1]+1,max{c[i−1,j],c[i,j−1]},i=0 or j=0i,j>0;xi =yji,j>0;xi !=yj
其中c[i,j]表示字符串A[1,…,i]和字符串B[1,…,j]的最长公共子序列长度。
4.状态与状态转移方程
5.动态规划问题分析举例
6.背包问题
0-1背包
递推公式
d
p
[
i
]
[
j
]
=
{
m
a
x
{
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
−
1
]
[
j
−
w
i
]
+
v
i
}
,
w
i
≤
j
d
p
[
i
−
1
]
[
j
]
,
w
i
>
j
dp[i][j] = \begin{cases} max\{dp[i-1][j],dp[i-1][j-w_i]+v_i\}, w_i≤j\\ dp[i-1][j], w_i>j \end{cases}
dp[i][j]={max{dp[i−1][j],dp[i−1][j−wi]+vi},wi≤jdp[i−1][j],wi>j
由于dp的过程是逐行求解的,且当前行的结果只与上一行的求解结果有关,所以可以把二维数组进一步缩减为一维数组。但求解顺序必须是从后往前,避免需要用到的上一行的值在本行被提前更新。直观的理解就是,为了使得在放入第i件物品的时候,保证
d
p
[
j
−
w
e
i
g
h
t
[
i
]
]
dp[j-weight[i]]
dp[j−weight[i]]存储的是没有放入第i件物品的数据。
改进为一维规划数组之后的核心代码如下所示:
for(int i=1;i<=m;i++){//遍历m件物品
for(int j=t;j>=weight[i];j--){//对于0-1背包问题,需要从后往前遍历数组
dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
}
}
习题:
采药(九度教程第 101 题)
0-1背包(刚好装满)
对于完全背包问题,其与0-1背包问题的主要区别在于初始化的过程,但状态转移的过程均是一致的。因为要求背包恰好被装满,所以对于第0行,只有
d
p
[
0
]
[
0
]
=
0
,
d
p
[
0
]
[
j
]
=
I
N
T
_
M
I
N
(
1
≤
j
≤
c
)
dp[0][0]=0,dp[0][j]=INT\_MIN (1≤j≤c)
dp[0][0]=0,dp[0][j]=INT_MIN(1≤j≤c),但该过程可能不存在解,所以如果输出为负值需要进行特殊处理。
如下我们将上述的采用改变为完全背包问题,代码如下:
#include <iostream>
#include <climits>
#define M 101
#define T 1001
using namespace std;
int max(int a,int b){return a>b?a:b;}
int weight[M],value[M];
int dp[T];
int main()
{
int t,m;
while(~scanf("%d%d",&t,&m)){
for(int i=1;i<=m;i++)
scanf("%d%d",&weight[i],&value[i]);
for(int i=0;i<=t;i++){
if(i==0)
dp[i]=0;
else
dp[i]=INT_MIN;//因为是要求解最大值,所以无效值一律赋值为最小值
}
for(int i=1;i<=m;i++){//遍历m件物品
for(int j=t;j>=weight[i];j--){//遍历符合情况的总量,不能再放的保留上一层循环的值
if(dp[j-weight[i]]!=INT_MIN)//当dp[j-weight[i]]是一个合理的状态的时候
dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
}
}
if(dp[t]!=INT_MIN)
printf("%d\n",dp[t]);
else
printf("-1\n");//如果不存在解这输出-1
}
return 0;
}
/*
70 3
71 100
69 1
1 2
*/
完全背包(0-n背包问题)
完全背包是改进版本的0-1背包问题,其指的时每一种物品有无穷多个,背包的容量为c问怎样选择物品才能使的最后背包中的总价值最大。这里有一种简单的思路就是将每种物品的数量根据进行拆分背包容量进行拆分,例如第i件物品至多有
c
w
i
\frac{c}{w_i}
wic件,然后将所有的物品合成一个集合采用0-1背包的思路进行求解,直观而言该方法的时间复杂度为:
O
(
c
∗
∑
0
≤
i
≤
n
c
w
i
)
O(c*\sum\limits_{0≤i≤n}\frac{c}{w_i})
O(c∗0≤i≤n∑wic),如果当重量小的物体数量比较多的时候,该方法的求解效率便会十分的低下。
这里我们主要要讲的是
O
(
c
∗
n
)
O(c*n)
O(c∗n)的方法,其时间复杂度和0-1背包问题是一模一样的,就连代码也基本是一模一样的,没错,就是和优化后采用一维规划数组存储状态值的0-1背包问题的代码基本一致。只不过是第二层循环中,循环变量从前往后进行遍历,这是有原因的,直观的理解就是由于每一种物品的数量是无限的,在加入第i件物品的时候
d
p
[
j
−
w
e
i
g
h
t
[
i
]
]
dp[j-weight[i]]
dp[j−weight[i]]可以是一件加入过物品i的结果,所以其没有0-1背包问题的那一层限制。
for(int i=1;i<=m;i++){//遍历m件物品
for(int j=weight[i];j<=t;j++){//对于完全背包问题,需要从左到右遍历一维数组
dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
}
}
对于采药问题的完全背包版本代码如下所示:
#include <iostream>
#include <climits>
#define M 101
#define T 1001
using namespace std;
int max(int a,int b){return a>b?a:b;}
int weight[M],value[M];
int dp[T];
int main()
{
int t,m;
while(~scanf("%d%d",&t,&m)){
for(int i=1;i<=m;i++)
scanf("%d%d",&weight[i],&value[i]);
for(int i=0;i<=t;i++){
dp[i]=0;
}
for(int i=1;i<=m;i++){//遍历m件物品
for(int j=weight[i];j<=t;j++){//遍历符合情况的总量,不能再放的保留上一层循环的值
dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
}
}
if(dp[t]<0)
printf("-1\n");
else
printf("%d\n",dp[t]);
}
return 0;
}
多重背包
其介于 0-1 背包和完全背包之间:有容积为c 的背包,给定一些物品,每种物品包含体积 w、价值 v、和数量 k,求用该背包能装下的最大价值总量。
同样我们可以将该问题转化为0-1背包问题,其时间复杂度为
O
(
c
∗
∑
i
=
1
n
k
i
)
O(c*\sum\limits_{i=1}^nk_i)
O(c∗i=1∑nki).当然该办法已经可以解决问题,但但对于k的规模较大的情况下可以采用更好的发来来进行优化。这个就得联系到之前涉及到的二进制拆解。举个例子,对于k=7,其实我们没有必要把这个对象存储7次,反之只需要存储1,2,4这三种组合便可以组合出1~7之间的任意情况了。因此对于更一般的情况k,可以将其拆解为
1
,
2
,
4
,
.
.
.
,
k
−
2
c
+
1
1,2,4,...,k-2^c+1
1,2,4,...,k−2c+1,其中
c
=
⌊
log
2
k
⌋
c=\lfloor \log_2^k\rfloor
c=⌊log2k⌋,通过此番拆解我们可以将算法的时间复杂度降到
O
(
c
∗
∑
i
=
1
n
l
o
g
2
(
k
i
)
)
O(c*\sum\limits_{i=1}^nlog_2(k_i))
O(c∗i=1∑nlog2(ki)).
例题可以参考如下:
珍惜现在,感恩生活(九度教程第 103 题)
0-1背包(物品可以只装一部分)
最后将一个背包问题中的贪心策略,对于背包问题还存在另外一种比较特殊的情况,即允许一个物品只装一部分。对于这种情况我们可以采用贪心的思想,对所有的物品按照单位的性价比进行降序排序,先装单位价值比大的物品,知道装不下为之,可以只装入一个物体的一部分知道把背包装满为止。对于该种情况可以参考FatMouse’ Trade (九度教程第 21 题)
习题:
Piggy-Bank (九度教程第 102 题)(刚好要装满的完全背包问题,且所求的结果为最小的价值)