前端刷题路-Day20|刷题打卡

227 阅读4分钟

掘金团队号上线,助你 Offer 临门! 点击 查看详情

斐波那契数列(题号509)

斐波那契数列为例子,斐波那契数列的概念很简单,从0和1开始,后续数的值为前两位的和,是酱紫的:

0 1 1 2 3 5 8 13 ...
链接

leetcode-cn.com/problems/fi…

基础解法

OK,在了解完基本概念之后就可以开始开发了,首先说说最简单的递归方法,也是一开始就能想到的:

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2)
}

这块的方法写的比较简单,首先判断是否为0或者1(此处不考虑n小于0的情况,后续同理,不再赘述)。如果是,直接返回n,否则返回前两位的和。

这种方法看上去十分简单,但如果简单打印下就会发现其执行的次数很多,接近2的n次方,举个例子:

var count  = 0

function fib(n) {
  count++
  return n <= 1 ? n : fib(n - 1) + fib(n - 2)
}

fib(5)
console.log(count)							// 15

count = 0

fib(10)
console.log(count)							// 177

很明显,刚刚数到10就已经执行了177次,这样的效率是十分低下的,那如果我们每次都记住之前的值呢,上面遍历的次数多是因为每次都要分解n,如果第一次遇到n的时候记住n是不是就可以了?

进阶解法

是的,这种方法确实可以减少很多工作量:

var count  = 0

function fib2(n, meno = []) {
  count++
  if (n === 0 || n === 1) {
    return n
  } else if (!meno[n]) {
    meno[n] = fib2(n - 1, meno) + fib2(n - 2, meno)
    return meno[n]
  } else {
    return meno[n]
  }
}

fib2(5)
console.log(count)						//  9

count = 0

fib2(10)
console.log(count) 					  // 19 

由于每次递归都记住了n的值,导致减少了大部分的递归次数,如果存在直接取值即可。

那还有没有更好的方式呢?这样做也是有O(2n)的时间复杂度,因为每个数数字都需要遍历两次。

动态规划

有的,那就是动态规划,动态规划的精华就是递归+记忆,话不多说,直接看代码:

var count  = 0

function fib3(n) {
  var arr = [0, 1]
  for (let i = 2; i <= n ; i++) {
    count++
    arr[i] = arr[i - 1] + arr[i - 2]
  }
  return arr[n]
}

fib3(5)
console.log(count)							// 4

count = 0

fib3(10)
console.log(count)							// 9

可以看到,上面的代码中循环的次数很少,可以当作O(n)的时间复杂度,这就是动态规划了,每次只需要取到前面两次的值而不需要再次执行一次函数,如果直接取arr[n]即可拿到最后结果。

最优路径

这题也是动态规划中很常见的一题了,具体就是Leetcode的62和63题,先说说62题,题目是这样的:

简单版(题号62)

链接:leetcode-cn.com/problems/un…

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?

输入:m = 3, n = 7
输出:28

就是这样一个简单的问题,正常的想法是先从第一格走,下边一种右边一种,然后再遍历下面一格和右边一格,如此一点点往下走,最后就可以拿到结果了。但从实际代码上来说,用这种思维方式来说很难能写出来代码,不如换一种方法,右下角开始往左上角走,每走一次统计这当前格子到达右下角的可能性,由于只能往右走和往下走,所以只需要加上下面和右边的格子即可。

其实看到这题的第一反应是使用递归,先拿到当前格子的坐标,然后对坐标进行x-1y-1的操作,然后再对x-1进行一样的操作,拿到x-1-1的值和y-1-1的值,以此类推。

这样做的确可行,并且其思维方式有点类似于上面的斐波那契数列基础解法。但问题也是同样的,并且这一题更加复杂,性能会是个很大的问题,LeetCode上也有人写出了递归的解法,结果就是超时。

这题由于每个格子的坐标都是唯一的,所以还不能用斐波那契数列的进阶解法,没法利用存数据的方法来减少递归次数,所以直接使用动态规划的方法:

var uniquePaths = function(m, n) {
  var board = []
  for (let i = 0; i < m; i++) {
    board[i] = new Array(n)
  }
  for (let i = m - 1; i > -1; i--) {
    for (let j = n - 1; j > -1; j--){
      if (i === m - 1 && j === n - 1) {
        board[i][j] = 1
      } else {
        board[i][j] = (i + 1 < m ? board[i + 1][j] : 0) + (j + 1 < n ? board[i][j + 1] : 0)
      }
    }    
  }
  return board[0][0]
};

整体逻辑没啥可说的,利用board来存储数据,从终点开始,记录下当前格子到起点的可能路径,如果下一格或者右边一格超出了边界,就当它的值是0。这样直接取board开头的值就是结果了。

👆的代码在判断边界条件时有些简写,正常可以不这么写,把三元拿出来会更好理解些,看上去也更舒服。

升级版(题号63)

链接:leetcode-cn.com/problems/un…

上面这题其实还是比较简单的,那如果在路径中增加不可以走的地方呢,比方说给定的二维数组:

[
	[0,0,0],
	[0,1,0],
	[0,0,0]
]

0是可以走的地方,1是不可以走的地方,在上面的例子中就是求从[0][0]走到[2][2]的可能路径。

看了上面的简单版本,可以很容易就得到答案,并且代码和上面差不多:

var uniquePathsWithObstacles = function(obstacleGrid) {
  var board = []
      row = obstacleGrid.length - 1
      col = obstacleGrid[0].length - 1
  for (let i = row; i > -1; i--){
    board[i] = new Array(col)
    for (let j = col; j > -1; j--) {
      if (i === row && j === col) {
        if (obstacleGrid[i][j] === 1) return 0
        board[i][j] = 1
      } else {
        var res = 0
        if (obstacleGrid[i][j] !== 1) {
          res = (i + 1 > row ? 0 : board[i + 1][j]) + (j + 1 > col ? 0 : board[i][j + 1])
        }
        board[i][j] = res
      }
    }
  }
  return board[0][0]
};

唯一区别就是增加对1的判断条件,别的没差,但一提交代码就会发现,这样写的性能只位于所有答案的后40%,这显然不是我们想要的结果,那还有什么更好的方法呢?

更好的答案(优雅)

在👆的代码中,两层循环中的判断条件有些太复杂了,有没有什么办法可以简化一下呢?显然是有的。

可以先把board的第一行和第一列算出来,如此在双层循环中就不要在进行复杂的条件判断了,而且这样可以优化下循环的条件,去掉边界值的判断。

先看代码:

const uniquePathsWithObstacles1 = (obstacleGrid) => {
  if (obstacleGrid[0][0] == 1) return 0; 							// 出发点就被障碍堵住 
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;
  // dp数组初始化
  const dp = new Array(m);
  for (let i = 0; i < m; i++) dp[i] = new Array(n);
  // base case
  dp[0][0] = 1;                 											// 终点就是出发点
  for (let i = 1; i < m; i++) { 											// 第一列其余的case
    dp[i][0] = (obstacleGrid[i][0] == 1 || dp[i - 1][0] == 0) ? 0 : 1;
  }
  for (let i = 1; i < n; i++) { 											// 第一行其余的case
    dp[0][i] = (obstacleGrid[0][i] == 1 || dp[0][i - 1] == 0) ? 0 : 1;
  }
  console.log(dp )
  // 迭代
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = obstacleGrid[i][j] == 1 ?
        0 :
        dp[i - 1][j] + dp[i][j - 1];
    }
  }
  return dp[m - 1][n - 1]; 														// 到达(m-1,n-1)的路径数
};

这里的dp就是上面的board,全称是dynamic program,也就是动态规划的英文。

首先拿到dp的第一行和第一列,此时的dp是酱婶的:

[
	[1,1,1],
	[1, , ],
	[1, , ]
]

之后循环mn就可以从1开始了,j-1i-1也就不会超过边界值了,省去了不少判断。

双层循环内部只要一个简单的判断即可。

但跑一下就会知道,性能并没有提升多少,那还有什么办法呢?

更好的答案(降维)

这就不是很好理解了,确实,我也是看了好几遍才理解了这个答案的真谛,这题估计是说不明白了,动手写一写吧,把每次循环i之后的result都写出来就好了。

var uniquePathsWithObstacles = function(obstacleGrid) {
  var n = obstacleGrid.length;
  var m = obstacleGrid[0].length;
  var result = Array(m).fill(0);
  for(var i = 0;i < n;i++){
      for(var j = 0;j < m;j++){
          if(i == 0 && j == 0){
              result[j] = 1;
          }
          if(obstacleGrid[i][j] == 1){
              result[j] = 0;
          }else if(j > 0){
              result[j] += result[j-1];
          }
        }
  }
  return result[m-1];
};

虽然难以理解,不过不得不说这种方法的性能强得一批,直接跳进了前90%。

小结

动态规划的基础介绍就酱了,还是和最开始说的一样,递推(动态规划) = 递归 + 记忆化。

也可以理解为递归或者记忆化升级之后就是动态规划了,其实它真没有想象中的那么难。



PS:想查看往期文章和题目可以点击下面的链接:

这里是按照日期分类的👇

前端刷题路-目录(日期分类)

经过有些朋友的提醒,感觉也应该按照题型分类
这里是按照题型分类的👇

前端刷题路-目录(题型分类)

有兴趣的也可以看看我的个人主页👇

Here is RZ