动态规划 -----2019-3-16记录

0-引子:Fibonacci sequence

这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波纳契数列以如下被以递推的方法定义:F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)

现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为0)。

  1. 简单递归的方法:

    	public static int fb(int n){
    	if(n<=0) return 0;
    	if(n==1) return 1;
    	if(n>1) return fb(n-1)+fb(n-2);	
    

}
```
既然是用递归实现的方法,那么我们该怎么优化呢?
先来看看递归实现时的递归树。

由上面的递归树我们可以很明显的看到一个问题,我们有很多重复的节点出现了,从java的角度来看,每次调用一个函数都会创建新的栈帧,而重复的调用则会导致创建很多重复的栈帧,很大程度上消耗了我们java虚拟机栈所占的内存空间,也很容易导致栈溢出。我们想一下,如果我们可以在每一次计算出对应的节点结果后将其保存,那么我们在重复调用函数的时候就可以减少计算所带来的时间与空间消耗,从而直接调用已知的结果。
通过上面的思考,我们就可以得到以下的方法。

  1. 自顶向下的备忘录法
public static int fbm(int n){
	if(n<=0) return 0;
	int [] memo = new int[n+1];
	for(int i = 0; i<=n; i++){
		memo[i] = -1;
	}
	return fb(n, memo);
}

public static int fb(int n, int [] memo){
	if(memo[n]!=-1) return memo[n];
	if(n<=2) memo[n] =1;
	else{
		memo[n] = fb(n-1, memo) + fb(n-2, memo);
	}
	return memo[n];
}
备忘录法也是比较好理解的,创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fb(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在memo数组中,下次在调用fb(n)的时候就不会重新递归了。比如上面的递归树中在计算fb(6)的时候先计算fb(5),调用fb(5)算出了fb(4)后,fb(6)再调用fb(4)就不会在递归fb(4)的子树了,因为fb(4)的值已经保存在memo[4]中。
但是这种方法还存在着一个问题,这种方法依旧是利用递归的方法,依然存在着空间消耗问题。既然我们计算fb(n)的时候要计算出fn(n-1)以及fb(n-2),那我们为什么不先计算出fn(n-1)以及fb(n-2)?
  1. 自底向上的动态规划
public static int fb(int n){
	if(n<=0) return 0;
	int [] memo = new int[n+1];
	memo[0] = 0;
	memo[1] = 1;
	for(int i = 2; i<=n; i++){
		memo[i] = memo[i-1] + memo[i-2];
	}
	return memo[n];
}

上面的这种方法在每个对应的memo[n]中保存了对应的斐波拉契数列的值,考虑到建立数组所带来的空间消耗,同时我们从上面的式子可以发现参与循环的其实只有i,i-1,i-2这三项,此时我们可以吧该方法所消耗的空间进一步压缩。
public static int fb(int n){
	if(n<=0) return 0;
	if(n==1) return 1;
	int fb_2 = 0;
	int fb_1 = 1;
	int fb =0;
	for(int i = 2; i <=n; i++){
		fb = fb_1 + fb_2;
		fb_2 = fb_1;
		fb_1 = fb; 
	}
	return fb;
}

1-什么是动态规划?

动态规划是求解决策过程最优化的数学方法。把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。

2-在什么情况下应该去使用动态规划?

如果要求一个问题的最优解(通常是最大值或者最小值),而且该问题能够分解成若干个子问题,并且小问题之间也存在重叠的子问题,则考虑采用动态规划。

3-动态规划原理

最优子结构

用动态规划求解最优化问题的第一步就是刻画最优解的结构,如果一个问题的解结构包含其子问题的最优解,就称此问题具有最优子结构性质。因此,某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个很好的线索。使用动态规划算法时,用子问题的最优解来构造原问题的最优解。因此必须考查最优解中用到的所有子问题。

重叠子问题

在斐波拉契数列中,可以看到大量的重叠子问题,比如说在求fb(6)的时候,fb(2)被调用了5次。如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。

4-如何去使用动态规划?

这时候我们就可以根据动态规划的定义,以五步走的形式进行动态规划。

1. 判题题意是否为找出一个问题的最优解 
2. 从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题 
3. 从下往上分析问题 ,找出这些问题之间的关联(`状态转移方程`) 
4. 讨论底层的边界问题 
5. 解决问题(通常使用数组进行迭代求出最优解)

5-举几个例子来看看

青蛙跳台阶

跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个n级台阶总共有多少种跳法。

1、判题题意是否为找出一个问题的最优解 
这个我还真的看不出。

2、从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题 
题目中没有给粟子,我们可以自己举点粟子。例如,跳上一个6级台阶台阶,有多少种跳法;由于青蛙一次可以跳两阶,也可以跳一阶,所以我们可以分成两个情况 
1、青蛙最后一次跳了两阶,问题变成了“跳上一个4级台阶台阶,有多少种跳法” 
2、青蛙最后一次跳了一阶,问题变成了“跳上一个5级台阶台阶,有多少种跳法” 
由上可得f(6) = f(5) + f(4); 
由此类推,f(4)=f(3) +f(2)

3.、从下往上分析问题 ,找出这些问题之间的关联(状态转移方程) 
跟上面的例题一相同,可以由f(1)逐渐迭代上去 
由2可得,状态转移方程为:f(n)=f(n-1)+f(n-2)

4、边界情况分析 
跳一阶时,只有一种跳法,所以f(1)=1 
跳两阶时,有两种跳法,直接跳2阶,两次每次跳1阶,所以f(2)=2 
跳两阶以上可以分解成上面的情况
public static int jump(int n) {
        //无意义的情况
        if(n <= 0)
            return 0;
        if(n == 1)
            return 1;
        if(n == 2)
            return 2;
        //数组用于存储跳n阶的跳法数
        int[] value = new int[n + 1];
        value[0] = 0;
        value[1] = 1;
        value[2] = 2;
        for(int i = 3; i <= n; i++) {
            value[i] = value[i - 1] + value[i - 2];
        }
        return value[n];
}

剪绳子

剪绳子
给你一根长度为n的绳子,请把绳子剪成m段 (m和n都是整数,n>1并且m>1)每段绳子的长度记为k[0],k[1],…,k[m].请问k[0]k[1]…*k[m]可能的最大乘积是多少?

例如,当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18. 
看完题目,我们按照上面提到的“动态规划五部”解决问题 
1、判题题意是否为找出一个问题的最优解 
看到字眼是“可能的最大乘积是多少”,判断是求最优解问题,可以用动态规划解决;

2、从上往下分析问题,大问题可以分解为子问题,子问题中还有更小的子问题 
题目中举了个例子:当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18;我们可以从这里开始突破,把长度为8绳子的最大乘积分解为数个子问题,长度为8我们可以把它看成长度为1和7的绳子的和,或者长度 为2和6的绳子的和,或者长度为3和5的绳子的和and so on! 
到这里,相信大家已经看到一丝真理了吧?

3. 从下往上分析问题 ,找出这些问题之间的关联(状态转移方程) 
在第二点时,我们已经从上到下分析问题了,现在我们要从下往上分析问题了。分析可知, 
f(8) 的值就是f(1)*f(7),f(2)*f(6),f(3)*f(5),f(4)*f(4)它们之中的最小值,即f(8) = Max{f(1)*f(7),f(2)*f(6),f(3)*f(5),f(4)*f(4)} 
只要知道f(1)到f(7)的值就能求出f(8);对于f(7),只要知道f(1)到f(6)的值就能求出f(6);对于f(6),只要知道f(1)到f(5)的值就能求出f(6);以些类推,我们只要知道前几个边界的值,就能一步步迭代出后续的结果! 
状态转移方程: f(n)=Max{f(n-i)*f(i)} i={1,2,3,…,n/2}
	
4. 讨论底层的边界问题 
底层的边界问题说的就是最小的前几个数值的f(n)的值,本题中就是f(0)、f(1)、f(2)、f(3)的值 
对于f(0),长度为0的绳子,没办法剪,没有意义 
对于f(1),长度为1的绳子,没办法剪,设为1 
对于f(2),长度为2的绳子,只有一种剪法,剪成两段长度为1的绳子,但剪后的乘积为1,比自身更小;如果不是求自身的值,要求乘积最大值的话就没必要剪。 
对于f(3),长度为3的绳子,只有一种剪法,剪成两段长度为1和2的绳子,但剪后的乘积为2,比自身更小;如果不是求自身的值,要求乘积最大值的话也没必要剪。 
public static int cutting(int n) {
        //长度小于等等于1没办法剪
        if(n <= 1)
            return 0;
        //对于f(2),长度为2的绳子,只有一种剪法,剪成两段长度为1的绳子,剪后的乘积为1
        if(n == 2)
            return 1;
        //对于f(3),长度为3的绳子,只有一种剪法,剪成两段长度为1和2的绳子,但剪后的乘积为2
        if(n == 3)
            return 2;
        int max = 0;
        //数组用于存储绳子乘积最大值
        int value[] = new int[n + 1];
        value[0] = 0;
        value[1] = 1;
        //剪后的乘积为1,比自身更小;如果不是求自身的值,要求乘积最大值的话就没必要剪
        value[2] = 2;
        //剪后的乘积为2,比自身更小;如果不是求自身的值,要求乘积最大值的话也没必要剪
        value[3] = 3;
        //从f(4)开始迭代
        for(int i = 4;i <= n; i++) {
            max = 0;
            for(int j = 1;j <= i/2; j++) {
                int val = value[j] * value[i - j];
                max = val > max ? val : max;
            }
            value[i] = max;
        }
        max = value[n];
        return max;
}

6-动态规划的经典模型

线性模型

线性模型的是动态规划中最常用的模型,这里的线性指的是状态的排布是呈线性的。

在一个夜黑风高的晚上,有n(n <= 50)个小朋友在桥的这边,现在他们需要过桥,但是由于桥很窄,每次只允许不大于两人通过,他们只有一个手电筒,所以每次过桥的两个人需要把手电筒带回来,i号小朋友过桥的时间为T[i],两个人过桥的总时间为二者中时间长者。问所有小朋友过桥的总时间最短是多少。

每次过桥的时候最多两个人,如果桥这边还有人,那么还得回来一个人(送手电筒),也就是说N个人过桥的次数为2*N-3(倒推,当桥这边只剩两个人时只需要一次,三个人的情况为来回一次后加上两个人的情况…)。有一个人需要来回跑,将手电筒送回来(也许不是同一个人,realy?!)这个回来的时间是没办法省去的,并且回来的次数也是确定的,为N-2,如果是我,我会选择让跑的最快的人来干这件事情,但是我错了…如果总是跑得最快的人跑回来的话,那么他在每次别人过桥的时候一定得跟过去,于是就变成就是很简单的问题了,花费的总时间:

T = minPTime * (N-2) + (totalSum-minPTime)

来看一组数据 四个人过桥花费的时间分别为 1 2 5 10,按照上面的公式答案是19,但是实际答案应该是17。

具体步骤是这样的:

第一步:1和2过去,花费时间2,然后1回来(花费时间1);

第二歩:3和4过去,花费时间10,然后2回来(花费时间2);

第三部:1和2过去,花费时间2,总耗时17。

所以之前的贪心想法是不对的。我们先将所有人按花费时间递增进行排序,假设前i个人过河花费的最少时间为opt[i],那么考虑前i-1个人过河的情况,即河这边还有1个人,河那边有i-1个人,并且这时候手电筒肯定在对岸,所以opt[i] = opt[i-1] + a[1] + a[i] (让花费时间最少的人把手电筒送过来,然后和第i个人一起过河)如果河这边还有两个人,一个是第i号,另外一个无所谓,河那边有i-2个人,并且手电筒肯定在对岸,所以opt[i] = opt[i-2] + a[1] + a[i] + 2a[2] (让花费时间最少的人把电筒送过来,然后第i个人和另外一个人一起过河,由于花费时间最少的人在这边,所以下一次送手电筒过来的一定是花费次少的,送过来后花费最少的和花费次少的一起过河,解决问题)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2
a[2] }

区间模型

区间模型的状态表示一般为d[i][j],表示区间[i, j]上的最优解,然后通过状态转移计算出[i+1, j]或者[i, j+1]上的最优解,逐步扩大区间的范围,最终求得[1, len]的最优解。

给定一个长度为n(n <= 1000)的字符串A,求插入最少多少个字符使得它变成一个回文串。

典型的区间模型,回文串拥有很明显的子结构特征,即当字符串X是一个回文串时,在X两边各添加一个字符’a’后,aXa仍然是一个回文串,我们用d[i][j]来表示A[i…j]这个子串变成回文串所需要添加的最少的字符数,那么对于A[i] == A[j]的情况,很明显有 d[i][j] = d[i+1][j-1] (这里需要明确一点,当i+1 > j-1时也是有意义的,它代表的是空串,空串也是一个回文串,所以这种情况下d[i+1][j-1] = 0);当A[i] != A[j]时,我们将它变成更小的子问题求解,我们有两种决策:
1、在A[j]后面添加一个字符A[i];

2、在A[i]前面添加一个字符A[j];

根据两种决策列出状态转移方程为:

d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1; (每次状态转移,区间长度增加1)

背包模型

有N种物品(每种物品1件)和一个容量为V的背包。放入第 i 种物品耗费的空间是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。f[i][v]表示前i种物品恰好放入一个容量为v的背包可以获得的最大价值。决策为第i个物品在前i-1个物品放置完毕后,是选择放还是不放.

状态转移方程为:
f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +Wi }

7-扩展

变态跳台阶

一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

矩形覆盖

我们可以用21的小矩形横着或者竖着去覆盖更大的矩形。请问用n个21的小矩形无重叠地覆盖一个2*n的大矩形,总共有多少种方法?

posted @ 2019-03-17 17:09  慵懒的仙人掌  阅读(293)  评论(0编辑  收藏  举报