浅谈动态规划(1)基础篇
动态规划(Dynamic programming [DP])是工程中非常重要的解决问题思想,从我们日常生活中导航软件寻求最短路径(详见),再到网购中如何通过凑单来利用优惠券等来最大程度的获取优惠,都离不开动态规划。但对于初学者来说DP确实是比较难的,状态转移方程也让人摸不着头脑,参考了其他一些优秀文章,我想用一些浅显易懂的方法来思考这一工程思想。
什么是动态规划?
以下是综合了动态规划的特点给出的动态规划的定义:动态规划是一种多阶段决策最优解模型,一般用来求最值问题,多数情况下它可以采用自下而上的递推方式来得出每个子问题的最优解(即最优子结构),进而自然而然地得出依赖子问题的原问题的最优解。
多阶段决策:意思是可将问题分解为若干成子问题,子子问题,以此类推,也就是一个问题可以拆分为多个子问题来进行求解。
最优子结构:在自下而上的递推过程中,求得的每个子问题必然是是全局最优解,其所分解的子问题是全局最优解,那么基于它们解的原问题自然也是全局最优解。
自下而上: 如何自下而上得出每个问题的最优解,子问题之间肯定是存在一定联系的,即迭代递推公式(状态转移方程),要定义好状态转移方程, 则需要定义好每个子问题的状态(DP 状态),那为什么要自下而上来进行求解,因为如果采用像递归这样自上向下来求解,子问题之间可能存在大量的重叠,大量地重叠子问题则是存在大量地重复计算,这样时间复杂度很可能呈指数级上升(下文会有所体现),而自下而上的求解方式可以消除重叠子问题。总之,如果状态转移方程定义好了的话,那么动态问题并不算太难。
既然我们了解了动态规划的基本概念及其特征,那判断题目是否可以使用动态规划来求解也很简单了。当所拿到问题的定义为最值问题,问题可采用递归的方式,且递归的过程中有一定量重复子问题时,该问题基本上可以用动态规划的思想来解决。基本思路如下:
先判断问题是否可用递归来解
分析在递归的过程中是否有在大量的重复子问题
采用备忘录的方式来存子问题的解以避免大量的重复计算(剪枝)
改为自下而上的方式来进行递推,即DP
下面通过一些例子来加深理解:
基础1:Fibonacci sequence
Fibonacci sequence: Each number in the sequence is the sum of the two numbers that precede it. So, the sequence goes: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, and so on. The mathematical equation describing it is Xn+2= Xn+1 + Xn.
Fibonacci sequence问题想必我们都不陌生,下面我们通过上述步骤来解决该问题。
先判断是否可用递归
import java.util.*;
public class FibonacciS {
public static void main(String[] args) {
int ret=0;
Scanner input = new Scanner(System.in);
int n=nextInt();
ret = fibonacci(n);
System.out.println(ret);
input.close();
}
public static int fibonacci(int n) {
if (n == 1)
return 1;
if (n == 2)
return 2;
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
采用递归树分析是否存在大量子问题:
可以看到单单是求 f(6),就存在两次重复计算, f(4) 求解了两次,f(3) 求解了两次,且时间复杂度呈指数级别,递归时间复杂度为解决每个子问题需要的时间乘以子问题总数,求解每个子问题需要的时间即 f(n) = f(n-1) + f(n-2) 只做了一次加法运算,每个问题一分为二,是个二叉树,可以看到第一层 1 个,第二层 2 个,第三层 4 个,即 1 + 2 + 2^2 + .... 2^n,所以说时间复杂度(O(2^n))指数级别。
P.S 求解问题 f(6), 可转化成求 f(5), f(4), 从原问题出发,分解成求子问题,子问题再分解成子子问题,以此类推,直至不能再分解,这种从原问题展开子问题进行求解的方式叫自上而下
采用备忘录的方式来存储子问题的解以避免大量的重复计算。既然上述中间子问题中存在着大量的重复计算,我们可以对这些中间结果进行缓存(可以用Hash table缓存)。
public static int fibonacci(int n) {
if (n = 1)
return 1;
if (n = 2)
return 2;
if (map.get(n) != null) {
return map.get(n);
}
int result = fibonacci(n - 1) + fibonacci(n - 2);
map.put(n, result);
return result;
}
缓存之后再看我们的递归树
可以看到通过缓存中间的数据,做了大量地剪枝的工作,同样的f(4),f(3),f(2),都只算一遍了,少去了大量的重复计算,问题的规模从二叉树变成了单链表(即 n),时间复杂度变成了 O(n),不过由于哈希表缓存了所有的子问题的结果,空间复杂度是 O(n)。
DP:
f(1) = 1
f(2) = 2
f(3) = f(1) + f(2) = 3
f(4) = f(3) + f(2) = 5
....
f(n) = f(n-1) + f(n-2)
只要依次自底向上求出 f(3),f(4),...,最后就求出了 f(n)
P.S 从最终结果不能再分解的子问题根据递推方程(f(n) = f(n-1) + f(n-2))逐渐求它上层的问题,上上层问题,最终求得最初问题,这种方式就叫自下而上。
f(n) 就是定义的每个子问题的状态(DP 状态),f(n) = f(n-1) + f(n-2) 就是状态转移方程,即 f(n) 由 f(n-1), f(n-2) 这两个状态转移而来,由于每个子问题只与它前面的两个状态,我们只要定义三个变量,自底向上不断循环迭代即可。
public int f(int n) {
if (n == 1)
return 1;
if (n == 2)
return 2;
int result = 0;
int pre = 1;
int next = 2;
for (int i = 3; i < n + 1; i ++) {
result = pre + next;
pre = next;
next = result;
}
return result;
}
时间复杂度虽然还是O(n),但空间复杂度只由于只定义了三个变量(result,pre,next)所以为常量 O(1)。
至此,相信大家对DP有了一定的了解,在明天的文章中,我么来继续深入讨论。