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

8次阅读

共计 9505 个字符,预计需要花费 24 分钟才能阅读完成。

文章首发于公众号 宫水三叶的刷题日记,转载请分割开白名单。

前言

在零碎学习动静布局之前,始终搞不懂「动静布局」和「记忆化搜寻」之间的区别。

总感觉动静布局只是单纯的难在于对“状态”的形象定义和“状态转移方程”的推导,并无具体的法则可循。

本文将助你彻底搞懂动静布局。点击 这里 能够查看更多算法面试相干内容~


演变过程

暴力递归 -> 记忆化搜寻 -> 动静布局

其实动静布局也就是这样演练过去的。

能够说简直所有的「动静布局」都能够通过「暴力递归」转换而来,前提是该问题是一个“无后效性”问题。


无后效性

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

可能你还是对什么是“无后效性”问题感到难以了解。没关系,咱们再举一个更具象的例子,这是 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[i][j];
    }
} 

能够和我下面提供的解决方案作比照。次要区别在于 if (cache[i][j] == -1) 的判断外面。

在我提供解决方案中,会在计算 cache[i][j] 时,尝试从“缓存”中读取 cache[i + 1][j]cache[i][j + 1],确保每次调用 recursive() 都是必须的,不反复的。

网上大多数的解决方案只会在外层读取“缓存”,在真正计算 cache[i][j] 的时候并不采取先查看再调用的形式,间接调用 recursive() 计算子问题。

尽管两者相比与间接的「暴力递归」都大大减少了计算次数(recursive() 的拜访次数),但后者的计算次数显然要比前者高上不少。

你可能会感觉反正都是“自顶向下”,两者应该没有区别吧?

为此我提供了以下试验代码来比拟它们对 recursive() 的调用次数:

class Solution {public static void main(String[] args) {Solution solution = new Solution();
        solution.uniquePaths(15, 15);
    }
    
    private int[][] cache;
    private long count; _// 统计 递归函数 的调用次数_

    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;
        }
        _// int result = recursive(m, n, 0, 0); // count = 80233199_
        _// int result = cacheRecursive(m, n, 0, 0); // count = 393_
        int result = fullCacheRecursive(m, n, 0, 0); _// count = 224_
        System.out.println(count);
        return result;
    }

    _// 齐全缓存_ 
    private int fullCacheRecursive(int m, int n, int i, int j) {
        count++;
        if (i == m - 1 || j == n - 1) return 1;
        if (cache[i][j] == -1) {if (cache[i + 1][j] == -1) {cache[i + 1][j] = fullCacheRecursive(m, n, i + 1, j);
            }
            if (cache[i][j + 1] == -1) {cache[i][j + 1] = fullCacheRecursive(m, n, i, j + 1);
            }
            cache[i][j] = cache[i + 1][j] + cache[i][j + 1];
        }
        return cache[i][j];
    }

    _// 只有外层缓存_
    private int cacheRecursive(int m, int n, int i, int j) {
        count++;
        if (i == m - 1 || j == n - 1) return 1;
        if (cache[i][j] == -1) {cache[i][j] = cacheRecursive(m, n, i + 1, j) + cacheRecursive(m, n, i, j + 1);
        }
        return cache[i][j];
    }

    _// 不应用缓存_
    private int recursive(int m, int n, int i, int j) {
        count++;
        if (i == m - 1 || j == n - 1) return 1;
        return recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);
    }
} 

因为咱们应用 cache 数组的目标是缩小 recursive() 函数的调用。

只有确保在每次调用 recursive() 之前先去 cache 数组查看,咱们才能够将对 recursive() 函数的调用次数减到起码。

在数据为 15 的样本下,这是 O(393n)O(224n) 的区别,但对于一些卡常数特地重大的 OJ,尤其重要。

所以我倡议你在「记忆化搜寻」的解决方案时,采取与我一样的策略:

确保在每次拜访递归函数时先去“缓存”查看。只管这有点“不美观”,但它能施展「记忆化搜寻」的最大作用。


3:从「自顶向下」到「自底向上」

你可能会想,为什么咱们须要改良「记忆化搜寻」,为什么须要明确两头后果的拜访机会和拜访次数?

因为一旦咱们能明确两头后果的拜访机会和拜访次数,将为咱们的算法带来微小的晋升空间。

后面说到,因为咱们无奈确定两头后果的拜访机会和拜访次数,所以咱们不得不“缓存”全副两头后果。

但如果咱们能明确两头后果的拜访机会和拜访次数,至多咱们能够大大降低算法的空间复杂度。

这就波及解决思路的转换:从「自顶向下」到「自底向上」。

如何实现从「自顶向下」到「自底向上」的转变,还是通过具体的例子来了解。

这是 LeetCode 509. Fibonacci Number,驰名的“斐波那契数列”问题。

如果不理解什么是“斐波那契数列”,能够查看对应的 维基百科。

因为斐波那契公式为:

人造适宜应用递归:

public class Solution {private int[] cache;
    public int fib(int n) {cache = new int[n + 1];
        return recursive(n);
    }

    private int recursive(int n) {if (n <= 1) return n;
        if (n == 2) return 1;
        if (cache[n] == 0) {if (cache[n - 1] == 0) {cache[n - 1] = recursive(n - 1);
            }
            if (cache[n - 2] == 0) {cache[n - 2] = recursive(n - 2);
            } 
            cache[n] = cache[n - 1] + cache[n - 2];
        }
        return cache[n];
    }
} 

但这依然会有咱们之前所说的问题,这些问题都是因为间接递归是“自顶向下”所导致的。

这样的解法的时空复杂度为 O(n):每个值计算一次,应用了长度为 n + 1 的数组。

通过观察斐波那契公式,咱们能够发现要计算某个 n,只须要晓得 n – 1 的解和 n – 2 的解。

同时 n = 1 和 n = 2 的解又是已知的(base case)。

所以咱们大能够从 n = 3 登程,逐渐往后迭代得出 n 的解。

因为计算某个值的解,只依赖该值的前一位的解和前两位的解,所以咱们只须要应用几个变量缓存最近的两头后果即可:

class Solution {public int fib(int n) {if (n <= 1) return n;
        if (n == 2) return 1;
        
        int prev1 = 1, prev2 = 1;
        int cur = prev1 + prev2;
        for (int i = 3; i <= n; i++) {
            cur = prev1 + prev2;
            prev2 = prev1;
            prev1 = cur;
        }
        return cur;
    }
} 

这样咱们就把本来空间复杂度为 O(N) 的算法升高为 O(1):只是用了几个无限的变量。

但不是所有的「动静布局」都像“斐波那契数列”那么简略就能实现从“自顶向下”到“自底向上”的转变。

当然也不是毫无法则可循,尤其是咱们曾经写出了「暴力递归」的解决方案。

让咱们再次回到 LeetCode 62. Unique Paths 当中:

class Solution {public int uniquePaths(int m, int n) {_// 因为咱们的「暴力递归」函数,真正的可变参数就是 i 和 j(变动范畴别离是 [0,m-1] 和 [0, n-1])_
        _// 所以倡议一个二维的 dp 数组进行后果存储(相当于建一个表格)_
        int[][] dp = new int[m][n];
        
        _// 依据「暴力递归」函数中的 base case_
        _// 咱们能够间接得出 dp 中最初一行和最初一列的值(将表格的最初一行和最初一列填上)_
        for (int i = 0; i < n; i++) dp[m - 1][i] = 1
        for (int i = 0; i < m; i++) dp[i][n - 1] = 1;
        
        _// 依据「暴力递归」函数中对其余状况的解决逻辑(依赖关系)编写循环_
        _//(依据表格的最初一行和最初一列的值,得出表格的其余格子的值)_
        for (int i = m - 2; i >= 0; i--) {for (int j = n - 2; j >= 0; j--) {dp[i][j] = dp[i + 1][j] + dp[i][j + 1];
            }
        }
        
        _// 最终咱们要的是 dp[0][0](表格中左上角的地位,也就终点的值)_
        return dp[0][0];
        
        _// 原「暴力递归」调用_
        _// return recursive(m, n, 0, 0);_
    }

    private int recursive(int m, int n, int i, int j) {
        _// base case_
        if (i == m - 1 || j == n - 1) return 1;
        _// 其余状况_
        return recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);
    }
} 

不难发现,咱们甚至能够间接依据「暴力递归」来写出「动静布局」,而不须要关怀原问题是什么。

简略的「动静布局」其实就是一个“打表格”的过程:

先依据 base case 定下来表格中的一些地位的值,再依据已得出值的地位去推算其余格子的信息。

推算所用到的依赖关系,也就是咱们「暴力递归」中的“其余状况”解决逻辑。


动静布局的实质

动静布局的实质其实依然是枚举:枚举所有的计划,并从中找出最优解。

但和「暴力递归」不同的是,「动静布局」少了很多的反复计算。

因为所依赖的这些历史后果,都被存起来了,因而节俭了大量反复计算。

从这一点来说,「动静布局」和「记忆化搜寻」都是相似的。

要把历史后果存起来,必然要应用数据结构,在 dp 中咱们通常应用一维数组或者二维数据来存储,假如是 dp[]。

那么对应解 dp 问题咱们有以下过程

  1. 状态定义 :确定 dp[] 中元素的含意,也就是说须要明确 dp[i] 是代表什么内容
  2. 状态转移 :确定 dp[] 元素之间的关系,dp[i] 这个格子是由哪些 dp 格子推算而来的。如斐波那契数列中就有 dp[i] = dp[i – 1] + dp[i – 2]
  3. 起始值 :base case,dp[] 中的哪些格子是能够间接得出后果的。如斐波那契数列中就有 dp[0] = 0 和 dp[1] = 1
    • *

打消“后效性”

咱们晓得应用「动静布局」的前提是问题的“无后效性”。

然而有些时候问题的“无后效性”并不容易体现。

须要咱们多引入一维来进行“打消”。

例如 LeetCode 上经典的「股票问题」,应用动静布局求解时往往须要多引入一维示意状态,有时候甚至须要再引入一维代表购买次数。

留神这里说的打消是带引号的,其实这样的做法更多的是作为一种“技巧”,它并没有真正扭转问题“后效性”,只是让问题看上去变得简略的。

因为本文篇幅曾经很长了,这里就不再开展「股票问题」。

之后会应用专门章节来对「股票问题」进行解说,以达到应用同一思路解决所有「股票问题」的目标,敬请期待。


总结

到这里咱们曾经能够答复「动静布局」和「记忆化搜寻」的区别是什么了。

「记忆化搜寻」实质是带“缓存”性能的「暴力递归」:

它只能解决反复计算的问题,而不能确定两头后果的拜访机会和拜访次数,实质是一种“自顶向下”的解决形式。

「动静布局」是一种“自底向上”的解决方案:

能明确拜访机会和拜访次数,这为升高算法的空间复杂度带来微小空间,咱们能够依据依赖关系来决定保留哪些两头后果,而无须将全副两头后果进行“缓存”。

正文完
 0