爬楼梯又名「青蛙跳台阶」 这道题在leetcode上题解高达5k+,就说明这道题非常的热门了。我本人是在面试和笔试都有碰到这道题,它本质是个数学问题名为「斐波那契数列」。 这道题虽然简单,但是非常经典,三种解法可以让大家很好的理解其中算法的精髓。
斐波那契数列
题目:假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
输入: n = 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
输入: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
不懂斐波那契数列其实是最好的,从算法角度去完成这道题。
思路
动态规划的题目分为两大类:
- 一种是求最优解类,典型问题是背包问题
- 另一种就是计数类,比如统计方案数的问题,它们都存在一定的递推性质。
前者的递推性质还有一个名字,叫做 「最优子结构」 ——即当前问题的最优解取决于子问题的最优解,后者类似,当前问题的方案数取决于子问题的方案数。所以在遇到求方案数的问题时,我们可以往动态规划的方向考虑。
关于这类问题的处理方式:将原问题拆解成若干个子问题,同时保存子问题的答案,使得每个子问题只求解一次,最终获得原问题的答案。
所以,对于递归性质的问题首先先找出重叠的子问题,然后有两种方法去解决:
- 记忆化搜索
- 动态规划
记忆化搜索和动态规划不同点就在于前者是自顶向下的解决问题,后者是自底向上。如果现在对自顶向下和自底向下不理解,做了这道题就明白了。
解法
回到爬楼梯的题目描述:每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
首先这个是一个递归问题,我们需要来通过规律找到重叠的子问题。假设爬楼梯函数为F,那么可以得到F(1)=1,F(2)=2,爬1楼有1种方法,爬2楼有两种方法,接下来F(3)=F(1)+F(2)合起来的方法,按照这种推导方式,爬n楼的时候F(n)=F(n-1)+F(n-2)。
1.递归
首先最简单的写法就是直接用递归:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
if(n <= 2) return n;
return climbStairs(n-1) + climbStairs(n-2);
};
这样写的话优点就是代码通俗易懂了,但是这样的时间复杂度是O(2^n)非常高,运行会超时。
2.动态规划
根据前面的图里的步骤,我们已经走到第二步找到了重叠子问题:F(n)=F(n-1)+F(n-2)。
那按照自底向上的思路来说,就是需要从最小单位一步步向上找到最终答案:已知F(1)和F(2)的情况下,根据F(n)=F(n-1)+F(n-2)我们下一步只能(必须)去找F(3)=F(2)+F(1),找到F(3)之后才能得出F(4)=F(3)+F(2)的结果,依次进行才能最终得出F(n)的结果,所以我们要使用一个数组比保存每次F(x)的答案,然后依次叠加:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function (n) {
const dp = [];
dp[1] = 1;
dp[2] = 2;
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
};
此时时间复杂度降为O(n),空间复杂度为O(n)。
仔细在看循环体的代码dp[i] = dp[i - 1] + dp[i - 2]
,每次循环需要i
之前的两个数,所以我们还可以再简化,定义两个变量代替长度为n
的数组dp
去存储需要的值,反正最后我们只需要dp[n]
的值,这样子可以将空间复杂度降为O(1)。
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function (n) {
if (n <= 2) return n;
let next1 = 1,
next2 = 2;
let val;
for (let i = 3; i <= n; i++) {
val = next1 + next2;
next1 = next2;
next2 = val;
}
return val;
};
此时时间复杂度还是O(n),但是空间复杂度就降为O(1)了。这样写只是dp[n]
数组里碰巧可以这样优化,并不是每一种动态规划都有机会这样去优化。
3.记忆化搜索
记忆化搜索是一种通过记录已经遍历过的状态的信息,从而避免对同一状态重复遍历的搜索实现方式。 因为记忆化搜索确保了每个状态只访问一次,它也是一种常见的动态规划实现方式。
记忆化搜索的要点就在记忆上。记忆化搜索是一种自顶向下的思路,因为要想要去记录已经遍历过的状态就得知道假设提前知道最终需要记录的结果。所以我们去求第n阶楼梯的结果。
比如爬楼梯这道题,我们首先假设爬到第n阶梯的时候需要一个[0,n]的数组去记录,所以先假设一个记忆的数组并且将值设为一个可以判断的值。
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function (n) {
let memory = new Array(n + 1).fill(-1);
return calWays(n, memory);
};
function calWays(n, memo) {
console.log(`进入cal函数:n=${n}`);
if (n == 0 || n == 1) return 1;
if (memo[n] == -1) {
console.log(`进入memo[n]判断-n:${n},memo[n]:${memo[n]}`);
memo[n] = calWays(n - 1, memo) + calWays(n - 2, memo);
}
return memo[n];
}
举一反三:连续子数组的最大和
Leetcode还有一道题,解法其实和爬楼梯差不多,但是大家看到这个题目会一眼看错。 连续子数组的最大和这道题和长度最小的子数组看起来很像,前面是要在数组里找子数组的最大的和,后面的是要找数组里,和小于等于某个数的最短连续子数组,返回的是数组的长度。
这两道题虽然看起来有点相似,但是完全是两种思路。一般大家会用滑动窗口的思路去考虑这种题,但是连续子数组的最大和是不能用这种思路的,因为它不限制数组的长度,任意长度的子数组组合都有可能是最大值,滑动窗口对于这道题就不起作用了,这种题用暴力解法求解就是for循环两次,把每种长度的连续子数组都试一次,就能找到最大和。
第二道题长度最小的子数组,这道题对数组的长度有限制,那用就天然的滑动窗口,因为滑动窗口就是不停地移动/变大变小去寻找答案的。
题目
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组 是数组中的一个连续部分。
输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6 。
解法
思路:
- 找数组连续元素里最大的和,这个第一反应就是暴力解法,把每种情况都算一遍,然后找出最大的。此时的时间复杂度是O(n^2),空间复杂度为O(1)。暴力学法这里就不具体写了。
- 这道题满足动态规划的第一种:求解最优解类。并且最优解是依赖于最优子结构(连续子数组)的。所以这道题应该用动态规划去求解。
首先是推导出动态规划的状态方程:
我们用dp[i]
代表以第 i
个数结尾的「连续子数组的最大和」,那么我们现在需要得到的结果就是Max{dp[i]}
,因此我们只需要求出每个位置的 dp[i]
,然后返回 dp
数组中的最大值即可。
所以问题就变成怎么去求dp[i]
,dp[i]
的值最终取决于dp[i-1]
和array[i]
的大小,对于连续子数组和来说,如果下一个值array[i]
比dp[i-1]
和还大的话,那么此时连续子数组就要从当前值开始了。
所以便有了下面的状态方程:
dp[i] = Max{dp[i - 1] + array[i], array[i]}
有了状态方程就比较好写代码了:
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function (nums) {
if (nums.length <= 1) return nums[0];
const dp = [nums[0]];
let sum = nums[0];
for (let i = 1; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
sum = Math.max(sum, dp[i]);
}
return sum;
};
此时的时间复杂度为O(n),空间复杂度为O(n)。 通过上面的爬楼梯的例子,这里其实可以把空间复杂度优化为O(1),我们只需要一个变量去存储和。
所以继续优化一下:
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function (nums) {
if (nums.length <= 1) return nums[0];
// const dp = [nums[0]];
let tmp = nums[0];
let sum = nums[0];
for (let i = 1; i < nums.length; i++) {
// dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
tmp = Math.max(tmp + nums[i], nums[i]);
sum = Math.max(sum, tmp);
}
return sum;
};