算法数据结构:动态规划

1,102 阅读7分钟

1、什么是动态规划

动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。

2、一个模型和三个特征

2.1、一个模型(多阶段决策最优解模型)

一个模型指的是,动态规划适合解决的问题的模型,我们把这个模型定义为多阶段决策最优解模型

我们一般是用动态规划来解决最优问题,而解决问题的过程,需要经历多个决策阶段。每个决策阶段都对应着一组状态。然后我们寻找一组决策序列,经过这组决策序列,能够产生最终期望求解的最优值。

2.2、三个特征

三个特征分别指的是,最优子结构无后效性重复子问题

2.3.1、最优子结构

最优子结构指的是,原问题的最优解包含子问题的最优解。反过来说就是,我们可以通过子问题的最优解,推导出原问题的最优解。如果我们把最优子结构,对应到我们前面定义的动态规划问题模型上,那我们也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来。

2.3.2、无后效性

无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。无后效性是一个非常宽松的要求。只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性

2.3.3、重复子问题

动态规划算法的关键在于解决重复子问题,这是动态规划算法的根本目的。重复子问题指的是,不同的决策序列,到达某个相同阶段时,可能会产生重复的状态。

3、两种动态规划解题思路总结

解决这种问题的思路,一般有两种,状态转移表法和状态转移方程法。

3.1、状态转移表法

一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。所以当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。从递归树中,我们很容易可以看出来,是否存在重复子问题,以及子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。

找到重复子问题之后,接下来,我们有两种处理思路,第一种是直接用回溯加备忘录的方法,来避免重复子问题。从执行效率上来讲,跟动态规划解决思路没有差别。第二种是使用动态规划的解决方法,状态转移表法。那么状态转移表法是如何工作的呢?

我们先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据地推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。

3.1、状态转移方程法

状态转移方程法有点类似递归的解题思路。我们需要分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。根据最优子结构,写出递归公式,也就是所谓的状态转移方程。有了状态转移方程,代码实现就非常简单了。一般情况,有两种代码实现方法,一种是递归加备忘录(可以用数组表示),另一种是迭代递推

4、实战

状态转移方程伪代码

# 初始化 base case
// 一般是二维的,也有一维,三维...
dp[0][0][...] = base
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2...)

4.1、斐波那契数列

4.1.1、暴力递归(会超时)
class Solution {
    public int fib(int n) {
        if (n < 1) {
            return 0;
        }
        if (n < 2) {
            return 1;
        }
        return (fib(n - 1) + fib(n - 2)) % 1000000007;
    }
}
4.1.2、动态规划

状态转移方程

f(n)={1(n=1,2)f(n1)+f(n2)(n>2)f(n)= \begin{cases} 1 &(n=1,2)\\ f(n-1)+f(n-2) &(n>2) \end{cases}
class Solution {
    public int fib(int n) {
        if (n < 1) {
            return n;
        }
        if (n < 3) {
            return 1;
        }
        int[] dp = new int[n + 1];
        dp[1] = 1;
        dp[2] = 1;
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
            dp[i] %= 1000000007;
        }
        return dp[n];
    }
}

根据动态转移方程可以看出,当前n状态仅和之前n-1n-2两个状态有关,这里就不需要用dp[]来存储所有的状态了,只需要用两个变量f1f2保存前面两个状态即可。代码如下:

class Solution {
    public int fib(int n) {
        if (n < 1) {
            return 0;
        }
        if (n < 3) {
            return 1;
        }
        int f1 = 1, f2 = 1;
        for (int i = 3; i <= n; i++) {
            int temp = f1 + f2;
            f1 = f2;
            f2 = temp % 1000000007;
        }
        return f2;
    }
}

5、小结

如果我们将这四种算法思想分一下类,那贪心、回溯、动态规划可以归为一类,而分治可以作为一类(因为动态规划跟其他三个都不大一样)。为什么这么说呢?前三个算法解决问题的模型,都可以抽象成本篇文章所讲的那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。

回溯算法十个万金油。基本上能用动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度比较高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。

尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足是三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区别非常明显;分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现那种存在大量的重复子问题。

贪心算法实际上是动态规划算法的一种特殊情况。它解决子问题起来更高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)

其中最优子结构、无后效性跟动态规划中的无异。贪心选择性的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成后,最终由这些局部最优解构成全局最优解。