读者:西法,记忆化递归究竟怎么改为动态规划啊?


title:
tags: [算法, 动态规划]
date: 2021-05-18
categories:git

  • [动态规划]

我在动态规划专题反复强调了先学习递归,再学习记忆化,最后再学动态规划github

其中缘由已经讲得很透了,相信你们已经明白了。若是不明白,强烈建议先看看那篇文章。算法

尽管不少看了我文章的小伙伴知道了先去学记忆化递归,可是仍是有一些粉丝问我:“记忆化递归转化为动态规划总是出错,不得要领怎么办?有没有什么要领呀?”api

今天我就来回答一下粉丝的这个问题。数组

实际上个人动态规划那篇文章已经讲了将记忆化递归转化为动态规划的大概的思路,只是可能不是特别细,今天咱们就尝试细化一波ide

咱们仍然先以经典的爬楼梯为例,给你们讲一点基础知识。接下来,我会带你们解决一个更加复杂的题目。函数

<!-- more -->学习

爬楼梯

题目描述

一我的爬楼梯,每次只能爬 1 个或 2 个台阶,假设有 n 个台阶,那么这我的有多少种不一样的爬楼梯方法?测试

思路

因为第 n 级台阶必定是从 n - 1 级台阶或者 n - 2 级台阶来的,所以到第 n 级台阶的数目就是 到第 n - 1 级台阶的数目加上到第 n - 1 级台阶的数目优化

记忆化递归代码:

const memo = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (n in memo) return memo[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  memo[n] = ans;
  return ans;
}

climbStairs(10);

首先为了方便看出关系,咱们先将 memo 的名字改一下,将 memo 换成 dp:

const dp = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (n in dp) return dp[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  dp[n] = ans;
  return ans;
}

climbStairs(10);

其余地方一点没动,就是名字改了下。

那么这个记忆化递归代码如何改形成动态规划呢?这里我总结了三个步骤,根据这三个步骤就能够将不少记忆化递归轻松地转化为动态规划。

1. 根据记忆化递归的入参创建 dp 数组

在动态规划专题中,西法还提过动态规划的核心就是状态。动态规划问题时间复杂度打底就是状态数,空间复杂度若是不考虑滚动数组优化打底也是状态数,而状态数是什么?不就是各个状态的取值范围的笛卡尔积么?而状态正好对应的就是记忆化递归的入参

对应这道题,显然状态是当前位于第几级台阶。那么状态数就有 n 个。所以开辟一个长度为 n 的一维数组就行了。

我用 from 表示改造前的记忆化递归代码, to 表示改造后的动态规划代码。(下同,再也不赘述)

from:

dp = {};
function climbStairs(n) {}

to:

function climbStairs(n) {
  const dp = new Array(n);
}

2. 用记忆化递归的叶子节点返回值填充 dp 数组初始值

若是你模拟上面 dp 函数的执行过程会发现: if n == 1 return 1if n == 2 return 2,对应递归树的叶子节点,这两行代码深刻到叶子节点才会执行。接下来再根据子 dp 函数的返回值合并结果,是一个典型的后序遍历

蓝色表示叶子节点

若是改形成迭代,如何作呢?一个朴素的想法就是从叶子节点开始模拟递归栈返回的过程,没错动态规划本质就是如此。从叶子节点开始,到根节点结束,这也是为何记忆化递归一般被称为自顶向下,而动态规划被称为自底向上的缘由。这里的底和顶能够看作是递归树的叶子和根。

知道了记忆化递归和动态规划的本质区别。 接下来,咱们填充初始化,填充的逻辑就是记忆化递归的叶子节点 return 部分。

from:

const dp = {};
function climbStairs(n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
}

to:

function climbStairs(n) {
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;
}
dp 长度为 n,索引范围是 [0,n-1],所以 dp[n-1] 对应记忆化递归的 dp(n)。所以 dp[0] = 1 等价于上面的 if n == 1: return 1。 若是你想让两者彻底对应也是能够的,数组长度开辟为 n + 1,而且数组索引 0 不用便可。

3. 枚举笛卡尔积,并复制主逻辑

  1. if (xxx in dp) return dp[xxx] 这种代码删掉
  2. 将递归函数 f(xxx, yyy, ...) 改为 dpxxx[....] ,对应这道题就是 climbStairs(n) 改为 dp[n]
  3. 将递归改为迭代。好比这道题每次 climbStairs(n) 递归调用了 climbStairs(n-1) 和 climbStairs(n-2),一共调用 n 次,咱们要作的就是迭代模拟。好比这里调用了 n 次,咱们就用一层循环来模拟执行 n 次。若是有两个参数就两层循环,三个参数就三层循环,以此类推。

from:

const dp = {};
function climbStairs(n) {
  // ...
  if (n in dp) return dp[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  dp[n] = ans;
  return ans;
}

to:

function climbStairs(n) {
  // ...
  // 这个循环其实就是咱上面提到的状态的笛卡尔积。因为这道题就一个状态,枚举一层就行了。若是状态有两个,那么笛卡尔积就能够用两层循环搞定。至于谁在外层循环谁在内层循环,请看个人动态规划专题。
  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

将上面几个步骤的成果合并起来就能够将原有的记忆化递归改造为动态规划了。

完整代码:

function climbStairs(n) {
  if (n == 1) return 1;
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;

  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

有的人可能以为这道题太简单了。实际上确实有点简单了。 并且我也认可有的记忆化递归比较难以改写,什么状况记忆化递归比较好写,改为动态规划就比较麻烦我也在动态规划专题给你们讲过了,不清楚的同窗翻翻。

据我所知,若是动态规划能够过,大多数记忆化递归均可以过。有一些极端状况记忆化递归过不了:那就是力扣测试用例偏多,而且数据量大的测试用例比较多。这是因为力扣的超时判断是多个测试用例的用时总和,而不是单独计算时间。

接下来,我再举一个稍微难一点的例子(这个例子就必须使用动态规划才能过,记忆化递归会超时)。带你们熟悉我上面给你们的套路。

1824. 最少侧跳次数

题目描述

给你一个长度为  n  的  3 跑道道路  ,它总共包含  n + 1  个   点  ,编号为  0  到  n 。一只青蛙从  0  号点第二条跑道   出发  ,它想要跳到点  n  处。然而道路上可能有一些障碍。

给你一个长度为 n + 1  的数组  obstacles ,其中  obstacles[i] (取值范围从 0 到 3)表示在点 i  处的  obstacles[i]  跑道上有一个障碍。若是  obstacles[i] == 0 ,那么点  i  处没有障碍。任何一个点的三条跑道中   最多有一个   障碍。

比方说,若是  obstacles[2] == 1 ,那么说明在点 2 处跑道 1 有障碍。
这只青蛙从点 i  跳到点 i + 1  且跑道不变的前提是点 i + 1  的同一跑道上没有障碍。为了躲避障碍,这只青蛙也能够在   同一个   点处   侧跳   到 另一条   跑道(这两条跑道能够不相邻),但前提是跳过去的跑道该点处没有障碍。

比方说,这只青蛙能够从点 3 处的跑道 3 跳到点 3 处的跑道 1 。
这只青蛙从点 0 处跑道 2  出发,并想到达点 n  处的 任一跑道 ,请你返回 最少侧跳次数  。

注意:点 0  处和点 n  处的任一跑道都不会有障碍。

示例 1:

输入:obstacles = [0,1,2,3,0]
输出:2
解释:最优方案如上图箭头所示。总共有 2 次侧跳(红色箭头)。
注意,这只青蛙只有当侧跳时才能够跳过障碍(如上图点 2 处所示)。
示例 2:

输入:obstacles = [0,1,1,3,3,0]
输出:0
解释:跑道 2 没有任何障碍,因此不须要任何侧跳。
示例 3:

输入:obstacles = [0,2,1,0,3,0]
输出:2
解释:最优方案如上图所示。总共有 2 次侧跳。

提示:

obstacles.length == n + 1
1 <= n <= 5 \* 105
0 <= obstacles[i] <= 3
obstacles[0] == obstacles[n] == 0

思路

这个青蛙在反复横跳??

稍微解释一下这个题目。

  • 若是当前跑道后面一个位置没有障碍物,这种状况左右横跳必定不会比直接平跳更优,咱们应该贪心地直接平跳(不是横跳)过去。这是由于最坏状况咱们能够先平跳过去再横跳,这和先横跳再平跳是同样的。
  • 若是当前跑道后面一个位置有障碍物,咱们须要横跳到一个没有障碍物的通道,同时横跳计数器 + 1。

最后选取全部到达终点的横跳次数最少的便可,对应递归树中就是到达叶子节点时计数器最小的。

使用 dp(pos, line) 表示当前在通道 line, 从 pos 跳到终点须要的最少的横跳数。不难写出以下记忆化递归代码。

因为本篇文章主要讲的是记忆化递归改造动态规划,所以这道题的细节就很少介绍了,你们看代码就好。

咱们来看下代码:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            if (pos, line) in dp: return dp[(pos, line)]
            if pos == len(obstacles) - 1:
                return 0
            # 贪心地平跳
            if obstacles[pos + 1] != line:
                ans = f(pos + 1, line)
                dp[(pos, line)] = ans
                return ans
            ans = float("inf")
            for nxt in [1, 2, 3]:
                if nxt != line and obstacles[pos] != nxt:
                    ans = min(ans, 1 +f(pos, nxt))
            dp[(pos, line)] = ans
            return ans

        return f(0, 2)

这道题记忆化递归会超时,须要使用动态规划才行。 那么如何将 ta 改形成动态规划呢?

仍是用上面的口诀。

1. 根据记忆化递归的入参创建 dp 数组

上面递归函数的是 dp(pos, line),状态就是形参,所以须要创建一个 m * n 的二维数组,其中 m 和 n 分别是 pos 和 line 的取值范围集合的大小。而 line 取值范围其实就是 [1,3],为了方便索引对应,此次西法决定浪费一个空间。因为这道题是求最小,所以初始化为无穷大没毛病。

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            # ...

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        # ...
        return min(dp[-1])

2. 用记忆化递归的叶子节点返回值填充 dp 数组初始值

很少说了,直接上代码。

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            if pos == len(obstacles) - 1:
                return 0
            # ...

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        # ...
        return min(dp[-1])

3. 枚举笛卡尔积,并复制主逻辑

这道题如何枚举状态?固然是枚举状态的笛卡尔积了。简单,几个状态就几层循环呗。

上代码。

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                # ...
        return min(dp[-1])

接下来就是把记忆化递归的主逻辑复制一下粘贴过来就行。

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            # ...
            # 贪心地平跳
            if obstacles[pos + 1] != line:
                ans = f(pos + 1, line)
                dp[(pos, line)] = ans
                return ans
            ans = float("inf")
            for nxt in [1, 2, 3]:
                if nxt != line and obstacles[pos] != nxt:
                    ans = min(ans, 1 +f(pos, nxt))
            dp[(pos, line)] = ans
            return ans

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                if obstacles[pos - 1] != line: # 因为自底向上,所以是和 pos - 1 创建联系,而不是 pos + 1
                    dp[pos][line] = min(dp[pos][line], dp[pos - 1][line])
                else:
                    for nxt in range(1, 4):
                        if nxt != line and obstacles[pos] != nxt:
                            dp[pos][line] = min(dp[pos][line], 1 + dp[pos][nxt])

        return min(dp[-1])

能够看出我基本就是把主逻辑复制过来,稍微改改。 改的基本就是由于:

  • 以前是递归函数,所以 return 须要去掉,好比改为 continue 啥的,不能让函数直接返回,而是继续枚举下一个状态。
  • 以前是 dp[(pos, line)] = ans 如今则改为填充咱上面初始好的二维 dp 数组。

你觉得这就结束了么?

那你就错了。之因此选这道题是有缘由的。这道题直接提交会报错,是答案错误(WA)。

这里我要告诉你们的是:因为咱们使用迭代模拟递归过程,使用多层循环枚举状态的笛卡尔积,而主逻辑部分则是状态转移方程,而转移方程的书写和枚举的顺序息息相关。

从代码不难看出:对这道题来讲咱们采用的是从小到大枚举,而 dppos 也仅仅依赖 dppos-1 和 dppos。

而问题的关键是 nxt,好比处理到了 dp2,d2 依赖了 dp2 的值,而实际上 dp2 是没有处理到的。

所以上面动态规划的的这一行代码有问题:

dp[pos][line] = min(dp[pos][line], 1 + dp[pos][nxt])

由于遍历到 dppos 的时候,有可能 dppos 还没计算好(没有枚举到),这就是产生了 bug。

那为何记忆化递归就没问题呢?

其实很简单。递归函数里面的子问题都是没有计算好的,到叶子节点后再开始计算,计算好后往上返回,而返回的过程其实和迭代是相似的。

好比这道题的 f(0,2) 的递归树大概是这样的,其中虚线标识可能没法到达。

递归树

当从 f(0, 2) 递归到 f(0, 1) 或者 f(0, 3) 的的时候,都是没计算好的,所以都无所谓,代码会继续往叶子节点方向扩展,到达叶子节点返回后,全部的子节点确定都已经计算好了,接下来的过程和普通的迭代就很像了

好比 f(0,2) 递归到 f(0,3) ,f(0,3) 会继续向下递归知道叶子节点,而后向上返回,当再次回到 f(0,2) 的时候,f(0,3) 必定是已经计算好的。

形象点来讲就是:f(0,2) 是一个 leader,告诉他的下属 f(0,3),我想要 xxxx,怎么实现我无论,你有的话直接给我(记忆化),没有的话想办法获取(递归)。无论怎么样,反正你给我弄出来送到我手上。

而若是使用迭代的动态规划,你有的话直接给我(记忆化)很容易作到。关键是没有的话想办法获取(递归)不容易作到啊,至少须要一个相似的循环去完成吧?

那如何解决这个问题呢?

很简单,每次只依赖已经计算好的状态就行了。

对于这道题来讲,虽然 dppos 可能没计算好了,那么 dppos-1 必定是计算好的,由于 dppos-1 已经在上一次主循环计算好了。

可是直接改为 dppos-1 逻辑还对么?这就要具体问题具体分析了,对于这道题来讲,这么写是能够的。

这是由于这里的逻辑是若是当前赛道的前面一个位置有障碍物,那么咱们不能从当前赛道的前一个位置过来,而只能选择从其余两个赛道横跳过来。

我画了一个简图。其中 X 表示障碍物,O 表示当前的位置,数字表示时间上的前后循序,先跳 1 再跳 2 。。。

-XO
---
---

在这里,而如下两种状况实际上是等价的:

状况 1(也就是上面 dppos 的状况):

-X2
--1
---

状况 2(也就是上面 dppos-1 的状况):

-X3
-12
---

能够看出两者是同样的。没懂?多看看,多想一想。

综上,咱们将 dppos 改为 dppos-1 不会有问题。你们遇到其余问题也采起相似思路分析一波便可。

完整代码:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                if obstacles[pos - 1] != line: # 因为自底向上,所以是和 pos - 1 创建联系,而不是 pos + 1
                    dp[pos][line] = min(dp[pos][line], dp[pos - 1][line])
                else:
                    for nxt in range(1, 4):
                        if nxt != line and obstacles[pos] != nxt:
                            dp[pos][line] = min(dp[pos][line], 1 + dp[pos-1][nxt])

        return min(dp[-1])

趁热打铁再来一个

再来一个例子,1866. 恰有 K 根木棍能够看到的排列数目

思路

直接上记忆化递归代码:

class Solution:
    def rearrangeSticks(self, n: int, k: int) -> int:
        @lru_cache(None)
        def dp(i, j):
            if i == 0 and j != 0: return 0
            if i == 0 and j == 0: return 1
            return (dp(i - 1, j - 1) + dp(i - 1, j) * (i - 1)) % (10**9 + 7)
        return dp(n, k) % (10**9 + 7)

咱无论这个题是啥,代码怎么来的。假设这个代码咱已经写出来了。那么如何改形成动态规划呢?继续套用三部曲。

1. 根据记忆化递归的入参创建 dp 数组

因为 i 的取值 [0-n] 一共 n + 1 个, j 的取值是 [0-k] 一共 k + 1 个。所以初始化一个二维数组便可。

dp = [[0] * (k+1) for _ in range(n+1)]

2. 用记忆化递归的叶子节点返回值填充 dp 数组初始值

因为 i == 0 and j == 0 是 1,所以直接写 dp0 = 1 就行了。

dp = [[0] * (k+1) for _ in range(n+1)]
dp[0][0] = 1

3. 枚举笛卡尔积,并复制主逻辑

就是两层循环枚举 i 和 j 的全部组合就行了。

dp = [[0] * (k+1) for _ in range(n+1)]
dp[0][0] = 1

for i in range(1, n + 1):
    for j in range(1, min(k, i) + 1):
        # ...
return dp[-1][-1]

最后把主逻辑复制过来完工了。

好比: return xxx 改为 dp形参一 = xxx 等小细节。

最终的一个代码就是:

class Solution:
    def rearrangeSticks(self, n: int, k: int) -> int:
        dp = [[0] * (k+1) for _ in range(n+1)]
        dp[0][0] = 1

        for i in range(1, n + 1):
            for j in range(1, min(k, i) + 1):
                dp[i][j] = dp[i-1][j-1]
                if i - 1 >= j:
                    dp[i][j] += dp[i-1][j] * (i - 1)
                dp[i][j] %= 10**9 + 7
        return dp[-1][-1]

总结

有的记忆化递归比较难以改写,什么状况记忆化递归比较好写,改为动态规划就比较麻烦我也在动态规划专题给你们讲过了,不清楚的同窗翻翻。

我之因此推荐你们从记忆化递纳入手,正是由于不少状况下记忆化写起来简单,并且容错高(想一想上面的青蛙跳的例子)。这是由于记忆化递归老是后序遍历,会在到达叶子节点只会往上计算。而往上计算的过程和迭代的动态规划是相似的。或者你也能够认为迭代的动态规划是在模拟记忆化递归的归的过程

咱们要作的就是把一些容易改造的方法学会,接下来面对难的尽可能用记忆化递归。据我所知,若是动态规划能够过,大多数记忆化递归均可以过。有一个极端状况记忆化递归过不了:那就是力扣测试用例偏多,而且数据量大的测试用例比较多。这是因为力扣的超时判断是多个测试用例的用时总和,而不是单独计算时间。

将记忆化递归改形成动态规划能够参考个人这三个步骤:

  1. 根据记忆化递归的入参创建 dp 数组
  2. 用记忆化递归的叶子节点返回值填充 dp 数组初始值
  3. 枚举笛卡尔积,并复制主逻辑

另外有一点须要注意的是:状态转移方程的肯定和枚举的方向息息相关,虽然不一样题目细节差别很大。 可是咱们只要紧紧把握一个原则就好了,那就是:永远不要用没有计算好的状态,而是仅适用已经计算好的状态

相关文章
相关标签/搜索