简介
动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
斐波那契数列
学习动态规划之前,我们先了解一下斐波那契数列是什么。
斐波那契数列又译为费波拿契数、斐波那契数列、费氏数列、黄金分割数列。
用文字来说,就是斐波那契数列由0和1开始,之后的斐波那契数就是由之前的两数相加而得出。首几个斐波那契数是:
0,1,1,2,3,5,8,13,21,34,55,89 ...
fib(n) = fib(n - 1) + fib(n - 2);
从中我们可以发现从第三个数开始的值是前两个值的和。
假设我在如下图中得到fib(7)的斐波那契数列,我们可以知道想获得7的斐波那契数列的话7就要获取fib(6) + fib(5) 的值,但fib(6) 又需要获取 fib(5) + fib(4)... 我们会发现有很多重复的计算,导致时间复杂度为O(2n)
我们发现左侧已经进行过fib(5)的计算,右侧又有一个fib(5),其实我们完全可以在左侧计算fib(5)的时候将值保存起来,这样我们在右侧直接使用这个值就可以了。
如果我们想算fib(6)的话,首先我们知道1 和 2 都等于1,依次相加得出如下:
数组 1 2 3 4 5 6
斐波那契数 1 1 2 3 5 8
我们从递归改为递推的话,时间复杂度就会变成O(n)
。
我们先用两个经典的问题来引出动态规划算法。
最优打工问题
如上图,我们一共有八个任务可以进行打工,对应的红色数字就是打工所赚取的钱,长度就是时间,在同一时间只能做一个任务,我们如何赚取最多的钱?
其实在面临一个选择的时候,只有两个选择,选 or 不选
我们需要找到一个最优解,如果选择opt(8)我们看看有什么结果。
图中我们可以看出来,我们选择第8号任务的话,我们可以赚取4块钱,但是我们就无法再做6、7号任务了,我们只考虑时间紧挨着的任务的最优解,我们就可以在前5个中再选取最优解即可。
如果我们不选,很简单,只要从前7个中获取最优解opt(7)。
然后我们就要在选和不选中选择赚钱最多的那个就ok了。
再举个例子,如果要做第7号任务,4、5、6、8都不能做,我们可以看到紧挨着的只有3号任务所以代码就是: prev(7) = 3
。
我们看一下prev函数是怎么计算出来的。
如果我们要做1号任务,由于我们只考虑前面的所以如果要做1号任务的话,我们前面就没有任务可以做,所以就是0,以此类推我直接把表格列出来
| i | prev(i) |
| --- | --- |
| 1 | 0 |
| 2 | 0 |
| 3 | 0 |
| 4 | 1 |
| 5 | 0 |
| 6 | 2 |
| 7 | 3 |
| 8 | 5 |
展开的树图如下:
我们在图中发现出现了我们之前所说的重叠的问题。
我们需要做的就是保存已经展开过的值,减少不必要的多次展开的问题,通过数组来保存已经有过的值。
| 任务编号 | 之前可做任务编号 | 当前任务最大收益 | 做 | 不做 | 所选任务 |
| --- | --- | --- | --- | --- | --- |
| i | prev(i) | opt(i) | opt(i) + opt(prev(i)) | opt(i-1) |[i] |
| 1 | 0 | 5 | 5 | 0 | [1] |
| 2 | 0 | 5 | 1 | 5 | [1] |
| 3 | 0 | 8 | 8 | 5 | [3] |
| 4 | 1 | 9 | 9 | 8 | [1,4] |
| 5 | 0 | 9 | 6 | 9 | [1,4] |
| 6 | 2 | 9 | 4 | 9 | [1,4] |
| 7 | 3 | 10 | 10 | 9 | [3,7] |
| 8 | 5 | 13 | 13 | 10 | [1,4,8] |
这样我们就可以很容易的到最大收益的任务编号。
背包问题
题目:现在有四个物品,背包总容量为8,背包最多能装下价值为多少的物品?
物品编号 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
物品体积 | 2 | 3 | 4 | 5 |
物品价值 | 3 | 4 | 5 | 6 |
我们用一个表格来展示我们的背包数据。
**我们第一行表示背包容量,第一列表示物品的编号。**
每个格子表示在当前背包容量的情况下,考虑前n个物品的最佳组合,所能装入的最大价值为多少,就填入方框中。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
---|---|---|---|---|---|---|---|---|---|---|
0 | ||||||||||
1 | ||||||||||
2 | ||||||||||
3 | ||||||||||
4 |
首先我们开始填入背包编号和容量,第一行全部是0,因为第一行表示前0个物品的最佳组合,也就是没有物品,所以我们不需要管背包容量有多么大,我们没有物品,所以都是0。
第一列也全部是0,因为我们对应的背包容量是0,所以不管有多少物品,我们没有容量,所以都是0。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | ||||||||
2 | 0 | ||||||||
3 | 0 | ||||||||
4 | 0 |
我们继续往右填,物品编号为1,背包容量为1,我们考虑前1个物品在容量为1的情况下,所能装入的最大价值为多少。
在每次装入之前我们需要考虑当前物品是否能装入背包
通过我们的已知条件,我们知道物品编号为1的物品它的体积为2。他比我们的背包容量还要大,所以我们只有一个选择,就是不装入1号物品。所以我们填入 0 。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | |||||||
2 | 0 | ||||||||
3 | 0 | ||||||||
4 | 0 |
我们继续往右填,物品编号为1,背包容量为2,我们的物品体积为2,现在我们就面临两个选择,装?or 不装?
我们一步步分析,如果我们选择不装,那我们就和前0号物品所对应最大价值是一样的,也就是 0 。
如果我们选择装入1号物品,物品编号为1,背包容量为2,物品的体积也是2,物品的价值为3。我们装入后背包容量 2-2=0,这时候我们的价值为3 。
由此我们可以知道,装 = 价值为3 or 不装 = 价值为 0,这时候我们选取最大的那个,也就是装。我们填入值为3。(后续填法一致,就不过多赘述)
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
2 | 0 | 0 | 3 | ||||||
3 | 0 | ||||||||
4 | 0 |
我们继续填,现在来到物品编号为2,背包容量为3这个位置,我们发现物品编号为2的体积为3,装入后背包容量 3-3=0,这时候我们的价值为4 。所以我们现在又来到了 装 or 不装 问题,装的话就是4,不装的话就选取前n个物品在此背包的最大价值也就是3,4>3所以我们选择装入。
当我们按照此方法以此类推我们可以得到如下表格:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
2 | 0 | 0 | 3 | 4 | 4 | 7 | 7 | 7 | 7 |
3 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 |
4 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 10 |
总结:
- 如果装不下当前物品,那么前n个物品的最佳组合和前n-1个物品的最佳组合是一样的。
- 如果装得下当前物品则有两种情况:
假设1: 装入当前物品,在给当前物品预留了相应空间的情况下,前n-1个物品的最佳组合加上当前物品的价值就是总价值。
假设2:不装当前物品,那么前n个物品的最佳价值组合,和前n-1个物品的最佳价值组合是一样的。
然后我们选取假设1和假设2中较大的价值,为当前组合的最大价值。
背包问题回溯
问题进阶:在背包内总价值最大的情况下,背包内装了哪些物品呢?
我们知道背包内最大总价值为10,我们需要查看当前物品编号为4是否放入了背包,进行n-1我们发现物品编号为3 容量为8时价值为9,9 != 10 由此可以知道物品编号4的确放入了背包,物品编号4对应的体积为5,我们找到n-1也就是物品编号为3且背包容量8-5=3的位置,如下图:
首先需要知道当前物品有没有放进去,进行n-1发现最大价值是一样的,所以可以知道当前物品没有放进去,进行往上移动,来到背包2的位置,我们还是需要知道当前物品有没有放进去,进行n-1发现最大价值为3 当前最大价值为4,所以知道物品2放入了背包。
然后我们进行当前容量减去物品2的容量就是3-3=0,找到物品编号为1,背包容量为0的位置进行判断。而背包容量为0肯定没有放任何东西,所以继续回溯就到了物品编号为0,背包容量为0,回溯结束。
题目练习
现在有这么一道题 数组如下:
arr[1,2,4,1,7,8,3]
我们在选择某个数的时候不能选择相邻的两个数,选择1就不能选择2,选择7就不能选择1和8,如何得出选择的是最大的和呢?
假设我们选择下标为6的,arr[6]会是怎样的呢?我把流程画出来。
这个时候很明显,我们又遇见了之前说的重叠问题。
直接上代码
第一种递归写法(不推荐)
public static void main(String[] args) {
int[] arr = new int[]{1, 2, 4, 1, 7, 8, 3};
System.out.println(resOpt(arr, 6));
}
private static int resOpt(int[] arr, int i) {
if (i == 0) {
return arr[0];
} else if (i == 1) {
return Math.max(arr[1], arr[0]);
} else {
int case1 = resOpt(arr, i - 2) + arr[i];
int case2 = resOpt(arr, i - 1);
return Math.max(case1, case2);
}
}
结果为:15
但是这种写法有个很严重的问题,就是我们没有考虑重叠的问题!效率非常低,时间复杂度为O(2n)
。
第二种:动态规划
public static void main(String[] args) {
int[] arr = new int[]{1,2,4,1,7,8,3};
System.out.println(dpOpt(arr));
}
private static int dpOpt(int[] arr) {
int[] opt = new int[arr.length];
opt[0] = arr[0];
opt[1] = Math.max(arr[1], arr[0]);
for (int i = 2; i < arr.length; i++) {
int case1 = opt[i - 2] + arr[i];
int case2 = opt[i - 1];
opt[i] = Math.max(case1, case2);
}
return opt[opt.length - 1];
}
题目练习
给定一个整数数组 arr 和一个目标值 target,请是否有一组数字加起来等于target,找到返回true,未找到返回false。
给定 arr = [3,34,4,12,5,2], target = 9
因为 nums[2] + nums[5] = 4 + 5 = 9
所以返回 true
如果我们先选择了 arr[5],我们还是面临两种情况,选 or 不选。
选择了arr[5] 也就是之前要有数组加起来是7,7+2=9 , 如果不选,也就是arr[4]之前要等于9。
具体还是和背包问题一样,填入表格即可。最终获取最右下角的值,就是是否有匹配到的结果。
public static void main(String[] args) {
int[] arr = {3, 34, 4, 12, 5, 2};
System.out.println(dpSubSet(arr, 9));
}
public static boolean dpSubSet(int[] arr, int target) {
boolean[][] dp = new boolean[arr.length + 1][target + 1];
for (int k = 1; k <= target; k++) {
//为第一行赋初值
dp[0][k] = false;
}
for (int k = 1; k <= arr.length; k++) {
//为第一列赋初值
dp[k][0] = true;
}
dp[0][0] = true;
for (int i = 1; i <= arr.length; i++) {
for (int j = 1; j <= target; j++) {
if (arr[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - arr[i - 1]];
}
}
}
return dp[arr.length][target];
}
结果:true