一、综述
动态规划是一种通过组合子问题的解地来求解原问题的方法,思考状态的时候采用递归,计算的时候自底向上穷举所有最优子问题。与分治法的区别在于:分治方法将问题划分为互不相交的子问题,递归地求解子问题,再将他们的解组合起来,求出原问题的解;而动态规划应用于子问题重叠的情况,但是注意,这里的子问题重叠是指在求原问题的子问题时,子问题的子问题重叠,即不同的子问题具有公共的子子问题。动态规划对每个子子问题只求解一次,将其解保存在一个表格里,从而每次求解那些公共子子问题时无需重新计算。
动态规划通常用来求解最优化问题,这类问题有很多可行的解,每个解都有一个值,我们希望找到最优的值。设计一个动态规划的一般步骤如下:
- 刻画一个最优解的结构特征;
- 递归地定义最优解的值;
- 计算最优解的值,通常采用自底向上的方法(与分治法的区别);
- 利用计算出的最优解的值构造该优解值的组合。
这个步骤比较细,我自己的理解是这样的:
- 定义问题的状态,注意,状态不仅可能为一个确切、量化的值,更多情况下是一个描述;
- 递推地表示状态,即状态转移方程,表示从某一状态如何转到其子状态(可以是多个子状态,也可以是单个子状态);
- 自底向上地不重复计算状态,此时一定要注意边界条件。
实际上,根据上述的步骤,我们可以发现,实际上动态规划将所有的最优子状态穷举了,最后找出总问题的最优状态。接下来,我们来看看常见的动态规划的经典题目。
二、经典案例
因为我懒得写main方法入口,所以使用的是JUnit4测试框架,然后又懒得每次写@Test注解的方法,受Spring MVC里AbstractController类的启发,就写了一个抽象类来作为程序运行时的入口,代码如下:
import org.junit.Test;
public abstract class EnhanceModual {
@Test
public void entrance() {
internalEntrance();
}
public abstract void internalEntrance();
}
接下来的类均继承这个抽象类,然后重写internalEntrance()方法即可。
1、斐波那契数的非递归形式
通常,我们写斐波那契数的代码时,按照从顶到底的顺序使用尾递归计算,即为了求F(n),从n开始,求F(n-1)和F(n-2)…这种代码非常的低效,换一种思维,采用动态规划的思维去考虑斐波那契数。
斐波那契数:F(0) = 0,F(1) = 1, F(2) = 1, F(3) = 2,…,F(n)
状态为F(n),即第n个位置上的斐波那契值
状态转移方程:F(n) = F(n-1) + F(n-2) (n≥2)
第三步:自底向上地计算所有值,即从F(0)开始算起。
import java.text.MessageFormat;
/**
* 斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,
* 233, 377, 610, 987, 1597, 2584, 4181...
* 递推公式:F[n]=F[n-1]+F[n-2](n>=2,F[0]=0,F[1]=1)
*/
public class ImprovedFibonacci extends EnhanceModual {
public int improvedFibonacci(int index) {
int[] results = { 0, 1 };
int result = 0;
if (index < 2) {
result = results[index];
return result;
}
int fabonacciOne = 0;//用来保存两个子状态之一
int fabonacciTwo = 1;//用来保存另一个子状态
// 从底层开始计算,这样的话就可以把记录需要重复计算的项了
for (int i = 2; i <= index; i++) {
result = fabonacciOne + fabonacciTwo;
fabonacciOne = fabonacciTwo;//子状态更新
fabonacciTwo = result;//子状态更新
}
return result;
}
public void internalEntrance() {
for (int i = 0; i <= 19; i++) {
int result = improvedFibonacci(i);
System.out.println(MessageFormat.format("F[{0}] = {1}", i, result));
}
}
}
运行结果:
F[0] = 0
F[1] = 1
F[2] = 1
F[3] = 2
F[4] = 3
F[5] = 5
F[6] = 8
F[7] = 13
F[8] = 21
F[9] = 34
F[10] = 55
F[11] = 89
F[12] = 144
F[13] = 233
F[14] = 377
F[15] = 610
F[16] = 987
F[17] = 1,597
F[18] = 2,584
F[19] = 4,181
2、 C(N)=(2/N)∑N−1i=0C(i)+N 的非递归实现
在《数据结构与算法分析——C语言描述》一书的P288面提到了
C(N)=(2/N)∑N−1i=0C(i)+N,C(0)=1
的代码实现,但是书中只给了递归实现,非递归实现留给了读者自己思考,这里我分析了后写出了运行时间为O(N)的代码。
看下图,C(N)的计算过程展开图,从低向上计算时,可以用一个列表存放之前已经计算的值,计算下一个值时,只需要把列表里的值全部取出来求和带入公式里就行了,但是这样需要扫描全表,所以我觉得不是很好,就试着将这个递推公式改了一下。
修改后的的状态转移公式如下,这个公式很好推出来,这里就不写过程了,按照这个公式,我们每次计算的时候都只需要一个前状态,这样的话就不用使用列表存储了,只需要用一个临时变量存储该值即可,这个例子给我们的启示就是有时候状态转移方程可以有多种表示方式,最好能够化简一下,尽可能选择所需子状态最少的状态转移方程。
C(N)=(1+1/N)C(N−1)+2−1/N,N≥1
/**
* 数据结构与算法分析:C语言描述P288的递推式的改进
*/
public class ImprovedRecursion extends EnhanceModual {
public double originalRecursion(int n) {
double result = 0.0;
if (n == 0) {
return 1.0;
} else {
for (int i = 0; i < n; i++) {
result += originalRecursion(i);
}
return 2.0 * result / n + n;
}
}
public double improvedRecursion(int n) {
double result = 0.0;
double[] results = { 1.0, 3.0 };
if (n < 2) {
return results[n];
} else {
double resultTmp = 3.0;
for (int i = 2; i <= n; i++) {
//注意这里(1 + 1.0 / i)不能写成了(1 + 1 / i)
result = (1 + 1.0 / i) * resultTmp + 2 - 1.0 / i;
resultTmp = result;
}
return result;
}
}
@Override
public void internalEntrance() {
// TODO Auto-generated method stub
System.out.print("原始递归方法:");
System.out.print(originalRecursion(0)+" ");
System.out.print(originalRecursion(1)+" ");
System.out.print(originalRecursion(2)+" ");
System.out.print(originalRecursion(3)+" ");
System.out.println(originalRecursion(4));
System.out.print("改进型方法");
System.out.print(improvedRecursion(0)+" ");
System.out.print(improvedRecursion(1)+" ");
System.out.print(improvedRecursion(2)+" ");
System.out.print(improvedRecursion(3)+" ");
System.out.println(improvedRecursion(4));
}
}
运行结果:
原始递归方法:1.0 3.0 6.0 9.666666666666668 13.833333333333334
改进型方法1.0 3.0 6.0 9.666666666666666 13.833333333333332
根据运行的结果,显然不同的计算方式对结果的精度也不一样,这些都是在写程序时需要考虑的问题。