一、概述
动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。
动态规划一般可分为线性动规,区域动规,树形动规,背包动规四类。
1、线性动规:拦截导弹,合唱队形,挖地雷,建学校,剑客决斗等;
2、区域动规:石子合并,加分二叉树,统计单词个数,炮兵布阵等;
3、树形动规:贪吃的九头龙,二分查找书,聚会的欢乐,数字三角形等;
4、背包问题:01背包问题,完全背包问题,分组背包问题,二维背包,装箱问题,挤牛奶等;
举例
A * "1+1+1+1+1+1+1+1 =?" *
A : "上面等式的值是多少"
B : *计算* "8!"
A *在上面等式的左边写上 "1+" *
A : "此时等式的值为多少"
B : *quickly* "9!"
A : "你怎么这么快就知道答案了"
B : "只要在8的基础上加1就行了"
B : "所以你不用重新计算因为你记住了第一个等式的值为8!动态规划算法也可以说是 '记住求过的解来节省时间'"
二、动态规划算法
1、动态规划基本思想
动态规划算法通常用于求解具有某种最优性质的问题
。在这类问题中,可能会有许多可行解,每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的
。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间
。由于动态规划解决的问题多数有重叠子问题这个特点,对每一个子问题只解一次,将其不同阶段子问题的最优局部解记录在一个二维数组中,依次解决各子问题,最后一个子问题就是初始问题的解,这样就可以避免大量的重复计算,节省时间。这就是动态规划法的基本思路。
动态规划核心思想
动态规划最核心的思想,就在于拆分子问题,记住子问题的解,减少重复计算
。
2、动态规划使用条件
能采用动态规划求解的问题通常要具备3个性质:
(1)最优化原理:如果
问题的最优解所包含的子问题的解也是最优的
,就称该问题具有最优子结构,即满足最优化原理
(2)无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关
。
(3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到
。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
举例:
在公司里面都有一定的组织架构,可能有高级经理、经理、总监、组长然后才是小开发。又到了一年一度的考核季,公司要挑选出三个最优秀的员工。一般高级经理会跟手下的经理说,你去把你们那边最优秀的3个人报给我,经理又跟总监说你把你们那边最优秀的人报给我,经理又跟组长说,你把你们组最优秀的三个人报给我,这个其实就动态规划的思想!
首先是重叠子问题,不同的问题,可能都要求1个相同问题的解。假如A经理想知道他下面最优秀的人是谁,他必须知道 X、Y、Z、O、P 组最优秀的人是谁, 甲总监想知道自己下面最优秀的人是谁,也要去知道 X、Y、Z 组里面最优秀的人是谁?这就有问题重叠了,两个人都需要了解 X、Y、Z 三个小组最优秀的人。
其次是最优子结构,最优解肯定是有最优的子解转移推导而来,子解必定也是子问题的最优解。甲总监下面最优秀的3个人肯定是从 X、Y、Z 提交上来的3份名单中选择最优秀的三个人。
第三是无后效性,这个问题可能比较难理解,也就是求出来的子问题并不会因为后面求出来的改变。我们可以理解为,X组长挑选出三个人,即便到了高级经理选出大部门最优秀的三个人,对于X组来说,最优秀的还是这3个人,不会发生改变。
3、动态规划问题求解的步骤
动态规划的设计都有一定的模式,一般要经历一下几个步骤:
(1)划分阶段:
按照问题的时间或空间特征,
把问题分为若干个阶段
。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的
,否则问题就无法求解。即划分子问题,例如上面的例子,我们可以认为每个组下面、每个部门、每个中心下面最优秀的3个人,都是全公司最优秀的3个人的子问题。(2)确定状态和状态变量:
将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。即如何让计算机理解子问题。上述例子,我们可以用 f[i][3] 表示第 i 个人,他手下最优秀的3个人是谁。
(3)确定决策并写出状态转移方程:
因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,
根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程
。即父问题是如何由子问题推导出来的。上述例子,每个大 Leader 下面最优秀的人等于他下面的小 Leader 中最优秀的人中最优秀的几个。(4)寻找边界条件:
给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件
。即确定初始状态是什么?最小的子问题?最终状态又是什么。例如上述问题,最小的子问题就是每个小组长下面最优秀的人,最终状态是整个企业,初始状态为每个领导下面都没有最优名单,但是小组长下面拥有每个人的评分。一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。
实际应用中可以按以下几个简化的步骤进行设计:(1)分析最优解的性质,并刻画其结构特征;(2)递归的定义最优解;(3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值;(4)根据计算最优值时得到的信息,构造问题的最优解。
递推关系必须是从次小的问题开始到较大的问题之间的转化,从这个角度来说,动态规划往往可以用递归程序来实现,不过因为递推可以充分利用前面保存的子问题的解来减少重复计算,所以对于大规模问题来说,有递归不可比拟的优势,这也是动态规划算法的核心之处。
确定了动态规划的这三要素,整个求解过程就可以用一个最优决策表来描述,最优决策表是一个二维表,其中行表示决策的阶段,列表示问题状态,表格需要填写的数据一般对应此问题的在某个阶段某个状态下的最优值
(如最短路径,最长公共子序列,最大价值等),填表的过程就是根据递推关系,从1行1列开始,以行或者列优先的顺序,依次填写表格,最后根据整个表格的数据通过简单的取舍或者运算求得问题的最优解
。
4、动态规划算法的两种形式
前面已经知道动态规划算法的核心是记住已经求过的解,记住求解的方式有两种:①自顶向下的备忘录法、 ②自底向上。
(1)自顶向下的动态规划:带备忘录的递归
leetcode 青蛙跳阶问题:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 10 级的台阶总共有多少种跳法?
试想:要想跳到第10级台阶,要么是先跳到第9级然后再跳1级台阶上去,要么是先跳到第8级然后一次迈2级台阶上去;同理,要想跳到第9级台阶,要么是先跳到第8级然后再跳1级台阶上去,要么是先跳到第7级然后一次迈2级台阶上去;要想跳到第8级台阶,要么是先跳到第7级然后再跳1级台阶上去,要么是先跳到第6级,然后一次迈2级台阶上去。
假设跳到第n级台阶的跳数我们定义为f(n),很显然就可以得出以下公式:
f(10) = f(9)+f(8)
f (9) = f(8) + f(7)
f (8) = f(7) + f(6)
...
f(3) = f(2) + f(1)
即递推公式为: f(n) = f(n-1) + f(n-2)
最初的子问题 f(2) 和 f(1) 我们是可以轻松解决的:当只有2级台阶时,有两种跳法,第一种是直接跳两级,第二种是先跳一级,然后再跳一级。即 f(2) = 2;当只有1级台阶时,只有一种跳法,即 f(1) = 1。
因此可以用递归去解决这个问题:
class Solution {
public int numWays(int n) {
if(n == 1){
return 1;
}
if(n == 2){
return 2;
}
return numWays(n-1) + numWays(n-2);
}
}
但是在 leetcode 提交发现超出时间限制。为什么超时了呢?递归耗时在哪里呢?先画出递归树看看:
要计算原问题 f(10),就需要先计算出子问题 f(9) 和 f(8);然后要计算 f(9),又要先算出子问题 f(8) 和 f(7),以此类推。一直到 f(2) 和 f(1),递归树才终止。
这个递归的时间复杂度:
递归时间复杂度 = 解决一个子问题时间 * 子问题个数
一个子问题时间 = f(n-1)+f(n-2),也就是一个加法的操作,所以复杂度是 O(1);问题个数 = 递归树节点的总数,递归树的总节点 = 2^(n-1) ,所以是复杂度O(2^n)。因此,青蛙跳阶,递归解法的时间复杂度 = O(1) * O(2^n) = O(2^n),就是指数级别的,爆炸增长的,如果n比较大的话,超时很正常的了。
优化:
仔细观察会发现这棵递归树存在大量重复计算,比如 f(8) 被计算了两次,f(7) 被重复计算了3次…所以这个
递归算法低效的原因,就是存在大量的重复计算
!
既然存在大量重复计算,那么我们可以先把计算好的答案存下来,即造一个备忘录,等到下次需要的话,先去备忘录查一下,如果有,就直接取就好了,备忘录没有才开始计算,那就可以省去重新重复计算的耗时啦!这就是带备忘录的动态规划解法
。
带备忘录的递归解法(自顶向下)
一般使用一个数组或者一个哈希map充当这个备忘录。
第一步,f(10)= f(9) + f(8),f(9) 和f(8)都需要计算出来,然后再加到备忘录中,如下:
第二步, f(9) = f(8) + f(7),f(8) = f(7) + f(6),因为 f(8) 已经在备忘录中啦,所以可以省掉,f(7) 和 f(6) 都需要计算出来,加到备忘录中~
第三步, f(8) = f(7) + f(6),发现 f(8)、f(7) 和 f(6)全部都在备忘录上了,所以都可以剪掉。
所以呢,用了备忘录递归算法,递归树变成光秃秃的树干咯,如下:
带备忘录的递归算法,子问题个数=树节点数=n,解决一个子问题还是O(1),所以带备忘录的递归算法的时间复杂度是O(n)。接下来呢,我们用带备忘录的递归算法来解决这个青蛙跳阶问题的递归超时问题,代码如下:
public class Solution {
//使用哈希map,充当备忘录的作用
Map<Integer, Integer> tempMap = new HashMap();
public int numWays(int n) {
// n = 0 也算1种
if (n == 0) {
return 1;
}
if (n <= 2) {
return n;
}
//先判断有没计算过,即看看备忘录有没有
if (tempMap.containsKey(n)) {
//备忘录有,即计算过,直接返回
return tempMap.get(n);
} else {
// 备忘录没有,即没有计算过,执行递归计算,并且把结果保存到备忘录map中
tempMap.put(n, (numWays(n - 1) + numWays(n - 2)));
return tempMap.get(n);
}
}
}
自顶向下的动态规划步骤:
1)穷举分析、2)确定最优子结构、3)确定状态转移方程、4)确定边界、5)确定重叠子问题
总结:
带备忘录的递归解法就是自顶向下的动态规划
,是从 f(10) 往 f(1) 方向延伸求解,也就是从大问题向子问题方向求解
,减少重复计算,时间复杂度为 O(n)。
(2)自底向上的动态规划
自顶向下的备忘录法使用了递归,计算 fib(10) 的时候最后还是要计算出 fib(1)、fib(2)、fib(3)…,那么何不先计算出 fib(1)、fib(2),、ib(3)…呢?这就是
自底向上动态规划的核心,先计算子问题,再由子问题计算父问题
。
我们来看下自底向上的解法,从 f(1) 往 f(10) 方向,一个 for 循环就可以解决,如下:
带备忘录的递归解法,空间复杂度是 O(n),但是仔细观察上图,可以发现,f(n) 只依赖前面两个数,所以只需要两个变量a和b来存储,就可以满足需求了,因此空间复杂度是O(1)就可以。
自底向上的动态规划实现代码如下:
public class Solution {
public int numWays(int n) {
if(n==1)
return 1;
if(n==2)
return 2;
int a = 1; // 备忘录记录临界值n为1的值
int b = 2; // 备忘录记录临界值n为2的值
int temp = 0; // dp[0]
for (int i = 3; i <= n; i++) {
temp = a + b; // 求f(n)
a = b; // 求f(n-2)
b = temp; // 求f(n-1)
}
return temp;
}
}
自底向上的动态规划结题思路:
穷举分析;
确定边界;
找规律,确定最优子结构;
写出状态转移方程;
1)穷举分析
2)确定边界
3)找规律,确定最优子结构
4)写出状态转移方程
自底向上的递归代码实现模板
dp[0][0][...] = 边界值
for(状态1 :所有状态1的值){
for(状态2 :所有状态2的值){
for(...){
//状态转移方程
dp[状态1][状态2][...] = 求最值
}
}
}
两种方式总结
一般来说由于
备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销
;使用自底向上的动态规划方法只使用循环和常数个空间,要比备忘录方法好
。
5、动态规划的经典模型
(1)线性模型
线性模型的是动态规划中最常用的模型,这里的线性指的是状态的排布是呈线性的。
(2)区间模型
区间模型的状态表示一般为 d[i][j],表示区间 [i, j] 上的最优解,然后通过状态转移计算出 [i+1, j] 或者 [i, j+1] 上的最优解,逐步扩大区间的范围,最终求得 [1, len] 的最优解。
(3)树形模型
(4)背包模型