什么是动态规划
动态规划(Dynamic Programming, DP)是一种用来解决一类最优化问题的算法思想。简单来说,动态规划将一个复杂的问题分解为若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划会将每个求解过的子问题的解记录下来,这样当下一次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算。注意:虽然动态规划采用这种方式来提高计算效率,但不能说这种做法就是动态规划的核心。
一般可以使用递归或者递推的写法来实现动态规划,其中递归的写法在此处又称作记忆化搜索。
注意:一个问题必须具有重叠子问题和最优子结构,才能使用动态规划去解决。
重叠子问题:如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题。
最优子结构:如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么称这个问题拥有最优子结构。
一些概念的区别
分治与动态规划
分治和动态规划都是将问题分解为子问题,然后合并子问题的解得到原问题的解。但不同的是,分治法分解出的子问题是不重叠的,因此分治法解决的问题不拥有重叠子问题,而动态规划解决的问题拥有重叠子问题。
贪心与动态规划
贪心和动态规划都要求原问题必须拥有最优子结构。二者的区别在于,贪心法采用的计算方式类似于上面介绍的“自顶向下”,但是并不等待子问题求解完毕后再选择使用哪一个,而是通过一种策略直接选择一个子问题去求解,没被选择的子问题就不去求解了,直接抛弃。也就是说,它总是只在上一步选择的基础上继续选择,因此整个过程以一种单链的流水方式进行,显然这种所谓“最优选择”的正确性需要用归纳法证明。
一些小知识
字符串的字串和子序列的区别
对于一个字符串而言,比如:pikachu
字串:是在字符串中,取出一块(连续的),如:pik, ach, kac等
子序列:指的是从字符串中,顺序取出字符,但是可以不连续:如:pau, kch, icu等
无后效性
状态的无后效性是指:当前状态记录了历史信息,一旦当前状态确定,就不会改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。
对于动态规划的问题来说,总会有很多设计状态的方式,但并不是所有的状态都具有无后效性,因此必须设计一个拥有无后效性的状态以及相应的状态转移方程,否则动态规划就没有办法得到正确结果。
最大连续子序列和
问题
给定一个数字序列 A 1 , A 2 , A 3 . . . , A n A_1,A_2,A_3...,A_n A1,A2,A3...,An,求i,j( 1 ≤ i ≤ j ≤ n 1 \leq i \leq j \leq n 1≤i≤j≤n)使得 A i + . . . + A j A_i+...+A_j Ai+...+Aj最大,输出这个最大和。
例如:-2 11 -4 13 -5 -2
显然11+(-4)+13=20 和为最大的选取情况,因此最大和为20
这个问题如果使用暴力法来做,枚举左端点和右端点需要O(n2)的复杂度,而计算A[i]+…+A[j]需要O(n)的复杂度,因此总复杂度为O(n3)。
就算采用记录前缀和的方法(预处理S[i] = A[0] + A[1] + … +A[i],这样A[i] + … +A[j] = S[j] - S[i-1])使用计算的时间变为O(1),总复杂度仍然有O(n2),这对n为105大小的题目来说是无法承受的。
动态规划
使用动态规划法时间复杂度仅有O(n)
令状态dp[i]表示以A[i]作为末尾的连续序列的最大和(这里是说A[i]必须作为连续序列的末尾)。以样例为例:-2 11 -4 13 -5 -2,下标分别记为0,1,2,3,4,5,那么
dp[0] = -2,
dp[11] = 11,
dp[2] = 7(11 + (-4) = 7),
dp[3] = 20(11 + (-4) + 13 = 20),
dp[4] = 15 (因为由dp数组的含义,A[4] = -5 必须作为连续序列的末尾,于是最大和就是 11 + (-4) + 13 + (-5) = 15,而不是20)
dp[5] = 13(11 + (-4) + 13 + (-5) + (-2) = 13)。
因为dp[i]要求是必须以A[i]结尾的连续序列,那么只有两种情况:
- 这个最大和的连续序列只有一个元素,即以A[i]开始,以A[i]结尾。
- 这个最大和的连续序列有多个元素,即从前面某处A[p]开始(p<i),一直到A[i]结尾。
对于第一种情况,最大和就是A[i]本身。
对于第二种情况,最大和就是dp[i-1]+A[i]
于是得到状态转移方程:dp[i] = max{A[i], dp[i-1]+A[i]}
代码:
#include<stdio.h>
#define max(x, y) ((x)>(y)?(x):(y))
#define MAXN 20
int n = 6;
int a[] = {0, -2, 11, -4, 13, -5, -2}; //一般不用下标为0的元素
//求解结果表示
int dp[MAXN];
void maxSubSUm()
{
dp[0] = 0;
for(int i = 0;i<n;i++)
dp[j] = max(dp[i - 1] + a[i], a[i]);
}
void dispmaxSum()
{
int maxj = 1;
for(int j = 2;j<=n;j++)
{
if(dp[j]>dp[maxj])
maxj = j;
}
for(int k = maxj;k<=1;k--)
{
if(dp[k]<=0)
break;
}
printf("最大连续子序列和:%d\n",dp[maxj]);
printf("所选子序列:");
for(int i = k+1;i<maxj;i++)
{
printf("%d",a[i]);
}
printf("\n");
}
int main()
{
maxSubSum();
printf("求解结果\n");
dispmaxSum();
}
最长上升子序列(LIS)
问题
在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是上升的。
例如,现有序列A = {1,2,3,-1,-2,7,9}(下标从1开始),它的最长不下降子序列是{1,2,3,7,9),长度为5。另外,还有一些子序列是上升子序列,比如{1,2,3}、{-2,7,9}等,但都不是最长的。
注意,也有些情况求的是最长非降序子序列,二者区别就是序列中是否可以有相等的数。假设我们有一个序列 b i,当b1 < b2 < … < bS的时候,我们称这个序列是上升的。对于给定的一个序列(a1, a2, …, aN),我们也可以从中得到一些上升的子序列(ai1, ai2, …, aiK),这里1 <= i1 < i2 < … < iK <= N,但必须按照从前到后的顺序。比如,对于序列(1, 7, 3, 5, 9, 4, 8),我们就会得到一些上升的子序列,如(1, 7, 9), (3, 4, 8), (1, 3, 5, 8)等等,而这些子序列中最长的(如子序列(1, 3, 5, 8) ),它的长度为4,因此该序列的最长上升子序列长度为4。
对于这个问题如果我们使用最原始的枚举法,其时间复杂度为O(2n),这显然是不能承受的。
这里我们常使用的方法是三种:分别是O(n2)的DP,O(nlogn)的二分+贪心法,以及O(nlogn)的树状数组优化的DP。(本文详细介绍的是DP,有关该LIS的详细方法可以看这篇文章:最长上升子序列 (LIS) 详解+例题模板 (全))
动态规划
令dp[i]表示以A[i]结尾的最长上升子序列长度(和最大连续子序列和问题一样,以A[i]结尾是强制要求),这样对A[i]来说就会有两种可能:
- 如果存在A[i]之前的元素A[j](J<i),使得A[j]<A[i](如果是最长不下降子序列:这里是A[j] ≤ \leq ≤A[i],这里大家仔细分析一下就行,换汤不换药)且dp[j]+1>dp[i](即把A[i]跟在A[j]结尾的LIS后面时能比当前以A[i]结尾的LIS长度更长),那么就把A[i]跟在以A[j]结尾的LIS后面,形成一条更长的LIS(令dp[i] = dp[j] + 1)。
- 如果A[i]之前的元素都比A[i]大,那么A[i]就只好自己形成一条LIS,但是长度为1,即这个子序列里面只有一个A[i]。
状态转移方程为:dp[i] = max{1, dp[j] + 1} (j = 1,2,…,i-1&&A[j]<A[i])
代码:
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 100;
int A[N], dp[N];
int main()
{
int n;
scanf("%d", &n);
for(int i = 1;i <= n;i++)
{
scanf("%d", &A[i]);
}
int ans = -1; //记录最长的dp[i]
for(int i = 1;i <= n;i++)
{
dp[i] = 1; //边界初始条件,即设每个元素自成一个子序列
for(int j = 1;j < i;j++)
{
if(A[i] >= A[j] && (dp[j] + 1)>dp[i])
dp[i] = dp[j] + 1; //状态转移方程
}
ans = max(ans, dp[i]);
}
printf("%d", ans);
return 0;
}
最长公共子序列(LCS)
问题
给定两个字符串(或数字序列)A和B,求一个字符串,使得这个字符串是A和B的最长公共部分(子序列可以不连续)。
如图所示,字符串“sadstorry”与“adminsorry”的最长公共子序列为“adsory”,长度为6。
动态规划
令dp[i][j]表示字符串A的i号位和字符串B的j号位之前的LCS长度(下标从1开始),如dp[4][5]表示“sads”与“admin”的LCS长度。那么可以根据A[i]和B[j]的情况,分为两种决策:
- 若A[i] == B[j],则字符串A与字符串B的LCS增加了一位,即有dp[i][j] = dp[i-1][j-1]+1。
- 若A[i] != B[j],则字符串A的i号位和字符串B的j号位之前的LCS无法延长,因此dp[i][j]将会继承dp[i-1][j]与dp[i][j-1]中的较大值,即有dp[i][j] = max{dp[i-1][j], dp[i][j-1]}。
如下表所示:
j | a | d | m | i | n | s | o | r | r | y | ||
---|---|---|---|---|---|---|---|---|---|---|---|---|
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | |
s | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
a | 2 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
d | 3 | 0 | 1 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
s | 4 | 0 | 1 | 2 | 2 | 2 | 2 | 3 | 3 | 3 | 3 | 3 |
t | 5 | 0 | 1 | 2 | 2 | 2 | 2 | 3 | 3 | 3 | 3 | 3 |
o | 6 | 0 | 1 | 2 | 2 | 2 | 2 | 3 | 4 | 4 | 4 | 4 |
r | 7 | 0 | 1 | 2 | 2 | 2 | 2 | 3 | 4 | 5 | 5 | 5 |
y | 8 | 0 | 1 | 2 | 2 | 2 | 2 | 3 | 4 | 5 | 5 | 6 |
由此可以得到状态转移方程:
d p [ i ] [ j ] = { d p [ i − 1 ] [ j − 1 ] + 1 , A [ i ] = = B [ j ] max ( d p [ i − 1 ] [ j ] , d [ i ] [ j − 1 ] ) , A [ i ] ! = B [ j ] dp[i][j] = \begin{cases} dp[i-1][j-1]+1,A[i]==B[j] \\ \max(dp[i-1][j], d[i][j-1]),A[i]!=B[j] \end{cases} dp[i][j]={dp[i−1][j−1]+1,A[i]==B[j]max(dp[i−1][j],d[i][j−1]),A[i]!=B[j]
边界:dp[i][0] = dp[0][j] = 0 (0 ≤ \leq ≤i ≤ \leq ≤n, 0 ≤ \leq ≤j ≤ \leq ≤m)
这样状态dp[i][j]只与其之前的状态有关,由边界发出就可以得到整个dp数组,最终dp[n][m]就是需要的答案,时间复杂度为O(nm)
代码:
#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
using namespace std;
const int N = 100;
char A[N], B[N];
int dp[N][N];
int main()
{
int n;
gets(A+1); //从下标为1开始读入
gets(B+1);
int lenA = strlen(A + 1); //由于读入时下标从1开始,因此读取长度也从+1开始
int lenB = strlen(B + 1);
//边界
for(int i = 0; i<=lenA; i++)
{
dp[i][0] = 0;
}
for(int j = 0; j<lenB; j++)
{
dp[0][j] = 0;
}
//状态转移方程
for(int i = 1; i<=lenA; i++)
{
for(int j = 1;j<=lenB; j++)
{
if(A[i]==B[j])
dp[i][j] = dp[i-1][j-1]+1;
else
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
//dp[lenA][lenB]是答案
printf("%d\n",dp[lenA][lenB]);
return 0;
}
最长回文子串
问题
给出一个字符串S,求S的最长回文子串的长度。
样例:字符串“PATZJUJZTACCBCC”的最长回文子串为“ATZJUJZTA”,长度为9。
如果使用暴力枚举法,从复杂度上来看,枚举端点需要O(n2),判断回文需要O(n),因此总的复杂度为O(n3)。
动态规划
使用动态规划法负责度为O(n2),最长回文子串有很多种使用动态规划的方法,这里仅仅介绍最容易理解的一种。
令dp[i][j]表示S[i]至S[j]所表示的子串是否是回文子串,是则为1,否则为0.这样根据S[i]是否等于S[j],可以把转移情况分为两类:
- 若S[i] == S[j],那么只要S[i+1]至S[j-1]是回文子串,S[i]至S[j]就是回文子串;如果S[i+1]至S[j-1]不是回文子串,则S[i]至S[j]也不是回文子串。
- 若S[i] != S[j],那么S[i]至S[j]一定不是回文子串。
状态转移方程:
d p [ i ] [ j ] = { d p [ i + 1 ] [ j − 1 ] , S [ i ] = = S [ j ] 0 , S [ i ] ! = S [ j ] dp[i][j] = \begin{cases} dp[i+1][j-1],S[i] == S[j] \\ 0,S[i] != S[j] \end{cases} dp[i][j]={dp[i+1][j−1],S[i]==S[j]0,S[i]!=S[j]
边界:dp[i][i] = 1,dp[i][i+1] = (S[i] == S[i+1]) ? 1 : 0。
代码:
#include<iostream>
#include<cstring>
const int maxn = 1010;
char S[maxn];
int dp[maxn][maxn];
int main()
{
gets(s);
int len = strlen(S), ans = 1;
memset(dp, 0, sizeof(dp)); //dp数组初始化
//边界
for(int i = 0; i<len; i++)
{
dp[i][i] = 1;
if(i < len - 1)
{
if(S[i] == S[i+1])
{
dp[i][i + 1] = 1;
ans = 2; //初始化时注意当前最长回文子串长度
}
}
}
//状态转移方程
for(int L = 3; L<=len; L++)
{
for(int i = 0; i + L - 1<len; i++)
{
int j = i + L - 1;
if(S[i] == S[j]&&dp[i + 1][j - 1] == 1)
{
dp[i][j] = 1;
ans = L; //更新最长回文子串长度
}
}
}
printf("%d\n", ans);
return 0;
}
在这里还有二分+字符串hash的做法,复杂度O(nlogn)。不过,最优秀的当前复杂度为O(n)的Manacher算法。
DAG最长路
DAG是有向无环图,该问题可以使用“关键路径”求解,但是使用DP思想更为简便,在这里我们主要要解决两个问题:
- 求整个DAG中的最长路径(即不固定起点和终点)
- 固定终点,求DAG的最长路径。
问题1
**给定一个有向无环图,怎样求解整个图的所有最短路径中权值之和最大的那条。**如下图所示B -> D -> F -> I就是改图的最长路径,长度为9。
动态规划
针对这个问题,令dp[i]表示从i顶点出发能获得的最长路径长度,这样所有dp[i]的最大值就是整个DAG的最长路径长度。
那么怎样求解dp数组呢?注意到dp[i]表示从i号顶点出发能获得的最长路径的长度,如果从i号顶点出发能直接到达顶点j1、j2、…、jk,而dp[j1]、dp[j2]、…、dp[jk]均已知,那么就有dp[i] = max{dp[j] + length[i → \rightarrow →j]|(i, j) ∈ \in ∈ E},如图所示:
显然根据上面的思路,需要按照逆拓扑序列的顺序来求解dp数组。但是有没有不求出逆拓扑序列也能计算dp数组的方法呢?当然有,那就是递归。请看下面的代码,其中图使用邻接矩阵的方式存储:
int Dp(int i)
{
if(dp[i] > 0)
return dp[i]; //dp[i]已计算得到
for(int i = 0;j<n;j++)
{
if(G[i][j] != INF)
{
dp[i] = max(dp[i], DP(j) + G[i][j]);
}
}
return dp[i]; //返回计算完比的dp[i]
}
由于从出度为0的顶点出发的最长路径的长度为0,因此边界为这些顶点的dp值为0,。但具体实现中不妨对整个dp数组初始化为0,这样dp函数当前方位的顶点i的出度为0时就会自动返dp[i] = 0(以此作为dp的边界),而出度不是0的顶点这会递归求解,递归过程中遇到已经计算过的顶点则直接返回对应的dp值。
问题2
这里我们讨论第二个问题:固定终点,求DAG的最长路径长度。
例如,还是那个图
如果我们固定H为路径终点,那么最长路径就会变为B → \rightarrow →D → \rightarrow →F → \rightarrow →H。
有了上面的经验,应该能很容易想到这个延伸问题的解决方案。假设规定的终点为T,那么可以**令dp[i]表示从i号顶点出发到达终点T能获得的最长路径长度。**同样的,如果从i号顶点出发能直接到达顶点j1、j2、…、jk,而dp[j1]、dp[j2]、…、dp[jk]均已知,那么就有dp[i] = max{dp[j] + length[i → \rightarrow →j]|(i, j) ∈ \in ∈ E}。
可以发现,这个式子和第一个问题的式子是一样的——但是如果仅仅是这样,显然无法体现出dp数组的含义中增加的“到达终点T”的描述。那么这两个问题的区别应该体现在哪里呢?没错,边界。在第一个问题中没有固定的终点,因此所有出度为0的顶点的dp值为0是边界;但是在这个问题中固定了终点,因此边界应当为dp[T] = 0。那么可不可以像之前的做法那样对整个dp数组都赋值为0呢?不行,此处会有一点问题。由于从某些顶点出发可能无法到达终点T(例如从出度为0的顶点),因此如果按之前的做法可能会得到错误的结果(例如出度为0的顶点会得到0),这从含义上说是不对的。合适的做法是初始化dp数组为一个负的大数,来保证“无法到达终点”的含义得以表达(即-INF);然后设置一个vis数组表示顶点是否已经被计算。
代码如下:
int Dp(int i)
{
if(vis[i])
return dp[i]; //dp[i]已计算得到
vis[i] = true;
for(int j = 0;j<n;j++)
{
if(G[i][j] != INF)
{
dp[i] = max(dp[i], Dp(j) + G[i][j]);
}
}
return dp[i];
}
注意,有一些经典的问题可以转化为DAG的最长路,例如经典的矩形嵌套问题。
背包问题
本文先介绍两类简单的背包问题:01背包问题和完全背包问题。
多阶段动态规划问题
有一类动态规划可解的问题,它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,一般把这类问题称为多阶段动态规划问题。
如图所示 ,该问题被分为了五个阶段,其中状态F属于阶段3,它由阶段2的状态C和状态D推得。显然,对于这种问题,只需要从第一个问题开始,按照阶段的顺序解决每一个阶段中状态的计算,就可以得到最后一个阶段中的状态的解。这对设计状态的具体含义很有帮助,01背包就是这样的例子。
01背包问题
问题
有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为V的背包。问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有1件。
样例:
5 8 //n == 5, V == 8
3 5 1 2 2 //w[i]
4 5 2 1 3 //c[i]
如果采用暴力枚举每一件物品放或者不放进背包,显然每件物品都有两种选择,因此n件物品就有22种情况,而O(22)的复杂度显然是很糟糕的。而使用动态规划方法可以将复杂度降为O(nV)。
动态规划
令dp[i][v]表示前i件物品(1 ≤ \leq ≤i ≤ \leq ≤n, 0 ≤ \leq ≤v ≤ \leq ≤V)恰好装入容量为v的背包中所能获得的最大价值。怎么求解dp[i][v]呢?
考虑对第i件物品的选择策略,有两种策略:
- 不放第i件物品,那么问题转化为前i-1件物品恰好装入容量为v的背包中所能获得的最大价值,也即dp[i-1][v]。
- 放第i件物品,那么问题转化为前i-1件物品恰好装入容量为v-w[i]的背包中所能获得最大价值,也即dp[i-1][v-w[i]] + c[i]。
由于只有这两种策略,且要求获得最大价值,因此状态转移方程:
d p [ i ] [ v ] = m a x ( d p [ i − 1 ] [ v ] , d p [ i − 1 ] [ v − w [ i ] ] + c [ i ] ) ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]]+c[i]) (1\leq i \leq n, w[i] \leq v \leq V) dp[i][v]=max(dp[i−1][v],dp[i−1][v−w[i]]+c[i])(1≤i≤n,w[i]≤v≤V)
注意到dp[i][v]只与之前的状态dp[i-1][]有关,所以可以枚举i从1到n,v从0到V,通过边界dp[0][v] = 0(0 ≤ \leq ≤v ≤ \leq ≤V)(即前0件物品放入任何容量v的背包中都只能获得价值0)就可以把整个dp数组推出来。而由于dp[i][v]表示的恰好为v的情况,所以需要枚举dp[n][v](0 ≤ \leq ≤v ≤ \leq ≤V),取其最大值才是最后的结果。
代码:
for(int i = 1;i<=n;i++)
{
for(int v = w[i];v<=V;v++)
{
dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]]+c[i]);
}
}
可以知道,时间复杂度和空间复杂度都是O(nV),其中时间复杂度已经无法再优化,但是空间复杂度还可再优化。
滚动数组
如下图所示,注意到状态转移方程中计算dp[i][v]时总是只需要dp[i-1][v]左侧部分的数据(即只需要图中正上方与左上方的数据),且当计算dp[i+1][]部分时,dp[i-1][]的数据又完全用不到了(只需要用到dp[i][]),因此不妨可以直接开一个一维数组dp[v](即把第一维省去了),枚举方向改变为i从1到n,v从V到0(逆序!),这样状态转移方程改变为:
d p [ v ] = m a x ( d p [ v ] , d p [ v − w [ i ] ] + c [ i ] ) ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) dp[v] = max(dp[v], dp[v-w[i]] + c[i])(1\leq i\leq n, w[i]\leq v \leq V) dp[v]=max(dp[v],dp[v−w[i]]+c[i])(1≤i≤n,w[i]≤v≤V)
这样修改对应到图中,可以这样理解:v的枚举顺序从右往左,dp[i][v]右边部分为刚计算过的需要保存给下一行使用的数据,而dp[i][v]左上角的阴影部分为当前需要使用的部分。将这两者结合一下,即把dp[i][v]左上角和右边的部分放在一个数组里,每计算出一个dp[i][v],就相当于把dp[i-1][v]抹消掉,因为在后面的运算中dp[i-1][v]再也用不到了。我们把这种技巧称为滚动数组。
代码如下:
for(int i = 1;i<=n;i++)
{
for(int v = V;v>=w[i];v--) //逆序枚举v
{
dp[v] = max(dp[v], dp[v-w[i]] + c[i]);
}
}
这样01背包就可以用一维数组表示来解决了,空间复杂度为O(V)。
特别说明:如果是用二维数组存放,v的枚举是顺序还是逆序都无所谓;如果使用一维数组存放,则v的枚举必须是逆序!
完整代码:
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn = 100; //物品最大件数
const int maxv = 1000; //V的上限
int w[maxn], c[maxn], dp[maxv];
int main()
{
int n,V;
scanf("%d%d", &n, &V);
for(int i = 1;i<=n;i++)
{
scanf("%d", &w[i]);
}
for(int i = 1;i<=n;i++)
{
scanf("%d", &c[i]);
}
//边界
for(int v = 0;v<=V;v++)
{
dp[v] = 0;
}
for(int i = 1;i<=n;i++)
{
for(int v = V;v>=w[i]; v--)
{
//状态转移方程
dp[v] = max(dp[v], dp[v - w[i]] + c[i]);
}
}
//寻找dp[0...V]中最大的即为答案
int max = 0;
for(int v = 0;v<=V;v++)
{
if(dp[v]>max)
max = dp[v];
}
printf("%d\n", max);
return 0;
}
完全背包问题
问题
有n种物品,每种物品的单件重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都有无穷件。
可以看出,完全背包问题和01背包问题的唯一区别就在于:完全背包的物品数量每种有无穷件,选取物品时对同一种物品可以选择1件、2件…只要不超过容量V即可,而01背包的物品数量每种只有1件。
动态规划
同样,令dp[i][v]表示前i件物品恰好放入容量为v的背包中能获得的最大价值。
和01背包一样,完全背包问题的每种物品都有两种策略,但是也有不同点。对第i件物品来说:
- 不放dii件物品,那么dp[i][v] = dp[i-1][v],这步跟01背包是一样的。
- 放第i件物品。这里的处理和01背包有所不同,因为01背包的每件物品只能选择一个,因此选择放第i件物品就意味着必须转移到dp[i-1][v-w[i]]这个状态;但是完全背包不同,完全背包如果选择放第i件物品之后并不是转移到dp[i-1][v-w[i]],而是转移到dp[i][v-w[i]],这是因为每种物品可以放任意件(注意有容量的限制,因此还是有限的),放了第i件物品后还可以继续放第i件物品,直到第二维的v-w[i]无法保持大于等于0为止。
这样我们可以得到状态转移方程:
d p [ i ] [ v ] = m a x ( d p [ i − 1 ] [ v ] , d p [ i ] [ v − w [ i ] ] + c [ i ] ) ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) dp[i][v] = max(dp[i-1][v], dp[i][v-w[i]] + c[i])(1\leq i\leq n,w[i] \leq v \leq V) dp[i][v]=max(dp[i−1][v],dp[i][v−w[i]]+c[i])(1≤i≤n,w[i]≤v≤V)
边界:dp[0][v] = 0(0 ≤ \leq ≤v ≤ \leq ≤V)
看上去和01背包很像是不是?其实唯一的区别就在于max的第二个参数是dp[i]而不是dp[i-1]。而这个状态转移方程同样也可以改写成一维形式,即状态转移方程:
d p [ v ] = m a x ( d p [ v ] , d p [ v − w [ i ] ] + c [ i ] ) ( 1 ≤ i ≤ n , w [ i ] ≤ v ≤ V ) dp[v] = max(dp[v], dp[v-w[i]] + c[i])(1\leq i \leq n, w[i] \leq v \leq V) dp[v]=max(dp[v],dp[v−w[i]]+c[i])(1≤i≤n,w[i]≤v≤V)
边界:dp[v] = 0(0 ≤ \leq ≤v ≤ \leq ≤V)
写成一维形式之后和01背包完全相同,唯一的区别在于这里v的枚举顺序是正向枚举,而01背包的一维形式中v必须是逆向枚举。完全背包的一维形式代码如下:
for(int i = 1;i<=n;i++)
{
for(int v = w[i];v<=V;v++) //正向枚举v
{
dp[v] = max(dp[v], dp[v-w[i]] + c[i]);
}
}
怎么理解必须正向枚举呢?如下图所示,求解dp[i][v]需要它左边的dp[i][v-w[i]]和它上方的dp[i-1][v],显然如果让v从小到大枚举,dp[i][v-w[i]]就总是已经计算出的结果;而计算出dp[i][v]之后dp[i-1][v]就再也用不到了,可以直接覆盖。