动态规划
定义
动态规划(dynamic programming)是通过组合子问题而解决整个问题的解。 分治法是将问题划分成一些独立的子问题,递归地求解各子问题,然后合并子问题的解。 动态规划适用于子问题不是独立的情况,也就是各子问题包含公共的子子问题。
此时,分治法会做许多不必要的工作,即重复地求解公共的子问题。动态规划算法对每个子问题只求解一次,将其结果保存起来,从而避免每次遇到各个子问题时重新计算答案。
适用范围
最优性原理体现为问题的最优子结构特性。当一个问题的最优解中包含了子问题的最优解时,则称该问题具有最优子结构特性。
最优性原理是动态规划的基础。任何一个问题,如果失去了这个最优性原理的支持,就不可能用动态规划设计求解。
- 问题中的状态满足最优性原理。
- 问题中的状态必须满足无后效性。
所谓无后效性是指:“下一时刻的状态只与当前状态有关,而和当前状态之前的状态无关,当前状态是对以往决策的总结”。
首先,动态规划方法适合的题型4个基本特点是:
- 最优子结构,当前一个状态得到最佳解时,当前状态在前一个状态下一定有最佳解;
- 子问题重叠,每个状态下要解决的问题除参数不同外,其本质是一样的;
- 有边界,当解决了最后一个子问题时,整个问题得解;
- 子问题独立,解决一个子问题时不依赖于另一个同级的子问题,只与它的母问题有关。
动态规划的设计
两种方法:
- 自顶向下(又称记忆化搜索、备忘录):基本上对应着递归函数实现,从大范围开始计算,要注意不断保存中间结果,避免重复计算
- 自底向上(递推):从小范围递推计算到大范围
一般分为两个步骤:
- 问题建模
- 求解问题
核心元素
有三个核心元素:
- 最优子结构
- 边界
- 状态转移方程
总结DP算法的思路
核心: 最优子结构、边界条件、状态转移方程
解题步骤: 1.建立数学模型 2.写代码求解问题
如何建模?先写出所求问题的最优子结构,进而分析出边界和状态转移方程,数学模型即这2者的组合,对于2输入维度动态规划,画表格帮助分析,行列分别代表1个输入维度
如何求解?
建好模后,根据方程组写出自底向上的动态规划代码,一维输入就是1个for循环,二维输入就是2个for循环,如果方程组比较抽象,可以画表格帮助分析
棋盘问题
问题
寻找一条从左上角(arr[0][0])到右下角(arr[m - 1][n - 1])的路线,使得沿途经过的数组中的整数和最小。
递归算法
从右下角倒着分析,最后一步到达arr[m - 1][n - 1]只有两条路径,即通过arr[m - 2][n - 1]或arr[m - 1][n - 2]到达。
推广到一半的情况,假设到达arr[i - 1][j]与arr[i][j - 1]的最短路径的和为f(i - 1, j)和f(i, j - 1),那么到达arr[i][j]的路径上所有数字和的最小值为 f(i, j) = min{f(i - 1, j), f(i, j - 1)} + arr[i][j]
递归方法实现效率太低,有大量重复计算过程。
动态规划算法
动态规划其实是一种空间换时间的算法,通过缓存计算的中间值,减少重复计算的次数,从而提高算法的效率。
递归从arr[m - 1][n - 1]开始逆向通过递归来求解,采用动态规划可以自底向上求解,以便使用前面计算出来的结果。
对于本题而言,显然有边界条件,f(i, 0) = arr[0][0] + arr[i][0], f(0, j) = arr[0][0] + arr[0][j]。
状态转移方程: f(i, j) = min{f(i - 1, j), f(i, j - 1)} + arr[i][j]
可以把遍历过程中求出所有的f(i, j)的值,保存到另一个二维数组中供后续使用。
1 | public class Test { |
对二维数组遍历一次,时间复杂度为O(mn),申请了一个二维数组来保存中间结果,空间复杂度为O(mn)。
国王与金矿
知道 i-1 座金矿的最大产量就一定能知道 i 座金矿的最大产量,这是 最优子结构,每个人要知道i座金矿的最大产量就必须知道知道 i-1 座金矿的最大产量,这是 子问题重叠,最终当考虑第 1 座金矿的最大产量时,只要看是否有足够人手开采第 1 座金矿,有的话,答案是已探明的储量,没有的话就是0,然后答案汇报到上级,上级再得出第 2 座金矿开采与不开采得出的较大产量,再往上汇报…,这就是 边界,而每个人从上级得到的前提都是不同的,上级决定开不开采,再将这个前提之一告诉下属,而下属不需要考虑上级给另一个下属什么前提,这是 子问题独立。
把金矿数量设为n,工人数量设为w,金矿的黄金量设为g[],金矿的用工量设为p[]。
F(n, w) = 0 (n <= 1, w < p[0]);
F(n, w) = g[0] (n == 1, w >= p[0]);
F(n, w) = F(n - 1, w) (n > 1, w < p[n - 1]);
F(n, w) = max(F(n - 1, w), F(n - 1, w - p[n - 1]) + g[n - 1]) (n > 1, w >= p[n-1]);
递归算法
把状态转移方程翻译成递归程序,递归结束条件是方程中的边界。 因为每个状态有两个最优子结构,所以递归的执行流程类似于一棵高度为N的二叉树。 时间复杂度为O(2^n)。
1 | public static int getMostGold(int n, int w, int[] g, int[] p) { |
DP解法
画表格分析,表格第一列代表给定前1-5做金矿的情况,也就是N的取值。表格第一行代表给定的工人数,也就是w的取值。
其余空白格表示,给定n和w值对应的黄金获得数,也就是F(n,w)。
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | ||||||||||
2金矿 | ||||||||||
3金矿 | ||||||||||
4金矿 | ||||||||||
5金矿 |
第一个金矿的信息:400金,5工人
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
2金矿 | ||||||||||
3金矿 | ||||||||||
4金矿 | ||||||||||
5金矿 |
第二个金矿的信息:500金,5工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 500), 5-9格子为500,第2行第10个格子,n=2,w=10 F(n-1, w-5) = 400 Max(400, 400+500) = 900
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
2金矿 | 0 | 0 | 0 | 0 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | ||||||||||
4金矿 | ||||||||||
5金矿 |
第三个金矿的信息:200金,3工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 200)
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
2金矿 | 0 | 0 | 0 | 0 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
4金矿 | ||||||||||
5金矿 |
第四个金矿的信息:300金,4工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 300)
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
2金矿 | 0 | 0 | 0 | 0 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
4金矿 | 0 | 0 | 200 | 300 | 500 | 500 | 500 | 700 | 800 | 900 |
5金矿 |
第五个金矿的信息:350金,3工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 350)
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
2金矿 | 0 | 0 | 0 | 0 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
4金矿 | 0 | 0 | 200 | 300 | 500 | 500 | 500 | 700 | 800 | 900 |
5金矿 | 0 | 0 | 350 | 350 | 500 | 550 | 650 | 850 | 850 | 900 |
上述表格,比如5金矿10工人的结果,来自于4金矿7工人和4金矿10工人, Max(900, 500+350)=900
不需要存储整个表格,只需要存储前一行的结果,就可以推导出新的一行。使用动态规划如下:
1 | int getMostGold(int n, int w, int[] g, int[] p) { |
上述方法利用两层迭代,外层迭代对表格每一行的迭代过程中,会保留上一行的结果数组preResults,并循环计算当前的结果数组results。
方法的时间复杂度为O(n*w),空间复杂度是O(w)。
当金矿更多的时候,动态规划的优势就能体现出来。
然而,当工人为1000时,动态规划的时间复杂度为5 * 1000 = 5000,开辟1000单位的空间。 递归的时间复杂度是O(2^n),需要计算32次,开辟5单位(递归深度)的空间。
动态规划方法的时间和空间都和w成正比,而简单递归和w无关,所以工人很多的时候,动规反而不如递归。
所以说,每一种算法都没有绝对的好与坏,关键看应用场景。
备忘录解法
1 | // 该内部类对象用于备忘录算法中作为HashMap存储的键 |