动态规划

动态规划(Dynamic Programming,简称DP)动态规划常常适用于有重叠子问题最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。

动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。虽然抽象后进行求解的思路并不复杂,但具体的形式千差万别,找出问题的子结构以及通过子结构重新构造最优解的过程很难统一,为了解决动态规划问题,只能靠多练习、多思考了。

\动态规划问题满足三大重要性质**

最优子结构性质:如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。

子问题重叠性质:子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。

无后效性:将各阶段按照一定的次序排列好之后,对于某个给定的阶段状态,它以前各阶段的状态无法直接影响它未来的决策,而只能通过当前的这个状态。换句话说,每个状态都是过去历史的一个完整总结。这就是无后向性,又称为无后效性。

重点 dp数组的含义 + 状态转移方程 (具体问题具体分析)

首先背包问题是我们接触动态规划比不可取的经典问题,重要的问题说三遍,经典经典经典。

  • 0-1背包问题
  • 完全背包问题
  • 多重背包问题

0-1背包问题

1、题目描述

​ 这里你有N件物品和一个容量为M的背包,这N件物品的第 i 件物品的 价值是V[i] ,重量是W[i].问题是拿取这N件物品的哪几件时,使得背包可以装下(意思就是物品的重量总和小于或等于M)且价值最大。

关键问题:其实这堆物品在你选择的时候无非就是两种路子可以选择:选 or 不选

第一步:构建dp数组的含义,dp[i][j] :代表的是前 i 个物品加入容量为 j 的背包里面价值总和的最大值

第二步:分析状态转移方程

​ 对于一个物品来说:要么选要么不选,

  • 选择这个物品:就是第i件物品放入背包中,此时

    1
    dp[i][j] = dp[i-1][j-w[i]] + V[i]   (j>W[i])
  • 不选择这个物品:就是舍弃第i件物品,不放入背包中,此时

    1
    dp[i][j] = dp[i-1][j]

    经过这两步的分析,可以得出这个问题的状态转移方程(即为重要,very very important)

    1
    dp[i][j] = Max(dp[i-1][j],dp[i-1][j-w[i]] + V[i])

2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "math.h"
#include "stdio.h"
#include "string.h"
int seekMax(int N, int M, int V[], int W[]);
int main(){
int N = 4; //物品的数量
int M = 10; //背包的容量
int V[] = {0,2,4,3,7}; //物品的价值 前面的0就是占位的,方便遍历
int W[] = {0,2,3,5,5}; //物品的重量
int re = seekMax(N, M, V, W);
printf("%d", re);
}
int seekMax(int N,int M,int V[],int W[])
{
//1、创建dp数组
int dp[N+1][M+1];
memset(dp, 0, sizeof(dp[0][0]) * (N + 1) * (M + 1)); //先全部置为0,因为c中会给随机值,很烦
//2、初始化dp数组
for (int i = 0; i < N; i++)
{
dp[i][0] = 0; //背包容量为0,你拿不拿物品价值最大都为0
}
for (int j = 0; j < N ; j++)
{
dp[0][j] = 0; //你不拿物品价值最大都为0
}
//3、开始根据状态转移方程地推填满dp数组
for (int i = 1; i <= N; i++)
{
for (int j = 1; j <= M; j++)
{
if(j<W[i]){
dp[i][j] = dp[i - 1][j]; //装不下第i件物品,只能不要咯
}else{
dp[i][j] = fmax(dp[i - 1][j], dp[i - 1][j - W[i]] + V[i]); //可以装下,可以选择 拿或者不拿
}
}
}
return dp[N][M];
}

3、深入分析理解

这个代码就是自下而上的方法,思路也是比较简单,就是不断遍历,不断填充dp表:

第一:初始化时候的表格:

第二:当 i=1的时候,只有物品1能够选择,如果背白容量够的话,那么此时的最大价值就是物品1的价值了

第三:当i=2的时候,根据状态转移方程,此时取i=2,j=3的时候有如下转换:

最后,根据这样的规则:逐一填表得:

这样,就可以得到最后的结果:13了,我们也可以根据状态转移方程方向得到选择的物品是第1 2 4号物品。

到此,分析时间复杂度为填表的时间为O(N*M) , 空间复杂度为O(N*M)

4、优化

在这个问题上,其实时间上没什么好优化的了,只能从空间上进行一点优化 ,

首先我们再看看状态转移方程:

1
dp[i][j] = Max(dp[i-1][j],dp[i-1][j-w[i]] + V[i])

可以明显看出,在填i+1行的数据的时候,只用到了第i行的数据,根本就没有用到i-1行的数据,换句话说,填某一行的数据的时候只与其前一行有关,根据这个规律,我们就可以使用将二维dp降为一维dp,缩减空间。此所谓滚动数组。

总结dp[i][j]所依赖的值必须是没有更新的,所以后到前。(ps:完全背包正好相反)

此时状态转移方程 dp[j] : 表示容量不超过 j

1
dp[j] = Max(dp[j],dp[j-w[i]] + V[i])  j>W[i]

代码实现:

和上面的代码有一点区别,在填充dp数组的第二层循环的时候,不应该从前到后(左到右),而应该从后到前(右到左),因为如果选择从前到后(左到右),会导致前面的值被修改,而后面的的值确实依赖前面的值的,要保证后面值得依赖是不变了。所以在第二轮扫描得时候需要从后到前扫描。(下图看做一行滴数据哈)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "math.h"
#include "stdio.h"
#include "string.h"
int seekMax(int N, int M, int V[], int W[]);
int main(){
int N = 4; //物品的数量
int M = 10; //背包的容量
int V[] = {0,2,4,3,7}; //物品的价值 前面的0就是占位的,方便遍历
int W[] = {0,2,3,5,5}; //物品的重量
int re = seekMax(N, M, V, W);
printf("%d", re);
}
//优化得
int seekMax(int N,int M,int V[],int W[])
{
//1、创建dp数组
int dp[M+1];
//2、初始化dp数组
for (int j = 0; j <= M ; j++)
{
dp[j] = 0; //初始全部都为0
}
//3、开始根据状态转移方程地推填满dp数组
for (int i = 1; i <= N; i++)
{
for (int j = M; j >= W[i]; j--)
{
dp[j] = fmax(dp[j], dp[j - W[i]] + V[i]); //可以装下,可以选择 拿或者不拿
}
}
return dp[M];
}

此时时间复杂度 O(N*M) 空间复杂度O(M) 空间复杂度优化了挺多哦

完全背包问题

1、题目描述

有N种物品和一个容量为M的背包,每种物品都就可以选择任意多个,第i种物品的价值为 V[i],重量是 W[i],求解:选哪些物品放入背包,可因使得这些物品的价值最大,并且体积总和不超过背包容量。

分析:

完全背包问题是在0-1背包问题的基础上略有不同,不同的是在0-1背包问题中,某一件物品要么取一件要么不取,但是在完全背包的问题中,某一件物品可以无限(任意)的取。

从物品的选择角度说也不是 选 OR 不选 的问题了,而是选 0 1 2 3 4 ,,,件的问题了,

默默嘀咕:曾经的我刚刚 接触的时候,贪心(有手就行),事实证明我还是年轻,打扰了,贪心解决不了。

动态规划方法:

第一步:构建dp数组的含义,dp[i][j] :代表的是前 i 个物品加入容量为 j 的背包里面价值总和的最大值

第二步:分析状态转移方程

​ 对于一件新的物品来说:可以选可以不选,

  • 选择这个新物品:此时(拿了 i 号物品,我们还是继续拿 i 号物品)

    1
    dp[i][j] = dp[i][j-w[i]] + V[i]   (j>W[i])
  • 不选择这个物品:就是舍弃全部的第 i 件物品,不放入背包中,此时

    1
    dp[i][j] = dp[i-1][j]

    经过这两步的分析,可以得出这个问题的状态转移方程(即为重要,very very important)

    1
    dp[i][j] = Max(dp[i-1][j],dp[i][j-w[i]] + V[i])

2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include "math.h"
#include "stdio.h"
#include "string.h"
int seekMax(int N, int M, int V[], int W[]);
int main(){
int N = 2; //物品的数量
int M = 10; //背包的容量
int V[] = {0,5,8}; //物品的价值 前面的0就是占位的,方便遍历
int W[] = {0,5,7}; //物品的重量
int re = seekMax(N, M, V, W);
printf("%d", re);
}
//没有优化的
int seekMax(int N,int M,int V[],int W[])
{
//1、创建dp数组
int dp[N+1][M+1];
memset(dp, 0, sizeof(dp[0][0]) * (N + 1) * (M + 1)); //先全部置为0,因为c中会给随机值,很烦
//2、初始化dp数组
for (int i = 0; i < N; i++)
{
dp[i][0] = 0; //背包容量为0,你拿不拿物品价值最大都为0
}
for (int j = 0; j < N ; j++)
{
dp[0][j] = 0; //你不拿物品价值最大都为0
}
//3、开始根据状态转移方程地推填满dp数组
for (int i = 1; i <= N; i++)
{
for (int j = 1; j <= M; j++)
{
if(j<W[i]){
dp[i][j] = dp[i - 1][j]; //装不下第i件物品,只能不要咯
}else{
//可以装下,可以选择 拿或者不拿 只是将 0-1背包问题中的 i-1 改为 i
dp[i][j] = fmax(dp[i - 1][j], dp[i][j - W[i]] + V[i]);
}
}
}
return dp[N][M];
}

注解 : 其实这边也还可以利用另外一个状态转移方程解决(自个摸索把)直接给出答案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
f[i][j]=max(f[i-1][j-k*V[i]]+k*W[i],f[i][j])     0<=k*c[i]<=j

代码:
#include "math.h"
#include "stdio.h"
#include "string.h"
int seekMax(int N, int M, int V[], int W[]);
int main(){
int N = 2; //物品的数量
int M = 10; //背包的容量
int V[] = {5,8}; //物品的价值
int W[] = {5,7}; //物品的重量
int re = seekMax(N, M, V, W);
printf("%d", re);
}

//f[i][j]=max(f[i-1][j-k*V[i]]+k*W[i],f[i][j]) 0<=k*c[i]<=j
int seekMax(int N,int M,int V[],int W[])
{
//1、创建dp数组
int dp[N+1][M+1];
memset(dp, 0, sizeof(dp[0][0]) * (N + 1) * (M + 1)); //先全部置为0,因为c中会给随机值,很烦
//2、初始化dp数组
for (int i = 0; i < N; i++)
{
dp[i][0] = 0; //背包容量为0,你拿不拿物品价值最大都为0
}
for (int j = 0; j < N ; j++)
{
dp[0][j] = 0; //你不拿物品价值最大都为0
}
//3、开始根据状态转移方程地推填满dp数组
for (int i = 0; i < N; i++){
for (int j = 0; j <= M; j++){
for (int k = 0; k * V[i] <= j; k++){
dp[i+1][j] = fmax(dp[i+1][j], dp[i][j-k * V[i]] + k * W[i]);
}
}
}
return dp[N][M];
}

3、深入分析理解

这边也可以画出表格来一步一步填这个表格的问题,自底向上,

第一步:初始化时的表格:

第二步:在第 i 个物品的时候,我们其实可以选择上一层中的几个位置中价值最高的那一个,在这里M=10,所以只需要将两个数值进行比较,如果M大于10,那么就需要将取0个、1个和两个i2物品的情况进行比较,然后选出最大值.

到此,分析时间复杂度为填表的时间为O(N*M) , 空间复杂度为O(N*M)

4、优化

优化思路和0-1背包问题一模一样的,就不在这里赘述了,直接上状态方程和代码。

总结dp[i][j]所依赖的值必须是已经更新的,所以前到后。(ps:0-1背包正好相反)

状态转移方程为

1
f[j]=max(f[j-w[i]]+c[i], f[j]);

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "math.h"
#include "stdio.h"
#include "string.h"
int seekMax(int N, int M, int V[], int W[]);
int main(){
int N = 2; //物品的数量
int M = 10; //背包的容量
int V[] = {0,5,8}; //物品的价值 前面的0就是占位的,方便遍历
int W[] = {0,5,7}; //物品的重量
int re = seekMax1(N, M, V, W);
printf("%d", re);
}
//优化得
int seekMax(int N,int M,int V[],int W[])
{
//1、创建dp数组
int dp[M+1];
//2、初始化dp数组
for (int j = 0; j <= M ; j++)
{
dp[j] = 0; //初始全部都为0
}
//3、开始根据状态转移方程地推填满dp数组
for (int i = 1; i <= N; i++)
{
for (int j = W[i]; j <= M; j++)
{
dp[j] = fmax(dp[j], dp[j - W[i]] + V[i]); //可以装下,可以选择 拿或者不拿
}
}
return dp[M];
}

此时时间复杂度 O(N*M) 空间复杂度O(M) 空间复杂度也是变成线性的了。

注解:其实还有一个小优化

比如两件物品 :i ji 物品的重量比 j 的重,但是 i 的价值确比 j 的低,那我们岂不是可以直接跳过 i 了,直接选择 j 物品了。道理很简单,难道这世界上会有人去买一个又贵又难吃的东西?(富豪除外)

多重背包问题

1、题目描述

有N种物品和一个容量为V的背包。第i种物品最多有n[i]件可用,每件费用是w[i],价值是c[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

分析

这里既不像0-1背包每种物品只有1件,也不像完全背包那样每种物品有无数件,而是限定来了每种物品的数量,并不是你想取多少就取多少,得看看人家有没有。

经过前面两个的分析,这个多重背包问题得状态转移方程和完全背包的状态转移方程岂不是一个爹娘的样子,就是K多了一个限制

1
dp[i,j] = max(dp(i-1, j - V[i] * k) + P[i] * k); (0 <= k * V[i] <= j && 0 <= k <= n[i])

2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include "math.h"
#include "stdio.h"
#include "string.h"
int seekMax(int N, int M, int V[], int W[],int n[]);
int main(){
int N = 3; //不同物品的数量
int M = 15; //背包的容量
int V[] = {3,4,5}; //物品的价值
int W[] = {2,3,4}; //物品的重量
int n[] = {4, 3, 2};//每种物品的个数
int re = seekMax(N, M, V, W,n);
printf("%d", re);
}
//没有优化的
int seekMax(int N,int M,int V[],int W[],int n[])
{
//1、创建dp数组
int dp[N+1][M+1];
memset(dp, 0, sizeof(dp[0][0]) * (N + 1) * (M + 1)); //先全部置为0,因为c中会给随机值,很烦
//2、初始化dp数组
for (int i = 0; i < N; i++)
{
dp[i][0] = 0; //背包容量为0,你拿不拿物品价值最大都为0
}
for (int j = 0; j < N ; j++)
{
dp[0][j] = 0; //你不拿物品价值最大都为0
}
//3、开始根据状态转移方程地推填满dp数组
for (int i = 0; i < N; i++){
for (int j = 0; j <= M; j++){
//k限制了条件 不加限制就是我上面讲的完全背包问题中在我没有优化的时候提出的另外一个方程的解
for (int k = 0; k <= n[i] && k * V[i] <= j; k++){
dp[i+1][j] = fmax(dp[i+1][j], dp[i][j-k * V[i]] + k * W[i]);
}
}
}
return dp[N][M];
}

分析就不再分析了,和前面的一模一样的

3、优化

  • 优化1:这边优化也可以项0-1背包和完全背包的样子,将dp二维数组改为一维的dp数组,改为滚动数组,这里不再赘述
  • 优化2:这里有一个比较巧妙的方法,完美将多重背包问题顺利转为0-1背包的问题了。下面会详细讲解这个优化(鄙人比较pick)

举个例子:比如有一种物品,她一共有8件,我们再取的时候可以取得 0 1 2 3 4 5 6 7 8 件这九种情况,但是我们在取得时候是不知道该取多少件得,这时候我们可以把这八件物品分堆,使得我们可以取得上述得九种情况的任意一种,所以分堆便是重点了,这里分堆采用2进制的方法进行分堆

统一:分为 1 2 4 8 。。。总的减去前面的总和(因为最后一个并不一定是2的整数次幂)

例如八件同一件物品:分为大小为 1 2 4 1 的四个堆即可,任意组合可以得到上述选择的九种可能,此时将这四个堆想象成0-1背包问题种的不一样的物品即可。

状态转移方程和0-1背包相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include "math.h"
#include "stdio.h"
#include "string.h"
int seekMax(int N, int M, int V[], int W[],int n[]);
int main(){
int N = 3; //不同物品的数量
int M = 15; //背包的容量
int V[] = {0,3,4,5}; //物品的价值 前面的0就是占位的,方便遍历
int W[] = {0,2,3,4}; //物品的重量
int n[] = {0,4,3,2};//每种物品的个数
int re = seekMax(N, M, V, W,n);
printf("%d", re);
}
//优化的
int seekMax(int N,int M,int V[],int W[],int n[])
{
//创建分堆后的价值和重量数组 这个大小可以根据题目给的数据范围来确定
int newW[N * 20];
int newV[M * 20];
newW[0] = 0;
newV[0] = 0;
//先分堆 完善上面两个数组
int number = 0; //分堆后的总堆数
for (int i = 1; i <= N;++i)
{
for (int j = 1; j <= n[i];j *= 2)
{
number++;
newW[number] = W[i] * j;
newV[number] = V[i] * j;
n[i] -= j;
}
//最后那个
if(n[i]>0){
number++;
newW[number] = W[i] * n[i];
newV[number] = V[i] * n[i];
}
}
//1、创建dp数组
int dp[M+1];
//2、初始化dp数组
for (int j = 0; j <= M ; j++)
{
dp[j] = 0; //初始全部都为0
}
//3、开始根据状态转移方程地推填满dp数组
for (int i = 1; i <= number; i++)
{
for (int j = M; j >= newW[i]; j--)
{
dp[j] = fmax(dp[j], dp[j - newW[i]] + newV[i]); //可以装下,可以选择 拿或者不拿
}
}
return dp[M];
}

至此三个经典的背包问题解决啦。

混合背包问题

所谓混合背包的问题无非就是前面三种背包的杂糅操作,比如有的物品符合0-1背包(只能够取1件或者不取),有的物品符合完全背包问题(一件物品能够取任意件),有的物品符合多重背包问题(一种物品只能怪取限定件)

1
2
3
4
5
6
7
8
9
伪代码:
for(i = 0 ;i<N;i++){
if i属于0-1背包问题
采用0-1解决方法
else if i属于完全背包问题
采用完全解决方法
else if i属于多重背包问题
采用多重解决方法
}

总结

上述三种经典背包问题可谓了解动态规划的经典之作了,其实三种背包问题 都是有着异曲同工之妙呀,认真解读会让自己了解的更加深刻,舒服。其实关于背包问题的变形变异还有很多类似的题目,后续加以继续撸。。。鄙人不才,若有误望指正,本文章也是采取一些其他博客的思路,谢谢各路大神。

解决拥有子问题的问题,可以有三个方法

  • 朴素递归 (效率很低)
  • 递归 + 记忆化 (效率较高)
  • 递推完善dp数组(效率最高)动态规划常用

重点 dp数组的含义 + 状态转移方程 (具体问题具体分析)

附件

[]: https://blog.csdn.net/woshi250hua/article/details/7636866

这位好心的博主列举了一些背包模型的例子,可以利于自己继续练习