从这一章开始,我们将向大家开启「算法」领域另一个重要的话题「动态规划」。
首先,「动态规划」这个名字可能听起来有点让琢磨不透,感觉它像是在「运筹学」这个领域里的方法,这一点其实没有错,「动态规划」的方法广泛运用于各行各业,当然包括「运筹学」等「最优化领域」。
但我们初学「动态规划」的时候,可以暂时忽略这个「动态规划」有点让人捉摸不透的名字。从一个最简单的例子去理解「动态规划」的基本思想。
首先我们解释「规划」这个词,在《算法导论》这本书里,对「规划」的解释是「表格」,这一点定义我觉得是非常准确的,因为可以用「动态规划」解决的问题,就是让我们在求解问题的过程中,记录每一步求解的结果。
下面我们解释「动态」,我没有在维基百科以及一些经典的书籍上找到「动态」的解释,我自己是这样理解「动态」这个词。「动态」这是与求解「动态规划」问题的两个思路相关的。
「动态规划」告诉我们求解一个问题,可以不直接求解这个问题,而是去思考这个问题最开始(规模最小的时候)的时候是什么样子,然后通过递推的方式,一步一步得到结果,直到问题得到解决,这是一种「自下而上」的思想。
而我们熟悉的「递归」方法,是一种「自上而下」的思想。这两种思想在绝大多数情况下,都能够帮助我们解决问题。而「动态」告诉我们「自上而下」「自下而上」都可以解决这一类问题。在这里给大家一个提示,在我们这门课程里介绍的绝大多数「动态规划」的问题,都可以使用「自底向上」的思路解决,树形 dp 等情况除外。
对于可以使用「动态规划」解决的问题,主要有下面三个特点:
1、重复子问题;
也叫「重复子问题」,从「斐波拉契数列」求解的问题中,我们知道,如果递归地去这个问题,会遇到很多「重复子问题」。这些子问题不应该被重复计算。
2、最优子结构;
求解子问题得到的最优解,组成了规模更大的原问题的最优解,这样的动态规划问题,我们称之为具有「最优子结构」。
动态规划问题通常应用的场景是:我们直接求解这个问题感觉难度较大,但是我们把这个问题拆分为规模更小的问题的时候,这个问题的解通常也就能够找到,这样的解决问题的实现通常都要借助递归来实现。
3、无后效性。
-
在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。
-
某阶段状态一旦确定,就不受之后阶段的决策影响。
我们将通过具体的例子来解释可以使用「动态规划」方法解决的问题的这 3 个特点。
我们先来看一个最最简单的问题:「斐波拉契数列」。
「力扣」第 509 题:斐波那契数。
方法一:使用递归
分析:虽然可以通过,但是认为是错的,因为进行了大量的重复计算。因此时间复杂度是认为指数级别。(这个结论比较粗糙,由于我们是算法基础课程,就不带着代价去研究这个细节了。)
Java 代码:
class Solution {
public int fib(int N) {
if (N < 2) {
return N;
}
return fib(N - 1) + fib(N - 2);
}
}
解决的办法是使用一个数组作为「缓存」,在遇到同样的问题的时候,先查表。
- 如果已经计算过,就不再计算;
- 如果还没有计算过,就递归去计算一次。
Java 代码:
import java.util.Arrays;
public class Solution {
public int fib(int N) {
if (N < 2) {
return N;
}
// 0 要占一个位置,所以设置 N + 1 个位置
int[] memo = new int[N + 1];
Arrays.fill(memo, -1);
return fib(N, memo);
}
public int fib(int n, int[] memo) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
if (memo[n] == -1) {
memo[n] = fib(n - 1) + fib(n - 2);
}
return memo[n];
}
}
方法二:动态规划
上面「递归」求解的过程是「自底向上」的过程,而「动态规划」告诉我们一种求解问题的思路:「自底向上」,事实上,我们人在计算的时候,更多会这样去计算。
- 「自上而下」和 「自底向上」的解法通常都可以称为「动态规划」;
- 如果没有学习过「动态规划」,通过「递归」求解,应该需要知道做了大量重复计算,因此需要加入缓存,这种做法叫「记忆化递归」或者「记忆化搜索」;
- 而使用「自底向上」的思路可以解决在入门阶段的绝大多数「动态规划」问题,我们就是去想一下,这个问题最开始的时候是什么样子,而不是直接去解决这个问题,请大家在练习的过程中逐渐体会这个思路。
注意:并不是所有的「动态规划」问题都可以「自底向上」去做,但是初学的时候,大家可以直接适应这种解法,因为「自上而下」的写法就是「递归」的写法,我们已经相对熟悉。
Java 代码:
public class Solution {
public int fib(int N) {
if (N < 2) {
return N;
}
int[] dp = new int[N + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i < N + 1; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[N];
}
}
这一小节,希望大家能够体会「动态规划」的一个思路,「自底向上」,并且理解使用「动态规划」解决问题的一个特征:「重复子问题」。
因为有「重复子问题」,我们在「自底向上」求解的过程中,通过先解决更小规模的问题,在处理更大规模的问题的时候,直接使用了更小规模问题的结果,进而原问题得到了解决。
练习
1、「力扣」第 70 题:爬楼梯。