1. 什么是动态规划(DP)

Dynamic programming is a technique to solve the recursive problems in more efficient manner. Many times in recursion we solve the sub-problems repeatedly. In dynamic programming we store the solution of these sub-problems so that we do not have to solve them again, this is called Memoization.

So the two components of dynamic programming (DP) are:

  • Recursion – Solve the sub-problems recursively
  • Memoization – Store the solution of these sub-problems so that we do not have to solve them again

动态规划是一种更有效地解决递归问题的技术。在递归中,我们多次重复地解决子问题。在动态规划中,我们存储这些子问题的解,这样我们就不必再次求解它们,这称为记忆

所以,动态规划算法(DP)的两个方面是:

  1. 递归 - 递归地解决子问题
  2. 记忆化 – 存储这些子问题的解决方案,这样我们就不必再次解决它们

下面以斐波那契数列举例说明。

2. 斐波那契数列

斐波那契数列(Fibonacci sequence),又称黄金分割数列,因数学家莱昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:0、1、1、2、3、5、8、13、21、34、…… 这个数列从第3项开始,每一项都等于前两项之和。

更多可以参考生命曲线与神圣的斐波那契数列

斐波那契数列的定义如下:

Fibonacchi(N) = 0                                  for n=0
Fibonacchi(N) = 1                                  for n=1
Fibonacchi(N) == Fibonacchi(N-1)+Finacchi(N-2)     for n>1
3. 一般递归函数的解法
#include <iostream>
using namespace std;

unsigned long long fibRecursion(int n)
{
    if(n<=1) return n;

    return fibRecursion(n-1) + fibRecursion(n-2); 
}

int main()
{
    int n;
    cin >> n;  //输入0~93之间的数,大于42就非常慢了
    cout << fibRecursion(n) << endl;
    return 0;
}

这个程序的性能很差,可以拷贝执行一下。当输入的数值(例如50)很大时,要非常久才能出结果。根据后面的时间预估,执行时间是以2的指数增长的。

graph TD exp4("Fab(4)") --> exp43("Fab(3)"); exp4 --> exp42("Fab(2)"); exp43 --> exp32("Fab(2)"); exp43 --> exp31("Fab(1)"); exp32 --> exp321("Fab(1)"); exp32 --> exp320("Fab(0)"); exp42 --> exp421("Fab(1)"); exp42 --> exp420("Fab(0)");

从上图我们可以看到,计算Fibonacci(4) 需要计算 Fibonacci(3) 和 Fibonacci(2)。计算 Fibonacci(3),需要计算 Fibonacci (2) 和 Fibonacci (1),其中Fibonacci(2)在计算Fibonacci(4)已经计算过了。因此,我们一次又一次地解决许多子问题。

【思考一下】计算Fibonacci(5)的话,总共会计算多少次Fibonacci(2)?

这个实现的时间复杂度:T(n)=T(n1)+T(n2)+1=2n=O(2n)T(n) = T(n-1) + T(n-2) + 1 = 2^n = O(2^n)

4. 使用动态规划

存储子问题结果,这样您就不必再次计算。存储子问题结果的方法:

  • 自下而上
  • 自上而下
4.1 Bottom-Up

假设我们需要解决N的问题,我们开始用尽可能小的输入来解决问题,并将其存储起来以备将来使用。现在,当您计算较大的值时,使用存储的解决方案(较小问题的解决方案)。

Bottom-Up solution for Fibonacci Series:

#include <iostream>
using namespace std;

unsigned long long fib[101];

unsigned long long fibBottomUp(int n)
{
    fib[0] = 0;
    fib[1] = 1;
    for (int i = 2; i < n + 1; i++) {
        fib[i] = fib[i - 1] + fib[i - 2];
    }
    return fib[n];
}

int main()
{
    int n;
    cin >> n;  //输入0~93之间的数
    cout << fibBottomUp(n) << endl;
    for (int i =0; i<=n; i++){
        cout << fib[i] << " ";
    }
    return 0;
}

时间复杂度:O(n) ,空间复杂度: O(n)

执行效率很高,输入90也可以很快算出来。

4.2 Top-Down

将问题分解为子问题,根据需要解决它们,并存储解决方案以备将来使用。

#include <iostream>
using namespace std;

unsigned long long fib_series[101] = {0};

unsigned long long fibUpDown(int n)
{
    if(n==0) return 0;
    if(n==1) return 1;

    if(fib_series[n]!=0) return fib_series[n];

    fib_series[n] = fibUpDown(n-1) + fibUpDown(n-2); //递归计算
    return fib_series[n];
}

int main()
{
    int n;
    cin >> n;  //输入0~93之间的数
    cout << fibUpDown(n) << endl;
    for (int i =0; i<=n; i++){
        cout << fib_series[i] << " ";
    }
    return 0;
}

可以比较和原来的递归算法(也是Top-Down的思路)的写法上的区别。

5. 动态规划的适用性

为了确定问题是否可以通过应用动态规划来解决,我们检查了两个属性。若问题具有这两个性质,那个么我们可以使用动态规划来解决这个问题。

  1. 重叠子问题
  2. 最优子结构。

重叠子问题,顾名思义,子问题需要一次又一次地解决。在递归中,我们每次都解决这些问题,而在动态规划中,我们只解决这些子问题一次,并将其存储起来以备将来使用。正如我们上面所看到的,我们正在反复解决许多子问题。

最优子结构:如果一个问题可以用子问题的解来解决,那么我们说这个问题具有最优子结构性质。