面试官问我斐波拉契数列,我从暴力递归讲到动态规划 ...

Posted 宫水三叶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试官问我斐波拉契数列,我从暴力递归讲到动态规划 ...相关的知识,希望对你有一定的参考价值。


前言

在系统学习动态规划之前,一直搞不懂「动态规划」和「记忆化搜索」之间的区别。

总觉得动态规划只是单纯的难在于对“状态”的抽象定义和“状态转移方程”的推导,并无具体的规律可循。

本文将助你彻底搞懂动态规划。


演变过程

暴力递归 -> 记忆化搜索 -> 动态规划

其实动态规划也就是这样演练过来的。

可以说几乎所有的「动态规划」都可以通过「暴力递归」转换而来,前提是该问题是一个“无后效性”问题。


无后效性

所谓的“无后效性”是指:当某阶段的状态一旦确定,此后的决策过程和最终结果将不受此前的各种状态所影响。可简单理解为当编写好一个递归函数之后,当可变参数确定之后,结果是唯一确定的。

可能你还是对什么是“无后效性”问题感到难以理解。没关系,我们再举一个更具象的例子,这是 ​​LeetCode 62. Unique Paths​​ :给定一个 m x n 的矩阵,从左上角作为起点,到达右下角共有多少条路径(机器人只能往右或者往下进行移动)。

面试官问我斐波拉契数列,我从暴力递归讲到动态规划

这是一道经典的「动态规划」入门题目,也是一个经典的“无后效性”问题。

它的“无后效性”体现在:当给定了某个状态(一个具体的 m x n 的矩阵和某个起点,如 (1,2)),那么从这个点到达右下角的路径数量就是完全确定的。

而与如何到达这个“状态”无关,与机器人是经过点 (0,2) 到达的 (1,2),还是经过 (1,1) 到达的 (1,2) 无关。

这就是所谓的“无后效性”问题。

当我们尝试使用「动态规划」解决问题的时候,首先要关注该问题是否为一个“无后效性”问题。


1:暴力递归

经常我们面对一个问题,即使我们明确知道了它是一个“无后效性”问题,它可以通过「动态规划」来解决。我们还是觉得难以入手。

这时候我的建议是,先写一个「暴力递归」的版本。

还是以刚刚说到的 ​​LeetCode 62. Unique Paths​​ 举例:

class Solution 
public int uniquePaths(int m, int n)
return recursive(m, n, 0, 0);


private int recursive(int m, int n, int i, int j)
if (i == m - 1 || j == n - 1) return 1;
return recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);

当我还不知道如何使用「动态规划」求解时,我会设计一个递归函数 ​​recursive()​​ 。

函数传入矩阵信息和机器人当前所在的位置,返回在这个矩阵里,从机器人所在的位置出发,到达右下角有多少条路径。

有了这个递归函数之后,那问题其实就是求解 ​​recursive(m, n, 0, 0)​​:求解从 (0,0) 到右下角的路径数量。

接下来,实现这个函数:

  1. Base case: 由于题目明确了机器人只能往下或者往右两个方向走,所以可以定下来递归方法的 base case 是当已经处于矩阵的最后一行或者最后一列,即只一条路可以走。
  2. 其余情况:机器人既可以往右走也可以往下走,所以对于某一个位置来说,到达右下角的路径数量等于它右边位置到达右下角的路径数量 + 它下方位置到达右下角的路径数量。即 ​​recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1)​​,这两个位置都可以通过递归函数进行求解。

其实到这里,我们已经求解了这个问题了。

但这种做法还有个严重的性能问题。


2:记忆化搜索

如果将我们上述的代码提交到 LeetCode,会得到 timeout 的结果。

可见「暴力递归」的解决方案“很慢”。

我们知道所有递归函数的本质都是“压栈”和“弹栈”。

既然这个过程很慢,我们可以通过将递归版本暴力解法的改为非递归的暴力解法,来解决 timeout 的问题吗?

答案是不行,因为导致 timeout 的原因不在于使用“递归”手段所带来的成本。

而在于在计算过程,我们进行了多次的重复计算。

我们尝试展开递归过程第几步来看看:

面试官问我斐波拉契数列,我从暴力递归讲到动态规划

不难发现,在递归展开过程会遇到很多的重复计算。

随着我们整个递归过程的展开,重复计算的次数会呈倍数增长。

这才是「暴力递归」解决方案“慢”的原因。

既然是重复计算导致的 timeout,我们自然会想到将计算结果进行“缓存”的方案:

class Solution 
private int[][] cache;

public int uniquePaths(int m, int n)
cache = new int[m][n];
for (int i = 0; i < m; i++)
int[] ints = new int[n];
Arrays.fill(ints, -1);
cache[i] = ints;

return recursive(m, n, 0, 0);


private int recursive(int m, int n, int i, int j)
if (i == m - 1 || j == n - 1) return 1;
if (cache[i][j] == -1)
if (cache[i + 1][j] == -1)
cache[i + 1][j] = recursive(m, n, i + 1, j);

if (cache[i][j + 1] == -1)
cache[i][j + 1] = recursive(m, n, i, j + 1);

cache[i][j] = cache[i + 1][j] + cache[i][j + 1];

return cache[i][j];

对「暴力递归」过程中的中间结果进行缓存,确保相同的情况只会被计算一次的做法,称为「记忆化搜索」。

做了这样的改进之后,提交 LeetCode 已经能 AC 并得到一个不错的评级了。

我们再细想一下就会发现,其实整个求解过程,对于每个情况(每个点)的访问次数并没有发生改变。

只是从「以前的每次访问都进行求解」改进为「只有第一次访问才真正求解」。

事实上,我们通过查看 ​​recursive()​​ 方法就可以发现:

当我们求解某一个点 (i, j) 的答案时,其实是依赖于 (i, j + 1)(i + 1, j)

也就是每求解一个点的答案,都需要访问两个点的结果。

这种情况是由于我们采用的是“自顶向下”的解决思路所导致的。

我们无法直观确定哪个点的结果会在什么时候被访问,被访问多少次。

所以我们不得不使用一个与矩阵相同大小的数组,将所有中间结果“缓存”起来。

换句话说,「记忆化搜索」解决的是重复计算的问题,并没有解决结果访问时机和访问次数的不确定问题。


2.1:次优解版本的「记忆化搜索」

关于「记忆化搜索」最后再说一下。

网上有不少博客和资料在编写「记忆化搜索」解决方案时,会编写类似如下的代码:

class Solution 
private int[][] cache;

public int uniquePaths(int m, int n)
cache = new int[m][n];
for (int i = 0; i < m; i++)
int[] ints = new int[n];
Arrays.fill(ints, -1);
cache[i] = ints;

return recursive(m, n, 0, 0);


private int recursive(int m, int n, int i, int j)
if (i == m - 1 || j == n - 1) return 1;
if (cache[i][j] == -1)
cache[i][j] = recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);

return cache[动态规划-斐波拉契数列笔记

剑指 offer——贪心动态规划篇

斐波拉契数列的计算方法

剑指offer面试题 10. 斐波那契数列

前端也能学算法:由浅入深讲解动态规划

面试官问你斐波那契数列的时候不要高兴得太早