动态规划:第 1 节:理解「重复子问题」(自己的草稿,内容不严谨,与之前有重复,不用看)

从这一章开始,我们将向大家开启「算法」领域另一个重要的话题「动态规划」。

首先,「动态规划」这个名字可能听起来有点让琢磨不透,感觉它像是在「运筹学」这个领域里的方法,这一点其实没有错,「动态规划」的方法广泛运用于各行各业,当然包括「运筹学」等「最优化领域」。

但我们初学「动态规划」的时候,可以暂时忽略这个「动态规划」有点让人捉摸不透的名字。从一个最简单的例子去理解「动态规划」的基本思想。

首先我们解释「规划」这个词,在《算法导论》这本书里,对「规划」的解释是「表格」,这一点定义我觉得是非常准确的,因为可以用「动态规划」解决的问题,就是让我们在求解问题的过程中,记录每一步求解的结果。

下面我们解释「动态」,我没有在维基百科以及一些经典的书籍上找到「动态」的解释,我自己是这样理解「动态」这个词。「动态」这是与求解「动态规划」问题的两个思路相关的。

「动态规划」告诉我们求解一个问题,可以不直接求解这个问题,而是去思考这个问题最开始(规模最小的时候)的时候是什么样子,然后通过递推的方式,一步一步得到结果,直到问题得到解决,这是一种「自下而上」的思想。

而我们熟悉的「递归」方法,是一种「自上而下」的思想。这两种思想在绝大多数情况下,都能够帮助我们解决问题。而「动态」告诉我们「自上而下」「自下而上」都可以解决这一类问题。在这里给大家一个提示,在我们这门课程里介绍的绝大多数「动态规划」的问题,都可以使用「自底向上」的思路解决,树形 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 题:爬楼梯

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章