本文介绍一种算法技术--动态规划。
一、什么是动态规划
动态规划与分治方法相似,通过组合子问题的解来求解问题。分治法会在计算时做许多不必要的工作(记不记得之前的一篇博客分析过没有优化过的斐波那契数列递归实现的时间复杂度高达指数级),然而动态规划则会把求过的子问题记录到一个容器中,从而避免了一些重复的工作。
我们常常使用动态规划来求解最优化问题,步骤如下:
1、刻画一个最优解的结构特征;
2、递归定义最优解的值;
3、计算最优解的值,通常我们采用自底向上的方法;
4、利用已经构造出来的信息构造一个最优解。
动态规划通常会比朴素的递归算法多一些空间开销,但是对时间复杂度的减少作用是巨大的,可以把指数级的时间复杂度减少为多项式级的时间复杂度。
动态规划的关键在于状态转移方程的定义。本质思想是每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的,相当于一种大事化小,小事化了的思想。
二、动态规划的例子以及与其他算法的比较
1、钢条切割
我们先来看一个简单的例子:给定一个长度为n的钢条和一个价格表,尝试对钢条进行切割,也可能不切割直接达到最大利益,切割不消耗收益。
例子要求求解的最大利益,这是一个求解一个最优解的问题,对于这种问题,问题的最优解可以由相关子问题的最优解组合而成,对于子问题再独立求解。
实际上上面这个题目我们可以使用下面这个状态转化公式将一个问题转化为一个子问题:
也就是对于一个长度为n的钢条的切割问题,我们把整根钢条转化切割成两部分,一部分长为i,一部分长为n-i,我们对长为i的钢条部分不做切割,对n-i的长度进行切割,且对n-i的长度的切割时最优切割。这样我们就把一个问题转化成了一个子问题,对n的最优切割的利益就变成了求对n-i长度进行切割的利益加上i长度钢条的利益再i属于[1,n]范围内的最大值。
基于这个想法我们使用下面几种方法来用Java实现:
①、自顶向下递归实现(朴素算法)
public static int cutRodWithRecursion(int[] price, int n){
if(n<0||price.length==0)
throw new IllegalArgumentException("非法参数");
if(n == 0)
return 0;
int result = Integer.MIN_VALUE;
for(int i = 1; i <= n; i++){
if(i == price.length)
break;
int temp = cutRodWithRecursion(price, n-i) + price[i];
if(result < temp)
result = temp;
}
return result;
}
代码不用做太多的解释,完全就是基于我们分析思想的翻译。我们需要分析的是这种方法的缺点,就像之前写的斐波那契数列数列求解的复杂度分析(想要回顾请点击此处)一样,这里的朴素递归存在大量的计算重复,它的计算的时间复杂度是指数级别的,非常糟糕。
②、动态规划
我们可以把朴素递归转化为一个更高效的动态规划算法。朴素递归算法之所以效率低,是因为它反复求解相同的子问题,因此动态规划问题要求仔细安排求解顺序,对每个子问题只求解一次。当然这种记录会导致空间上会付出一些额外的东西,但是对于时间上的节省是巨大的。
动态规划有两种等价的实现方式:
a、带备忘录的自顶向下法:这个方法的核心思想在于在朴素递归的基础上,增加了一个备忘机制(使用数组或者散列表之类的数据结构),需要一个子问题的解时,可以直接检查备忘,判断之前是否计算过这个子问题,如果是直接返回这个子问题的解,否则进行子问题的计算。我们来进行实现:
private static int cutRodMemo(int[] price, int length, int[] memo){
//已经存在备忘录对应的元素
if(memo[length]>=0)
return memo[length];
int temp = 0;
//要切割的钢条为0的时候直接返回0
if(length != 0){
temp = Integer.MIN_VALUE;
for( int i = 1; i < price.length&&i <= length ; i++){
int result = 0;
result = price[i]+cutRodMemo(price, length-i, memo);
if(temp < result)
temp = result;
}
}
memo[length] = temp;
return temp;
}
public static int cutRodWithMemoUpToDown(int[] price, int length){
int[] memo = new int[length+1];
for(int i = 0; i <= length; i++)
memo[i] = Integer.MIN_VALUE;
return cutRodMemo(price, length, memo);
}
我们在cutRodMemo方法中封装了对钢条切割最优情况的求解,传入了一个数组memo表示备忘机制。我们在cutRodMemoUpToDown方法中创建memo,对cutRodMemo再做一步封装,对外只暴露出cutRodMemo方法。
b、自底向上法:自顶向下法是我们需要求解一个问题的时候,再尝试去求解其子问题,然而自底向上法是我们先求解子问题,在子问题的基础上去求解这些子问题组成的问题,当我们需要求解一个有子问题的问题时,发现其子问题已经被求解出来了。实现如下:
public static int cutRodDownToUp(int[] price, int length){
int[] memo = new int[length+1];
memo[0] = 0;
for(int j = 1; j <= length; j++){
int temp = Integer.MIN_VALUE;
for(int i = 1 ; i <= j && i < price.length ; i++)
if(temp < price[i] + memo[j - i])
temp = price[i] + memo[j - i];
memo[j] = temp;
}
return memo[length];
}
同样我们创建一个memo数组来存储子问题的解,每当我们需要求解一个问题而又需要其子问题的解时,我们就去memo数组查找存储的子问题的解。本质就是先求解子问题,记录,然后利用已有子问题的解获取更大的问题的解。
这两种问题的渐进运行时间相同,都是Θ(n^2),但是自底向上方法的系数更小。
2、卡特兰数
卡特兰数是一种经典的组合数,满足以下性质:
h(0) = 1,h(1) = 1,catalan数满足递推式:h(n) = h(0)*h(n-1)+h(1)+h(n-2)+...+h(n-1)*h(0) (n>=2)。
当然上面这个递推公式还可以再进一步化简:
卡特兰数有很多的应用:
a、出栈次序:对于一个栈进栈次序为1、2、3...n。求不同的出栈次序的种类。
b、二叉树构成问题:有n个结点,求总共能够成多少种不同的二叉树。
c、凸多边形的三角形划分:一个凸的n边形,用直线连接两个定点使之分成多个三角形,直线不相交。
d、其他
当然我们可以直接通过递推公式化简之后的公式直接计算得出结果,函数如下:
private static int getCatalanByCalDirectlyMemo(int n, ArrayList<Integer> factorial){
return combination(n, n<<1, factorial)/(n+1);
}
public static int getCatalanByCalDirectly(int n){
ArrayList<Integer> factorial = new ArrayList<Integer>();
return getCatalanByCalDirectlyMemo(n, factorial);
}
//计算阶乘 存储在备忘机制中
private static void calFactorial(ArrayList<Integer> factorial, int n){
if( factorial.size() == 0)
factorial.add(1);
for(int i = factorial.size(); i <= n; i++){
factorial.add(factorial.get(i-1)*i);
}
}
//计算组合
private static int combination(int superScript, int subScript, ArrayList<Integer> factorial){
return arrange(superScript, subScript, factorial)/factorial.get(superScript);
}
//计算排列
private static int arrange(int superScript, int subScript, ArrayList<Integer> factorial){
//开辟一个备忘录存储前n个元素的阶乘
calFactorial(factorial, subScript);
return factorial.get(subScript)/factorial.get(subScript-superScript);
}
但是我们这里假装不知道这个递推公式的化简公式,我们使用动态规划利用递推式来求解这个问题:
a、带备忘自顶向下递归
public static int getCatalanUpToDownWithMemo(int n){
int[] catalan = new int[n+1];
catalan[0] = 1;
catalan[1] = 1;
return catalanMemo(n, catalan);
}
private static int catalanMemo(int n , int[] catalan){
if(catalan[n]>0)
return catalan[n];
int result = 0,i = 0;
for(i = 0; i < n ; i++){
result += catalanMemo(i,catalan)*catalanMemo(n-i-1,catalan);
}
catalan[i] = result;
return result;
}
b、自底向上
public static int getCatalanBottomToUp(int n){
if(n==1||n==0)
return 1;
int catalan[] = new int[n+1];
catalan[0] = 1;
catalan[1] = 1;
for(int i = 2; i <= n; i++){
catalan[i] = 0;
for(int j = 0; j < i; j++)
catalan[i] += catalan[j]*catalan[i-1-j];
}
return catalan[n];
}
这些东西和第一个例子基本一致,不做过多分析了。
3、爬楼梯情况
假设对于一座n层高的楼梯,我们从下往上走,每次只能上1级或者2级台阶,求解到底有多少种走法。
如果使用排列组合的思想使用多层嵌套循环遍历复杂度实在是太高,高达了指数级,显然不是我们想要的,我们也就不实现了。
我们以动态规划思路为核心来思考这个问题。比如现在我们想爬到第十层,实际上对于第十层我们只能由两个状态得到,就是从第九层跨一步到第十层以及从第八层跨两步到第十层,也就是对于到第十层的可能等于到第八层的肯可能到可能到第八层的可能。我们把这个推论扩大,使用F(h)表示到第n层的可能,F(n) = F(n-1)+F(n-2)在n>=2的时候成立的,我们再来看看F(1)和F(2),明显F(1) = 1,F(2) = 2。这样我们就获得了状态转移方程和边界值,求解到第n层的可能也就演变为求到第n-1层的可能和第n-2层的可能,到第n-1层的可能就等于第n-2层的可能加上第n-2层的可能...直到达到边界值F(1)和F(2)。我们来进行实现:
a、带备忘的自顶向下
public static int getResultUpToDownWithMemo(int n){
int[] memo = new int[n+1];
memo[0] = Integer.MIN_VALUE;
memo[1] = 1;
memo[2] = 2;
return getResultUpToDownMemo(memo, n);
}
private static int getResultUpToDownMemo(int[] memo, int n){
if(memo[n]>0)
return memo[n];
memo[n] = getResultUpToDownMemo(memo, n-1)+getResultUpToDownMemo(memo, n-2);
return memo[n];
}
代码很简单,不做过多说明,时间复杂度和空间复杂度都是多项式级别的,对于这个空间复杂度我们可以再做一步优化,我们可以改为自底向上,只保存前两项,使得空间复杂度变为常数级别。
b、自底向上法
public static int getResultDownToUp(int n){
int resultBeforeTwo = 1;
int resultBeforeOne = 2;
int result = 0;
for(int i = 3; i <= n; i++){
result = resultBeforeOne + resultBeforeTwo;
resultBeforeTwo = resultBeforeOne;
resultBeforeOne = result;
}
return result;
}
代码也很简单,但是空间复杂度被优化为了常数级别。
这些只是动态规划的基础,我们就不在这里对动态规划做深入学习了,以后遇到深入一些的题目再分享给大家。