阅读 2542

聊一聊前端算法面试——动态规划

文章首发于:github.com/USTB-musion…

写在前面

现在竞争越来越激烈,以往前端算法面试只问问排序的日子一去不复返了。现在大厂喜欢问一些进阶性的算法问题,比如今天要聊的面试中经常出现但理解起来有些困难的一种算法思想——「动态规划」。

先看下几个常见的面试题:

  1. 假如楼梯有n个台阶,每次可以走1个或2个台阶,请问走完这n个台阶有几种走法(动态规划实现)❓
  2. 如下图所示:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径❓

  1. 在M件物品里取出若干件放在大小为W的背包里,每件物品的体积为W1,W2,W3····Wn,与这些物品对应的价值分别对应为P1,P2,P3·····Pn,如何求出这个背包能装的最大价值❓

上面这些问题是非常常见的动态规划的题目,你可以先思考一下如何回答上边的问题🤔,然后带着答案来阅览接下来的内容。

什么是动态规划❓

动态规划,英文是Dynamic Programming,简称DP,擅长解决“多阶段决策问题”,利用各个阶段阶段的递推关系,逐个确定每个阶段的最优决策,并最终得到原问题的最优决策。

动态规划与递归

  • 动态规划本质上不是递归,甚至可以理解是和递归相反的一种算法设计思想。
  • 递归是自顶向下的,从顶部开始分解问题,然后通过解决分解出的小问题,从而解决出整个问题
  • 动态规划是自底向上的,从底部开始解决问题,按照顺序一步一步扩大问题的规模从而去解决整个问题

先来看一道面试题:假如楼梯有n个台阶,每次可以走1个或2个台阶,请问走完这n个台阶有几种走法❓具体如何分析这道题目,可以看下笔者前段时间写的文章:聊一聊前端算法面试——递归

第一种方法:暴力递归

function climbStairs(n) {
  if (n == 1) return 1
  if (n == 2) return 2
  return climbStairs(n-1) + climbStairs(n-2)
}
复制代码

暴力递归这种方法通俗易懂,但是非常低效,我们可以来看下它的递归树:

这个递归树怎么理解?这是一种自顶向下的方法,我们想求出f(10),得先求出子问题f(9)和f(8),并且满足f(10)=f(9)+f(8),同理可得f(9)=f(8)+f(7),f(8)=f(7)+f(6),······f(3)=f(2)+f(1)。最后遇到f(2)或者f(1)时,一颗完整的递归树就出来了,这其实就是一个二叉树。

递归算法的时间复杂度怎么求?子问题个数乘与解决一个子问题所需要的时间

从上图中可以看出,随着问题规模的增长,这是一个指数级别的算法,时间复杂度为O(2^n)。从上图f(10)为例,暴力递归有大量的子问题被重复计算。f(7)被计算了2次,f(6)被计算了4次,而上层的每一次计算更是把底层的f(1)和f(2)都计算了,可以看出这是一种及其低效的做法。那有木有什么改进的方法呢❓

第二种方法:加上memorize操作,即备忘录的递归

既然暴力递归低效的根本原因是有大量的子问题被重复计算,那能不能把这些子问题缓存起来呢?把这些子问题放在特定的数据结构里,当计算某个子问题时,先去这个数据结构里查一下,如果原来有缓存,则直接返回。如果原来没有缓存,则把这个子问题缓存起来,方便下次使用。这样就能优化暴力递归低效的原因了。

var calculated = []

function climbStairs(n) {

    if(n == 1) {
        return 1
    }else if (n == 2) {
        return 2
    }else {
        if(!calculated[n-1]){
            calculated[n-1] = climbStairs(n-1)
        }

        if(!calculated[n-2]){
            calculated[n-2] = climbStairs(n-2)
        }
        return calculated[n-1] + calculated[n-2]
    }

}
复制代码

我们来看一下时间复杂度为多少?通过memorize操作,把巨大的递归树进行“剪枝”操作,把需要重复计算的子问题都缓存起来,没有冗余的计算,时间复杂度和问题规模成正比,即为O(n)。

第三种方法:动态规划

动态规划需要满足3个条件:最优子问题边界条件状态转移方程

1.最优子问题

f(10)=f(9)+f(8),就是f(10)问题的最优子问题,如果求出f(9)和f(8)的最优子问题,那么就是f(10)的最优子问题了

2.边界条件

动态规划是自顶向下的设计思想,以爬楼梯为例,最后分解到底层的边界条件就是f(1)=1,f(2)=2。

3.状态转移方程

其实,动态规划最难的步骤就是写出状态转移方程,那么如何来写出状态转移方程呢?状态转移方程可以理解是描述数学问题的数学方程式,对于爬楼梯问题来说,可以发现其状态转移方程为 f[i]=f[i-1]+f[i-2],从最开始的1和2个台阶两个状态开始,自底向上进行求解:

function climbStairs(n) {
  var val = [];
  for ( var i = 0; i <= n ; ++i) {
    val[i] = 0
  } 

  if (n <= 2) {
    return n
  } else {
    val[1] = 1
    val[2] = 2
    for (var i = 3; i <= n; ++i) {
      val[i] = val[i-1] + val[i-2]
    }
    return val[n]
  }
}
console.log(climbStairs(10)) // 55
复制代码

面试题2:不同路径问题

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

这是一道leetcode的原题,如果你在面试中遇到这道题,该怎么应答呢?还记得上面说的动态规划三要素吗❓

1.最优子问题

动态规划是自底向上的思想,可能跟大部分人的思路是相反的。如上图所示,我们想达到终点F(7*3),无非有两种情况,一种是F(7*2)向下走一步,一种是F(6*3)向右走一步。所以我们可以得出打到终点的最优子问题是F(7*2)和F(6*3)。

2.状态转移方程

根据最优子问题的分析,容易想出状态转移方程为F(m*n) = F((m-1)*n) + F(m*(n-1))。

3.边界条件
  • 如果只有1行,遍历第一行,如果有一个格点初始值为 1 ,说明当前节点有障碍物,没有路径可以通过,设值为 0 ;
  • 如果只有1列,遍历第一列,如果有一个格点初始值为 1 ,说明当前节点有障碍物,没有路径可以通过,设值为 0 ;
var uniquePathsWithObstacles = function(arr) {
  // arr为二维数组,m为行,n为列
  let n = arr.length, m = arr[0].length;
  let temp = [];

  // 初始化将格子填充为0
  for (let i = 0; i < n; i++) {
    temp[i] = Array(m).fill(0)
  }

  // 如果起始或终止目标有障碍物,则直接返回0
  if (arr[0][0] == 1 || arr[n - 1][m - 1] == 1) {
    return 0
  }

  // 遍历二维数组的列数
  for (i = 0; i < n; i++) {
    // 遍历二维数组的行数
    for (let j = 0; j < m; j++) {
      if (i == 0 && j == 0) {
        temp[i][j] = 1;
        // 第一种边界情况:1行n列
      } else if (i == 0) {
        if (arr[i][j] != 1 && temp[i][j - 1] != 0) {
          temp[i][j] = 1;
        } else {
          temp[i][j] = 0;
        }
        // 第二种边界情况: m行1列
      } else if (j == 0) {
        if (arr[i][j] != 1 && temp[i - 1][j] != 0) {
          temp[i][j] = 1;
        } else {
          temp[i][j] = 0;
        }
      } else if (arr[i][j] != 1) {
        // 如果不是上述的两种边界情况,终止条件的到达方式是i-1,j和i,j-1的和
        temp[i][j] = temp[i - 1][j] + temp[i][j - 1]
      }
    }
  }
  return temp[n - 1][m - 1]
};

console.log(uniquePathsWithObstacles([[0,0,0],[0,1,0],[0,0,0]])) // 2
复制代码

面试3:背包问题

在M件物品里取出若干件放在大小为W的背包里,每件物品的体积为W1,W2,W3····Wn,与这些物品对应的价值分别对应为P1,P2,P3·····Pn,如何求出这个背包能装的最大价值❓

function beibao(M, W, arrP, arrW) {
  var result = []
  for (var i = 0; i <= M; i++) {
    result[i] = []
    for (var j = 0; j <= W; j++) {
      if ( i == 0) {
        result[i][j] = 0
      } else if ( arrW[i-1] > j) {
        result[i][j] = result[i-1][j]
      } else {
        result[i][j] = Math.max(arrP[i-1] + result[i-1][j - arrW[i-1]], result[i-1][j])
      }
    }
  }
  return result[i-1][j-1]
}

var M = 5; // 物体个数
var W = 16; // 背包总容量
var arrP = [4,5,10,11,13]; // 物体价值
var arrW = [3,4,7,8,9]; // 物体个数
console.log(beibao(M, W, arrP, arrW)); // 23

复制代码

总结

动态规划适合解决重叠子问题和最优子结构性质的问题,三要素为「最优子问题」,「边界条件」和「状态转移方程」,其中解决动态规划这类问题的关键在于写出「状态转移方程」,而写出状态转移方程法的思路为:

  • 找出最优子问题
  • 写出状态转移方程
  • 将状态转移方程翻译成代码
关注下面的标签,发现更多相似文章
评论