动态规划(DP)是一种算法技术,用于通过将问题分解为更简单的子问题优化,并利用整个问题的最优解决方案取决于其子问题的最优解决方案这一事实。
让我们以斐波那契数为例。众所周知,斐波那契数是一系列数字,其中每个数字是前面两个数字的总和。前几个斐波那契
数字分别是0、1、1、2、3、5和8,然后从那里继续。
斐波那契数列(Fibonacci sequence),又称黄金分割数列,因数学家莱昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:0、1、1、2、3、5、8、13、21、34、……在数学上,斐波那契数列以如下被以递推的方法定义:F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*)
如果要求我们计算第n个斐波那契数,则可以使用以下公式进行计算,
Fib(n)= Fib(n-1)+ Fib(n-2),其中n> 1
正如我们在这里可以清楚看到的那样,为了解决总体问题(即Fib(n)),我们将其分解为两个较小的子问题(Fib(n-1)和Fib(n-2))。这表明我们可以使用DP解决此问题。
动态规划的特点
在继续了解解决DP问题的不同方法之前,让我们首先看一下告诉我们可以应用DP来解决它的问题的特征。
1.重叠子问题
子问题是原始问题的较小版本。如果发现其解决方案涉及多次解决同一子问题,则任何问题都具有重叠的子问题。以斐波那契数为例;要找到fib(4),我们需要将其分解为以下子问题:
我们在这里可以清楚地看到重叠的子问题模式,因为fib(2)被调用了两次,而fib(1)被调用了三次。
2.最佳子结构属性
如果可以从子问题的最优解中构造出整体最优解,那么任何问题都具有最优的子结构属性。如我们所知,对于斐波那契数,
Fib(n)= Fib(n-1)+ Fib(n-2)
这清楚地表明,大小为“ n”的问题已简化为大小为“ n-1”和“ n-2”的子问题。因此,斐波那契数具有最佳的子结构属性。
动态规划方法
DP提供了两种方法来解决问题:
1.自上而下的记忆
在这种方法中,我们尝试通过递归找到较小子问题的解决方案来解决更大的问题。每当我们解决子问题时,我们都会缓存其结果,这样,如果多次调用它,就不会最终重复解决它。相反,我们可以只返回保存的结果。这种存储已经解决的子问题的结果的技术称为“记忆化”。
我们将在斐波那契数示例中看到这种技术。首先,让我们看一下找到第n个斐波那契数的非DP递归解决方案:
package Dynamicprogramming2;
class FibonacciRecursion {
public int CalculateFibonacci(int n) {
if (n < 2)
return n;
return CalculateFibonacci(n - 1) + CalculateFibonacci(n - 2);
}
public static void main(String[] args) {
FibonacciRecursion fib = new FibonacciRecursion();
System.out.println("5th Fibonacci is ---> " + fib.CalculateFibonacci(5));
System.out.println("6th Fibonacci is ---> " + fib.CalculateFibonacci(6));
System.out.println("7th Fibonacci is ---> " + fib.CalculateFibonacci(7));
}
}
正如我们在上面看到的,这个问题显示了重叠的子问题模式,因此让我们在这里使用备注。我们可以使用数组来存储已经解决的子问题:
package Dynamicprogramming2;
class FibonacciMemoization {
public int CalculateFibonacci(int n) {
int memoize[] = new int[n + 1];
return CalculateFibonacciRecursive(memoize, n);
}
public int CalculateFibonacciRecursive(int[] memoize, int n) {
if (n < 2)
return n;
// if we have already solved this subproblem, simply return the result from the
// cache
if (memoize[n] != 0)
return memoize[n];
memoize[n] = CalculateFibonacciRecursive(memoize, n - 1) + CalculateFibonacciRecursive(memoize, n - 2);
return memoize[n];
}
public static void main(String[] args) {
FibonacciMemoization fib = new FibonacciMemoization();
System.out.println("5th Fibonacci is ---> " + fib.CalculateFibonacci(5));
System.out.println("6th Fibonacci is ---> " + fib.CalculateFibonacci(6));
System.out.println("7th Fibonacci is ---> " + fib.CalculateFibonacci(7));
}
}
2.自下而上制表
列表与自顶向下方法相反,并且避免了递归。通过这种方法,我们解决了“自下而上”的问题(即先解决所有相关的子问题)。这通常是通过填充n维表来完成的。根据表中的结果,然后计算最重要/原始问题的解决方案。
列表与记忆化相反,因为在记忆化中我们解决了问题并维护了已解决的子问题的映射。换句话说,在记忆中,我们是自上而下地进行的,因为我们首先要解决最主要的问题(通常向下递归来解决子问题)。
让我们将制表应用于我们的斐波那契数示例。因为我们知道每个斐波那契数都是前面两个数的和,所以我们可以使用此事实来填充表格。
这是我们自下而上的动态编程方法的代码:
package Dynamicprogramming2;
class FibonacciTabular {
public int CalculateFibonacci(int n) {
int dp[] = new int[n + 1];
// base cases
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
public static void main(String[] args) {
FibonacciTabular fib = new FibonacciTabular();
System.out.println("5th Fibonacci is ---> " + fib.CalculateFibonacci(5));
System.out.println("6th Fibonacci is ---> " + fib.CalculateFibonacci(6));
System.out.println("7th Fibonacci is ---> " + fib.CalculateFibonacci(7));
}
}
进一步观察斐波那契数列可以发现:当前值仅仅是前两次计算结果之和。这意味着不需要存储所有先前的返回值,而只需要存储最后两次计算的值就能计算出当前值。
package Dynamicprogramming2;
class Fibonacci {
public int CalculateFibonacci(int n) {
int a = 0;
int b = 1;
int sum = 0;
for (int i = 0; i < n - 1; i++) {
sum = a + b;
a = b;
b = sum;
}
return sum;
}
public static void main(String[] args) {
Fibonacci fib = new Fibonacci();
System.out.println("5th Fibonacci is ---> " + fib.CalculateFibonacci(5));
System.out.println("6th Fibonacci is ---> " + fib.CalculateFibonacci(6));
System.out.println("7th Fibonacci is ---> " + fib.CalculateFibonacci(7));
}
}