前端算法学习总结篇(js) 3.动态规划

391 阅读10分钟

动态规划

前言

动态规划(Dynamic Programming) (dp的由来) 动态规划既是一种数学优化方法,也是一种计算机编程方法。 大白话:通过递归方式把复杂问题分解为更简单的子问题来简化复杂问题,找到子问题的最优解来最优地解决问题。 经典题型有:最短路径系列,背包系列,斐波那契系列,棋盘系列等。 鄙人一直活在动态规划的阴影里,正所谓消除恐惧的办法就是直面恐惧!!因此有了本文。

前置准备

掌握递归

递归代码伪代码模板:

function recur(level, param) {
  // 边界/中止条件
  if (level > MAX_LEVEL) return
  // 处理当前这一层的主要逻辑
  generate(level, param)
  // 恢复当前层状态(需要的话)
  // 比如进入的param是个引用类型,在主要逻辑上有做修改,需要恢复到原始状态的时候在这处理
  // 下沉
  return recur(level + 1, newParam)
}

在我们具体实现的过程中,其实我们可能会把多个步骤合并成一步,比如处理逻辑和下沉还有返回结果一起完成

掌握分治

分治其实是递归的一种,一些大的问题其实可以被分解为多个子问题,这些子问题都有相似性 根据这样的相似性我们可以把一个问题变成一个树的结构,大问题分解成子问题,子问题也可以继续向下分解,这些分解后的问题经过计算后再合并结果给到父级,最后解决大的问题

分治伪代码逻辑:

function divideConquer(problem, param1, param2, ...) {
  // 中止条件
  if (!problem) return result
  // 拆分子问题
  subproblems = splitProblem(problem)
  // 调用子问题的递归函数/下沉
  subresult1 = divideConquer(subproblems[0], p1, p2, ...)
  subresult2 = divideConquer(subproblems[1], p1, p2, ...)
  subresult3 = divideConquer(subproblems[2], p1, p2, ...)
  ...
  // 恢复当前层状态(如果需要的话)
  // 合并结果并返回
  return mergeResult(subresult1, subresult2, subresult3, ...)
}

动态规划 和 递归/分治 的描述很相似,他们有什么区别呢?

动态规划 和 递归/分治 没有根本上的区别 (关键看有无最优的子结构)

他们都是依靠递归来简化复杂问题,分治会把所有结果合并,而动态规划有自己的最优子结构

最优子结构:由有最优解的子问题形成的最优答案

共性:找到重复子问题

差异性:最优子结构、中途可以淘汰次优解 (正因为可以淘汰次优解,所以dp的时间复杂度才不会到指数级别)

入门

斐波那契数

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0, F(1) = 1
F(n) = F(n - 1) + F(n - 2), 其中 n > 1

给定一个 n ,求 F(n)

像这个题目,我们用树状图来表示就是: 时间复杂度比较图

那么解决这个问题我们可以简单的用分治(递归)的形式来解决:

/**
 * @param {number} n
 * @return {number}
 */
var fib = function(n) {
  // 边界/中止条件 当 n = 0 或者 n = 1 返回当前的数字
  if (n < 2) return n
  // 拆分子问题分别递归 然后 “+” 合并 最后返回结果
  return fib(n - 1) + fib(n - 2)
};

这是可以解决这个问题的,但是我们看到上边的树状图,其实他做了很多重复的逻辑。 比如递归调用fib(2),我们可以数一下当 n = 5 的时候 fib(2) 调用了 3 次。 随着我们 n 的数值变大,这个调用次数将会指数上升,而这仅仅只是 fib(2) 的调用。

我们该怎么减少调用次数呢?(分治+记忆化搜索)

我们可以用一个数组/对象 来缓存每次调用某个数字的结果,我们也把这种行为叫做记忆化搜索比如:

/**
 * @param {number} n
 * @return {number}
 */
var fib = function(n, memo = []) { // memo = {}
  // 边界/中止条件 当 n = 0 或者 n = 1 返回当前的数字
  if (n < 2) return n
  // 拆分子问题分别递归 然后 “+” 合并 最后返回结果
  if (!memo[n]) memo[n] = fib(n - 1) + fib(n - 2)
  return memo[n]
};

那么memo就维护了一个缓存,当存在对应数字的memo则直接返回,不存在的时候就乖乖去计算并存储。 那我们现在执行到的fib用树状图来表示就是:

时间复杂度比较图

我们可以看到事实上我们去分解这个斐波那契的时候采用的是自顶向下的解决方法,就是像树状图,从上到下去分解这个问题最终合并得到答案。 这种思维习惯比较符合我们人脑的思维习惯,我要解决 fib(5) 那我就去求 fib(4) 和 fib(3)。

有 自顶向下 那就会有 自底向上 的解法

对于计算机而言,自底向上其实更加快速,如果我们已经提前知道了所有的结果呢? 来看代码:

/**
 * @param {number} n
 * @return {number}
 */
var fib = function(n) {
  const dp = new Array(n).fill(0)
  dp[0] = 0
  dp[1] = 1
  for (let i = 2; i <= n; ++i) {
    dp[i] = dp[i - 1] + dp[i - 2]
  }
  return dp[n]
};

那这种递推的方式其实就是一种自底向上的解决方案。 我们在力扣上分别对三种解法提交代码,我们可以发现这种自底向上的解决方法速度是最快的,他的时间复杂度只有O(n)

对于 分治+记忆化(自顶向下) 解法和 递推(自底向上) 解法如何选择?

我这里是建议大家如果对算法不是很熟悉的话,可以先从自顶向下这种思维模式出发,先实现,然后我们再尝试使用递推的方式来解决问题。

如果已经有一定的算法水平,请一定要优先考虑直接递推,递推也是动态规划的模板。

总结

对于斐波那契数列这道题目,这里的递推实际上是一维递推,他不需要淘汰任何值,是最简单的傻递推(无脑计算存储)。我们接下来来看稍微复杂的递推是什么样子的吧!

不同路径 II

该题目由 leetcode 62.不同路径 变种,题目要求如下:

一个机器人位于一个m x n网格的左上角 (起始点在下图中标记为 机器人 )。 机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 星星)。 现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 1 和 0 来表示。

不同路径 II

测试用例如下:

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

看完题目,我们一定要优先考虑这个题目他的一个重复性是在哪?重复子问题是什么?你可以先思考 1 到 5 分钟再往下看答案。

分治 + 记忆化搜索

我们看题目不难发现,机器人的移动每次只能向右或者向下,那这就是一个分子问题的提醒,我们每次到达一个新的位置的时候就应该 分成两个子问题 去探索,向右向下。那每次到一个新的子问题就变成了 求新的位置到右下角的路径数量

注意到我们的题目里有提到障碍物,那障碍物意味着,此路不通,这一条路径应该被淘汰

我们看代码怎么写:

/**
 * @param {number[][]} obstacleGrid
 * @return {number}
 */
var uniquePathsWithObstacles = function(obstacleGrid) {
  // 得先知道是 几 x 几 的格子
  const m = obstacleGrid.length, n = obstacleGrid[0].length
  // 边界条件 起点即终点 是障碍物那就是0,不是障碍物那就是1
  if (m === 1 && n === 1) return obstacleGrid[0][0] ^ 1
  // 得到一个对应棋盘大小的二维数组
  const grid = Array.from(Array(m), () => Array(n).fill(0))
  // 递归函数
  const countPaths = (row, col) => {
    // 边界/中止条件
    if (row >= m || col >= n || obstacleGrid[row][col] === 1) return 0 // 超出棋盘或者碰到障碍物,越界不计数
    if (row === m - 1 && col === n - 1) return 1 // 到达右下角,计数 + 1
    // 记忆化搜索 ,分治 并 合并结果
    if (!grid[row][col]) grid[row][col] = countPaths(row + 1, col) + countPaths(row, col + 1)
    return grid[row][col]
  }
  // 开始递归
  countPaths(0, 0)
  // 返回路径总和
  return grid[0][0]
};

代码实现和我们的一维相比是不是稍微复杂了一些呢?但是还是那些步骤,不清楚的话可以打印这个 grid 来对比看看,再接着往下看递推如何实现

递推

我们从左上角出发,每次都需要知道 向下和向右 两种情况的结果是什么才能得到当前这个格子的路径和。

事实上我们可以从右下角来反向思考,那右下角代表终点,他能表示一个路径,那么与他相连的上一个格子和左边一个格子一定就是 1 。由于每一个格子的路径和取决于该格子的右侧和下侧,那么只要我们提前知道右侧和下侧格子的路径和,就能直接得到答案。

时间复杂度比较图

同理可得,最下,和最右的格子由于只能从自己的下/右侧各自获取路径和,所以他们只能是 1。

我们可以得到一个状态转移方程(DP方程)

dp[row][col] = dp[row + 1][col] + dp[row][col + 1]

那我们直接看代码:

/**
 * @param {number[][]} obstacleGrid
 * @return {number}
 */
var uniquePathsWithObstacles = function(obstacleGrid) {
  // 得先知道是 几 x 几 的格子
  let m = obstacleGrid.length, n = obstacleGrid[0].length
  if (obstacleGrid[m - 1][n - 1] === 1) return 0
  // 注意我们这里其实是把棋盘沿着左下到右上斜线翻转了的 也就是起点当成了终点 初始化行(顶行)和列(左列)
  let dp = Array.from(Array(m), () => Array(n).fill(0))
  // 先把最右和最下边填充
  for (let i = 0; i < m && obstacleGrid[i][0] === 0; ++i) {
    dp[i][0] = 1
  }
  for (let i = 0; i < n && obstacleGrid[0][i] === 0; ++i) {
    dp[0][i] = 1
  }
  for (let row = 1; row < m; ++row) {
    for (let col = 1; col < n; ++col) {
      // 遇到障碍直接返回 0
      dp[row][col] = obstacleGrid[row][col] === 1 ? 0 : dp[row - 1][col] + dp[row][col - 1]
    }
  }
  return dp[m - 1][n - 1]
};

细心的同学可以发现 我们把起点当成了终点去计数,把棋盘相当于沿着斜线做了一个翻转,这样做的原因是为了精简代码(否则将有很多 m - 1, n - 1,代码会非常乱),让逻辑更加明确,也方便debug,后续我们也会有很多起点当终点的操作。

各位同学也可以自己尝试不把起点当成终点去写写看,谁写谁知道(狗头)。

那实际上这道题目只不过是在斐波那契数的基础上变成了一个二维的递推,且增加了一个障碍物的分支判断逻辑。是不是没有想象的那么难呢?

你完全可以相信递推。

进阶

打家劫舍

打家劫舍也是一道比较常见的算法题,到这里希望大家已经对递推有了一定的领悟,并且尝试多用递推去解决问题

源题目来自: leetcode 198.打家劫舍

题目详情: 你是一个专业的小偷(如果真是小偷建议投案自首,争取宽大处理),计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例1:

输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例2:

输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

提示条件:

1 <= nums.length <= 100
0 <= nums[i] <= 400
使用递推解决

初步的解答思路:

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
  let n = nums.length
  // 边界条件,从提示可以得知 n 最小是 1
  if (n === 1) return nums[0]
  let dp = new Array(n).fill(0)
  // 从第一间房屋或者第二间房屋开始
  dp[0] = nums[0]
  dp[1] = nums[1]
  for (let i = 2; i < n; ++i) {
    // 从第四间房屋开始,我们实际上存在 偷第一间连续跳过两间再偷的可能性
    if (i >= 3) dp[i] = Math.max(dp[i - 2], dp[i - 3]) + nums[i]
    else dp[i] = dp[i - 2] + nums[i]
  }
  // 偷到倒数第二间的时候不能再偷最后一间,所以2选1
  return Math.max(dp[n - 1], dp[n - 2])
};

这个答案看起来还是有些复杂的,我们看到在 for 循环里我们还有对于 特殊情况的分支,这真的有必要吗?我们创造一个dp数组用来维护最优解真的是必要的吗?

那我们可能就有两个优化点:

  • 核心逻辑整理简化
  • 空间复杂度优化(能不能不用dp数组呢)

我们整理核心逻辑如下: 当 n > 2 时,对于第 n 间房屋我们有两个选择:

  1. 偷窃,那么 n - 1 房屋就不能偷,此时偷窃金额就是 前n - 2 房屋可以偷到的最高金额再加上当前第 n 间房屋可偷的金额
  2. 不偷,偷窃金额就是 n - 1 间房屋能偷到的最高金额

得到 DP 方程:

dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1])

边界条件: i > 2
只有一间屋直接偷,两间屋就选金额更大的偷
dp[0] = nums[0]
dp[1] = Math.max(nums[0], nums[1]) 

我们可以看到,每一间房屋能偷到的最高金额取决于 n - 1n - 2,那么我们就可以只维护两个变量来记录最优解。

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
  let n = nums.length
  // 边界条件,从提示可以得知 n 最小是 1
  if (n === 1) return nums[0]
  // fist 代表 n - 2, second 代表 n - 1
  let first = nums[0], second = Math.max(nums[0], nums[1])
  for (let i = 2; i < n; ++i) {
    // 先保存 n - 1 的值
    const temp = second
    // 修改 n - 1 的值,取决于 DP 方程选择
    second = Math.max(first + nums[i], second)
    // 修改 n - 2 值为最开始 n - 1 的值
    first = temp
  }
  return second
};

经过优化,我们利用两个变量巧妙的减少了空间复杂度。 那我们趁热打铁,来试一下 打家劫舍II 看看会有怎样的变化。

打家劫舍II

源题来自leetcode 213.打家劫舍 II

打家劫舍II 相对于第一版唯一的不同就是:屋子围成了一圈,你可以理解就是这个数组是滚动数组,也就是说第一间屋子和最后一间屋子是连在一起的,那么就意味着你偷窃第一间屋子,最后一间屋子是万万不能碰的。 这里再次提醒:《中华人民共和国刑法》 第二百六十四条 盗窃公私财物,数额较大的,或者多次盗窃、入户盗窃、携带凶器盗窃、扒窃的,处三年以下有期徒刑、拘役或者管制,并处或者单处罚金;数额巨大或者有其他严重情节的,处三年以上十年以下有期徒刑,并处罚金;数额特别巨大或者有其他特别严重情节的,处十年以上有期徒刑或者无期徒刑,并处罚金或者没收财产。

好,那么我们直接来看一下示例

示例1:

输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。

示例2:

输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例3:

输入:nums = [1,2,3]
输出:3

根据题意结合示例,我们不难发现该题目的关键点:

  1. 当房子数量小于 3 的时候,我们还是和初始题目那么处理,选择投第一家/第二家即可。
  2. 房子大于等于 3 的时候,(为了符合各位的逻辑思维我们用下标描述房子)我们发现当我们从第 0 家开始偷窃的时候,我们不能偷窃 n - 1 家(也就是只能偷到 n - 2 家);当我们从第 1 家开始偷窃的时候,我们只能偷到 n - 1 家。

综上所述我们发现我们其实有了一个偷窃范围:

  • (0, n - 2)
  • (1, n - 1)

那么我们只需要判断这两个偷窃范围他们哪个能偷到的更多就可以了!!复用上一题的代码,然后做一个比较即可!!

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
  const n = nums.length
  // 边界条件
  if (n === 1) return nums[0]
  else if (n === 2) return Math.max(nums[0], nums[1])
  // 定义核心逻辑函数
  const robRange = function(start, end) {
    // 这里之前的边界条件我们提取到外面去处理了
    // start 代表从下标几的房子开始,可能是 0 可能是 1
    // end 代表 n - 2 或者代表 n - 1
    let first = nums[start], second = Math.max(nums[start], nums[start + 1])
    for (let i = start + 2; i <= end; ++i) {
      // 先保存 n - 1 的值
      const temp = second
      // 修改 n - 1 的值,取决于 DP 方程选择
      second = Math.max(first + nums[i], second)
      // 修改 n - 2 值为最开始 n - 1 的值
      first = temp
    }
    return second
  };
  // 分别对 0 到 n - 2 和 1 到 n - 1进行处理
  return Math.max(robRange(0, n - 2), robRange(1, n - 1))
}

我们可以发现,动态规划题我们需要考虑到其中的关键因素,然后抽象成代码逻辑,这往往是比较困难的,需要大量的练习。如果没能发现关键点,也不必灰心,是很正常的,这也是动态规划题目的难点。加油加油!!

入门题目

leetcode 509.斐波那契数

leetcode 1137.第 N 个泰波那契数

leetcode 62.不同路径

leetcode 63.不同路径 II

leetcode 70.爬楼梯

leetcode 746.使用最小花费爬楼梯

进阶题目

leetcode 198.打家劫舍

leetcode 213.打家劫舍 II