掘金团队号上线,助你 Offer 临门! 点击 查看详情
斐波那契数列(题号509)
以斐波那契数列为例子,斐波那契数列的概念很简单,从0和1开始,后续数的值为前两位的和,是酱紫的:
0 1 1 2 3 5 8 13 ...
链接
基础解法
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-1
和y-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, , ]
]
之后循环m
和n
就可以从1开始了,j-1
和i-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:想查看往期文章和题目可以点击下面的链接:
这里是按照日期分类的👇
经过有些朋友的提醒,感觉也应该按照题型分类
这里是按照题型分类的👇
有兴趣的也可以看看我的个人主页👇