背包问题是经典的动态规划问题,那么先简要说一下什么是动态规划
一、动态规划
举一道非常简单的leetcode题目:爬楼梯
题目描述:假设你在爬楼梯,需要n阶才能爬到楼顶,已知每次你可以爬1或者2个台阶,求一共有多少种办法可以爬到楼顶?
分析:假设n=1,毫无疑问我们只有一种方法,能够到达楼顶
n=2时,我们有两种方法到达楼顶(1 1)(2)
n=3时呢?我们可以很容易的发现,n=3时结果为n=2与n=1结果的和,因为你可以从第一层直接到第三层,或者是从第 二层直接到第三层。
n=4、5、6……
这样就会发现这样一个规律:
n=1, f(1) = 1
n=2, f(2) = 2
n=3,4,5……, f(n) = f(n-1)+f(n-2)
是个斐波那契数列,很容易想到使用递归来解答,java代码如下:
import java.util.*;
public class Palouti {
public static void main(String[] args) {
// TODO Auto-generated method stub
Scanner sc = new Scanner(System.in);
int N = Integer.parseInt(sc.nextLine());
System.out.println("台阶数为:" + N);
int res = getMax1(N);
System.out.println("爬楼梯的方法共为:" + res);
}
private static int getMax1(int N) {
if (N == 1)
return 1;
if (N == 2)
return 2;
return getMax1(N-1) + getMax1(N-2);
}
}
看着非常帅气,潇洒的几行代码就完成了,但是这其中却有很严重的问题,分析一下就可看出这种递归写法存在很多重复操作,如n = 5时,你需要算n=4和n=3的情况,而算n=4时你还需要算一下n=3的情况,如此大量的重复计算实际上实不可取的,面试时这么答是要完蛋的。一个直观的想法就是,如果我们将每次计算的结果都记录下来,那不是就不用每次都重复计算了?这样我们就需要一个大小为n的数组来存取每次计算的结果,代码如下:
private static int getMax2(int N) {
int[] F = new int[N+1];
for (int i = 0; i <= N; i++) {
if (i == 0)
F[i] = 0;
else if (i == 1)
F[i] = 1;
else if (i == 2)
F[i] = 2;
else {
F[i] = F[i-1] + F[i-2];
}
}
return F[N];
}
这其实就是一种动态规划的思想,若要求解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表,从而避免重复计算。
可使用动态规划的基本要素:
1.最优子问题:当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质
2.重叠子问题: 可用动态规划算法求解的问题应具备的另一个基本要素是子问题的重叠性质。在用递归算法自顶向下求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,当再次需要此子问题时,只要简单地用常数时间查看一下结果。
动态规划最经典的问题应该可以说是背包问题,下面,开始学习背包九讲吧
二、0、1背包问题
问题描述:有N 件物品和一个容量为V 的背包。放入第i 件物品耗费的空间是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。
分析问题:这是最基本的背包问题,这里每件物品只有一个,我们只有选或不选两种选择。求解目标是放入物品使得容量为V的背包价值总和最大化,那么子问题便是是否选择第i个物品从而能使得总价值最大化,而由于每个物品都有不同的费用(即耗费的空间),因此还需要对不同的空间进行分析。因此需要一个二维数组:F[N][V], F[i][v]表示前i个物品放入空间为v的背包所获的最大价值。
我们可以针对这个数据来得到一个表格:
i/v | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
1 | 5 | 5 | 5 | 5 | 5 | 5 | 5 |
2 | 7 | 5+7 | 5+7 | 5+7 | 5+7 | 5+7 | 5+7 |
3 | 7 | 5+7 | 5+7 | 5+7+6 | 5+7+6 | 5+7+6 | 5+7+6 |
F[0][0~V]=0 :这是毫无疑问的,因为不放入物品一定不会有价值
F[0~N][0]=0: 这也显而易见,因为背包空间为0自然放不进去物品,更不会有价值
现在来分析将上表第i行(也就是第i个物品)的情况,需要分析两种情况,首先当C[i] > v时,我们只能让F[i][v] = F[i-1][v] ,这是很显然的,第i个物品放不进去,我们只能让其等于将前i-1个物品放入v容量包中所获得的最大价值。第二种情况当C[i] <= v时,我们才有资格考虑是不是要放入第i个物品,我们只有放与不放两种决策。
放入第i个物品:F[i][v] = F[i-1][v-C[i]] + W[i] 即如果选择放入第i个物品,那么将前i个物品放入v容量的包中所能获得的最大价值就 等同于前i-1件物品放入空间为v-C[i]的背包所能获得的最大价值加上第i件物品的价值。
不放入第i件物品:F[i][v] = F[i-1][v] 这个不用解释
这时我们根据这两种决策哪个能得到最大价值来进行抉择,即:
F[i][v] = max{ F[i-1][v], F[i-1][v-C[i]] + W[i]}
最后F[N][V]就是将N个物品放入容量为V的背包中所能获得的最大价值。
加粗的公式是背包问题的基础,一定要很好的理解,之后的所有背包问题都是在这个思想上的改进。java代码如下,可以根据代码来好好梳理下思路。
import java.util.*;
public class Bag_01_1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
Scanner sc = new Scanner(System.in);
System.out.println("请输入物品的个数: ");
int N = Integer.parseInt(sc.nextLine());
System.out.println("请输入背包的大小: ");
int V = Integer.parseInt(sc.nextLine());
System.out.println("请输入各个物品的价值: ");
int[] W = new int[N];
String[] str1 = sc.nextLine().split(" ");
for (int i=0; i < N; i++) {
W[i] = Integer.parseInt(str1[i]);
}
int[] C = new int[N];
System.out.println("请输入各个物品的花费: ");
String[] str2 = sc.nextLine().split(" ");
for (int i=0; i < N; i++) {
C[i] = Integer.parseInt(str2[i]);
}
System.out.println("您输入的物品价值数组为: ");
System.out.println(Arrays.toString(W));
System.out.println("您输入的物品的花费数组为: ");
System.out.println(Arrays.toString(C));
int res = maxValue1(N, V, W, C);
System.out.println("最大价值为:");
System.out.println(res);
}
private static int maxValue1(int N, int V, int[] W, int[] C) {
// 创造一个二维数组
int [][] F = new int[N+1][V+1];
//F[i][v]代表将前i个物品放入v空间的包中的最大价值
for (int i = 0; i <= N; i++) {
F[i][0] = 0;
}
for (int j = 0; j <= V; j++) {
F[0][j] = 0;
}
for (int i = 1; i <= N; i++) {
for (int v = 1; v < C[i-1]; v++) {
F[i][v] = F[i-1][v];
}
for (int v = C[i-1]; v <= V; v++) {
F[i][v] = Math.max(F[i-1][v], F[i-1][v - C[i-1]]+W[i-1]);
}
}
return F[N][V];
}
可见时间复杂度和空间复杂度都为O(NV)。
三、空间复杂度的优化
上面所讲的方法使用了二维数组来保存结果,我们是否可以考虑仅仅使用一个一维数组来进行存储,从而降低空间复杂度呢?
of course !
从上面的算法可以看出,我们是对每个遍历到的物品的每种子背包空间的结果进行存储,这次我们仅仅使用一个一维数组F[V]表示每种空间容量下的最大价值。每遍历一个物品就对F[C[i] ~ V]进行一次更新,因为0~C[i]并不需要更新。还有一个问题,从上面算法可以看出第i个物品的结果仅与前i-1的结果有关,那么如何才能保证第i个物品的结果仅是从前i-1个物品结果中得到的呢?
答案是第二个循环,我们让背包容量从V遍历到C[i],这样我们在计算v容量子背包的结果时就可以确保没有被第i件物品更新过,
因为F[j] = max{F[j-1], F[j-1]+W[i]}, 其中F[j-1]是由主循环中第i-1次循环得到的,在第i次循环时F[j-1]还没有更新过,但是如果你从C[i-1]遍历到V,就会出现问题,这只适用于完全背包问题,这在之后会讲。
代码如下:
private static int maxValue2(int N, int V, int[] W, int[] C) {
// 使用一维数组来做 降低了空间复杂度
// 这里F[v] 代表v空间的背包所能达到的最大价值
int[] F = new int[V+1];
for (int i=0; i <= V; i++) {
F[i] = 0;
}
for (int i=1; i <= N; i++) {
for (int j=V; j >=C[i-1]; j--) {
F[j] = Math.max(F[j-1], F[j-1]+W[i-1]);
}
}
return F[V];
}
}
如果能理解的话,可以拿leetcode 746练下手https://leetcode-cn.com/problems/min-cost-climbing-stairs/