难度标识:
⭐:简单,⭐⭐:中等,⭐⭐⭐:困难
。
tips:这里的难度不是根据LeetCode难度定义的,而是根据我解题之后体验到题目的复杂度定义的。
1.爬楼梯 ⭐
思路
这个问题实际上是一个经典的动态规划问题。我们可以这样思考:
-
如果你在第 i 阶,那么你可能是从第 i−1 阶爬上来的,也可能是从第 i−2 阶爬上来的。
-
为了到达第 i 阶,你有
ways[i-1]
种方法从第 i−1 阶爬上来,有ways[i-2]
种方法从第 i−2 阶爬上来。 -
所以,总的方法数
ways[i] = ways[i-1] + ways[i-2]
。
以上面的递推关系,我们可以从低到高地计算出到达每一阶的方法数。
具体步骤如下:
-
初始化:如果 n = 1,则只有一种方法,即
ways[1] = 1
;如果 n = 2,则有两种方法,即ways[2] = 2
。 -
建立一个长度为 n+1 的数组
ways
,其中ways[i]
表示到达第 i 阶的方法数。 -
设置
ways[1] = 1
和ways[2] = 2
。 -
对于
i
从 3 到 n,计算ways[i] = ways[i-1] + ways[i-2]
。 -
返回
ways[n]
,即为到达第 n 阶的方法数。
从这个递推关系中,我们可以看出到达楼顶的方法数实际上是一个斐波那契数列。所以,你还可以使用斐波那契数列的公式或其他相关算法来解决这个问题。
但是,动态规划方法在这里是最直观和容易理解的。理解了动态规划我们可以对空间进行优化。
-
斐波那契数列 :到达每一级台阶的方法数是前两级的方法数之和。这与斐波那契数列的生成规则相同,即
F(i) = F(i-1) + F(i-2)
。 -
空间优化 :我们可以不使用一个数组来存储每一级的方法数,而是使用了两个变量
a
和b
来迭代计算。这是因为在计算某一级台阶的方法数时,你只关心前两级的方法数,而不关心之前所有级别的方法数。 -
值交换 :使用
[a, b] = [b, a + b]
解构赋值的写法进行交换操作,不需要引入临时变量,你在每次迭代中都交换了a
和b
的值,同时更新了b
为下一个级别的方法数。 -
结果输出 :循环结束后,变量
b
存储的就是到达第n
级的方法数,直接返回b
作为结果。
简而言之,核心思路是利用斐波那契数列的特性,同时通过两个变量来迭代计算方法数,从而达到了空间的优化。
代码
js
var climbStairs = function (n) {
if (n <= 2) return n
let a = 1, b = 2
for (let i = 3; i <= n; i++) {
[a, b] = [b, a + b]
}
return b
};
2.杨辉三角 ⭐
思路
要生成「杨辉三角」的前 numRows
行,我们可以使用以下核心思路:
-
初始化:
-
创建一个空的结果数组
result
。 -
如果
numRows
为0,直接返回空数组。
-
-
第一行:
- 第一行总是
[1]
。我们可以直接将其添加到结果数组中。
- 第一行总是
-
构建后续行:
-
从第二行开始迭代到第
numRows
行。 -
每行的第一个和最后一个数字总是
1
。 -
对于第
i
行的中间数字(如果存在的话),它等于第i-1
行的相邻两个数字之和。也就是说,假设上一行为prevRow
,则当前行的第j
个数字为prevRow[j-1] + prevRow[j]
。
-
-
更新结果数组:
- 按照上述规则构建每一行,并将其添加到结果数组
result
中。
- 按照上述规则构建每一行,并将其添加到结果数组
-
返回结果:
- 最后,返回填充好的
result
数组。
- 最后,返回填充好的
通过迭代构建每一行,并使用上一行来确定当前行的值,我们可以模拟「杨辉三角」的构建过程,并生成其前 numRows
行。
代码
js
var generate = function (numRows) {
let res = [], preRows = [1]
res.push(preRows)
for (let i = 2; i <= numRows; i++) {
const newRow = [1]
for (let j = 1; j < i - 1; j++) {
newRow.push(preRows[j - 1] + preRows[j])
}
newRow.push(1)
res.push(newRow)
preRows = newRow
}
return res
};
3.打家劫舍 ⭐
思路
这是一个经典的动态规划问题。核心思路如下:
假设 dp[i]
表示偷到第 i
个房子时可以得到的最大金额。对于第 i
个房子,有两种选择:
-
偷第
i
个房子:那么就不能偷第i-1
个房子,所以可以偷到的最大金额是第i-2
个房子的最大金额加上第i
个房子的金额,即dp[i-2] + nums[i]
。 -
不偷第
i
个房子:那么最大金额就是偷第i-1
个房子的金额,即dp[i-1]
。
于是,动态规划的转移方程为: dp[i]=max(dp[i−1],dp[i−2]+nums[i])
最终的答案是 dp[n-1]
,其中 n
是房子的数量。跟第一题一样,这题我们也可以使用空间优化。
-
两种选择:对于每个房屋,小偷可以选择偷窃它或跳过它。但如果他决定偷窃当前的房屋,那么他就不能偷窃前一个房屋。
-
动态规划:通过遍历每个房屋,并在每步中选择"偷"或"不偷"的最优解,小偷可以确定偷窃到的最大金额。
-
状态变量 :使用两个变量
pre
和res
保存之前的状态。其中,pre
保存前一个房屋的最大偷窃金额,而res
保存当前房屋的最大偷窃金额。 -
状态转移:对于每个房屋,小偷的选择是继续保持之前的最大偷窃金额(即不偷当前的房屋),或者偷窃当前的房屋加上前前一个房屋的最大偷窃金额(因为不能偷窃相邻的房屋)。
-
优化:由于每次的决策只依赖于前两个房屋的决策,因此不需要保存整个数组的状态,只需要两个变量即可。这大大减少了空间的使用。
这种方法的关键是理解如何使用两个变量来跟踪前两个房屋的最优决策,并在每步中更新这些变量。
代码
js
var rob = function (nums) {
const n = nums.length
if (n === 1) return nums[0]
let pre = nums[0], res = Math.max(nums[0], nums[1])
for (let i = 2; i < n; i++) {
[pre, res] = [res, Math.max(res, pre + nums[i])]
}
return res
};
4.完全平方数 ⭐⭐
思路
这个问题可以使用动态规划来解决。核心思路如下:
-
初始化 :创建一个数组
dp
,长度为n + 1
,用于存储从0
到n
每个数字所需要的最少完全平方数的数量。初始化为正无穷大,因为我们想要计算的是最小值。但是dp[0]
需要初始化为0
,因为数字0
不需要任何完全平方数。 -
完全平方数的列表 :首先,生成一个小于或等于
n
的完全平方数列表。例如,对于n = 12
,完全平方数列表为[1, 4, 9]
。 -
状态转移方程 : 对于数组中的每个数字
i
和上面提到的完全平方数列表中的每个完全平方数j
,我们有以下状态转移方程: dp[i]=min(dp[i],dp[i−j]+1) 这意味着为了得到数字i
所需的最少完全平方数的数量,我们可以查看数字i - j
所需的最少完全平方数的数量,并添加一个(即j
是一个完全平方数,所以加1
)。 -
结果 :最后,
dp[n]
将包含和为n
的完全平方数的最少数量。
这种方法利用了动态规划的基本思想:将一个复杂问题分解为较小的子问题,并使用子问题的解决方案来构建原始问题的解决方案。在这种情况下,为了找到一个特定数字所需的最少完全平方数的数量,我们查看较小的数字所需的数量,并考虑添加一个完全平方数。
代码
js
var numSquares = function (n) {
const dp = new Array(n + 1).fill(Infinity)
dp[0] = 0
for (let i = 1; i <= n; i++) {
for (let j = 1; j * j <= i; j++) {
dp[i] = Math.min(dp[i], dp[i - j * j] + 1)
}
}
return dp[n]
};
5.零钱兑换 ⭐
思路
这个问题也是一个典型的动态规划问题。以下是解决该问题的核心思路:
-
状态定义:
- 使用数组
dp
,其中dp[i]
表示组成金额i
所需的最少硬币数量。数组的长度应该为amount + 1
,并且初始化为一个较大的值(例如amount + 1
),因为我们想要找的是最小值。将dp[0]
初始化为0
,因为金额为0
时不需要任何硬币。
- 使用数组
-
状态转移方程:
- 遍历所有的硬币面额
coin
。对于每个coin
,再从coin
遍历到amount
,更新dp
的值: dp[i]=min(dp[i],dp[i−coin]+1) 这里的意思是:如果选择使用当前的coin
,那么金额i
所需的硬币数量就是金额i - coin
所需的硬币数量加上这一个硬币。
- 遍历所有的硬币面额
-
结果返回:
- 如果
dp[amount]
大于amount
(说明我们没有找到任何硬币组合来组成金额amount
),则返回-1
。 - 否则,返回
dp[amount]
。
- 如果
简单来说,该问题的核心思路是使用动态规划,从每一个小的金额开始,找出组成该金额所需的最少硬币数量。我们利用已经计算出来的较小金额的答案,逐步计算出组成目标金额 amount
所需的最少硬币数量。
代码
js
var coinChange = function (coins, amount) {
const val = amount + 1
const dp = new Array(val).fill(val)
dp[0] = 0
for (let i = 1; i < val; i++) {
for (let coin of coins) {
if (i - coin >= 0) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1)
}
}
}
return dp[amount] === val ? -1 : dp[amount]
};
6.单词拆分 ⭐
思路
解这题的核心思路:
-
状态定义:
- 使用一个布尔数组
dp
,其中dp[i]
表示字符串s
的前i
个字符是否可以被wordDict
中的单词拼接。dp[0]
代表空字符串,因此初始化为true
。
- 使用一个布尔数组
-
状态转移方程:
-
对于字符串
s
的前i
个字符是否可以被拼接,我们遍历j
,其中j
是从0
到i
。如果dp[j]
是true
并且s.substring(j, i)
(即从j
到i
的子串)在wordDict
中,那么dp[i]
就是true
。形式上,状态转移方程是:
dp[i]=dp[j] && s.substring(j,i) 存在于 wordDict 中
对于每一个
i
,我们检查所有可能的j
(即所有可能的前一个单词的结束位置)。
-
-
结果返回:
- 如果
dp[s.length()]
是true
,那么就可以从wordDict
拼接出字符串s
,返回true
。否则,返回false
。
- 如果
简单地说,我们从左到右遍历字符串 s
,对于每个位置,检查是否存在一个 j
使得 dp[j]
是 true
并且从 j
到当前位置的子串在 wordDict
中。如果存在这样的 j
,则当前位置也可以被拼接。
代码
js
var wordBreak = function (s, wordDict) {
const set = new Set(wordDict)
const n = s.length
const dp = new Array(n + 1).fill(false)
dp[0] = true
for (let i = 1; i <= n; i++) {
for (let j = 0; j < i; j++) {
if (dp[j] && set.has(s.substring(i, j))) {
dp[i] = true
break
}
}
}
return dp[n]
};
7.最长递增子序列 ⭐
思路
解这题的核心思路:
-
状态定义:
- 使用一个整数数组
dp
,其中dp[i]
表示以nums[i]
结尾的最长严格递增子序列的长度。这意味着任何一个有效的子序列,它的最后一个数字就是nums[i]
。
- 使用一个整数数组
-
初始化:
- 我们可以将整个
dp
数组初始化为1
,因为每个元素本身都可以形成一个长度为1
的子序列。
- 我们可以将整个
-
状态转移方程:
-
遍历数组,对于每个
nums[i]
,再遍历其之前的所有元素nums[j]
(j < i
)。- 如果
nums[i]
大于nums[j]
,这意味着nums[i]
可以跟在nums[j]
之后,形成一个更长的递增子序列。因此,dp[i]
可能需要更新,它要么保持不变,要么成为dp[j] + 1
的值,这取决于哪一个值更大。
形式上,状态转移方程是:
dp[i]=max(dp[i],dp[j]+1) if nums[i]>nums[j]
- 如果
-
-
找到最大值:
- 遍历完数组后,我们只需要从
dp
数组中找到最大值,即为最长严格递增子序列的长度。
- 遍历完数组后,我们只需要从
这种方法的基本思想是:考虑到目前为止的每个元素,并查看以它为结尾的最长递增子序列可以有多长。在考虑每个元素时,我们查看所有先前的元素并确定是否可以形成一个更长的递增子序列。最终,动态规划数组 dp
中的最大值就是我们要找的答案。
代码
js
var lengthOfLIS = function (nums) {
const n = nums.length
const dp = new Array(n + 1).fill(1)
let maxLen = 1
for (let i = 0; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1)
}
maxLen = Math.max(maxLen, dp[i])
}
}
return maxLen
};
8.乘积最大子数组 ⭐
思路
这个问题的一个重要的观察是,一个负数乘以一个负数会得到一个正数,所以我们需要考虑当前负数乘积最小的子数组,因为它可能会因为另一个负数变成最大的乘积。
因此,我们需要维护两个状态:以 nums[i]
结尾的乘积最大的子数组以及乘积最小的子数组。这样我们可以处理数组中的负数和零。
以下是解决问题的核心思路:
-
状态定义:
-
maxEndHere[i]
表示以nums[i]
结尾的最大乘积子数组的乘积。 -
minEndHere[i]
表示以nums[i]
结尾的最小乘积子数组的乘积。
-
-
初始化:
-
maxEndHere[0]
和minEndHere[0]
都初始化为nums[0]
。 -
maxProduct
初始化为nums[0]
。这是最终的答案,表示到目前为止的最大乘积。
-
-
状态转移方程:
-
对于每个
i
从1
到n-1
(n
是数组长度),我们更新maxEndHere[i]
和minEndHere[i]
:-
maxEndHere[i]
是以下三者的最大值:-
nums[i]
-
nums[i]
乘以maxEndHere[i-1]
(如果前面的连续子数组乘积是正的,它会增加当前值) -
nums[i]
乘以minEndHere[i-1]
(如果前面的连续子数组乘积是负的,由于当前的nums[i]
可能也是负的,它可能会产生更大的乘积)
-
-
同样,
minEndHere[i]
是以下三者的最小值:-
nums[i]
-
nums[i]
乘以maxEndHere[i-1]
-
nums[i]
乘以minEndHere[i-1]
-
-
-
-
更新全局最大值:
- 对于每个
i
,我们更新maxProduct = max(maxProduct, maxEndHere[i])
。
- 对于每个
-
返回结果:
- 返回
maxProduct
。
- 返回
简而言之,我们不仅跟踪了乘积最大的子数组,而且还跟踪了乘积最小的子数组,因为负数的存在可能会反转乘积的符号。最后,最大的连续乘积必然在这两者之一。这题我们也可以进行空间优化。
-
双重状态跟踪 :对于每个数组元素,我们都维护两个状态,一个是到该位置的最大乘积(
maxVal
),另一个是到该位置的最小乘积(minVal
)。我们需要跟踪最小乘积是因为一个负数乘以最小乘积可能会变成一个更大的正数。 -
负数的处理 :如果当前的数字是负数,那么乘上它会使最大值变成最小值,最小值变成最大值。因此,当我们遇到负数时,我们交换
maxVal
和minVal
。 -
更新状态 :对于每个数组元素,我们更新
maxVal
和minVal
。maxVal
是当前元素和maxVal
与当前元素的乘积中的较大者。同样,minVal
是当前元素和minVal
与当前元素的乘积中的较小者。 -
更新结果 :在每一步中,我们都用
maxVal
更新结果res
,以确保我们有到目前为止的最大乘积。
最终,res
会包含数组中乘积最大的连续子数组的乘积。
代码
js
var maxProduct = function (nums) {
let maxVal = nums[0], minVal = nums[0], res = nums[0]
for (let i = 1; i < nums.length; i++) {
if (nums[i] < 0) {
[maxVal, minVal] = [minVal, maxVal]
}
maxVal = Math.max(nums[i], maxVal * nums[i])
minVal = Math.min(nums[i], minVal * nums[i])
res = Math.max(res, maxVal)
}
return res
};
9.分割等和子集 ⭐⭐
思路
解决这题的核心思路:
-
总和的计算 :首先,计算数组
nums
的总和。如果总和是奇数,那么不可能将其分成两个和相等的子集,因为两个整数的和如果是奇数,那么这两个整数必然一个是奇数,一个是偶数。 -
目标和 :如果总和是偶数,我们的目标是找到一个子集,其和为总和的一半,即
sum(nums) / 2
。 -
动态规划:
-
使用一个布尔型的二维数组
dp
,其中dp[i][j]
表示从数组的前i
个元素中选取一些,能否得到总和为j
的子集。 -
初始化
dp[0][0] = true
,表示从前0个元素中选择可以得到和为0的子集。 -
递推关系是:如果我们可以从前
i-1
个元素中得到和为j
的子集,或者我们可以从前i-1
个元素中得到和为j - nums[i]
的子集(这意味着加上nums[i]
可以得到和为j
的子集),那么我们可以从前i
个元素中得到和为j
的子集,即dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i]]
。
-
-
结果 :我们只需检查
dp[n][sum(nums) / 2]
是否为真,其中n
是数组的长度。如果为真,说明可以从数组中选择一些元素,使得它们的和为sum(nums) / 2
,从而满足题意。
注意:这种方法基于动态规划,其时间和空间复杂度都是 O(n * target),其中 n
是数组长度,target
是目标和,即 sum(nums) / 2
。下面的代码是将二维降到了一维。
代码
js
var canPartition = function (nums) {
const sum = nums.reduce((acc, cur) => acc + cur, 0)
if (sum % 2 === 1) return false
const target = sum / 2
const dp = new Array(target + 1).fill(false)
dp[0] = true
for (let num of nums) {
for (let i = target; i >= num; i--) {
dp[i] = dp[i] || dp[i - num]
}
}
return dp[target]
};
10.最长有效括号 ⭐
思路
这题我们不使用动态规划的解法,使用更简单的双指针的解法。
-
初始化计数器:
- 使用两个计数器:
left
和right
。这两个计数器分别跟踪遇到的左括号'('
和右括号')'
的数量。
- 使用两个计数器:
-
从左到右遍历:
-
遍历字符串中的每一个字符。
-
对于每一个字符:
-
如果是
'('
,增加left
计数器。 -
如果是
')'
,增加right
计数器。 -
如果
left
与right
相等,说明到目前为止我们遇到的左括号和右括号可以形成有效的括号子串。此时,记录当前有效子串的长度,并与之前的最长有效子串长度进行比较,更新最长长度。 -
如果
right
超过left
,说明到目前为止的右括号太多,不能形成有效的括号子串。此时,需要重置left
和right
为0,因为这种情况下,任何之前的信息都不再有用。
-
-
-
从右到左遍历:
-
使用相似的方法再次遍历字符串,但这次是从右到左。
-
与前一个方向相反,这次的目的是找到类似
"((())"
这样的子串,其中从左到右的方法会失败,但从右到左可以找到正确的结果。 -
对于每一个字符:
-
如果是
')'
,增加right
计数器。 -
如果是
'('
,增加left
计数器 -
如果
left
与right
相等,再次更新最长有效子串的长度。 -
如果
left
超过right
,重置两个计数器。
-
-
-
得到结果:
- 在两次遍历结束后,你会得到整个字符串中的最长有效括号子串的长度。
这种方法的优点是空间复杂度为 O(1)。而时间复杂度为 O(n),因为你只需要遍历整个字符串两次。
代码
js
var longestValidParentheses = function (s) {
let left = 0, right = 0, maxLen = 0
for (let i = 0; i < s.length; i++) {
if (s[i] === '(') {
left++
} else {
right++
}
if (left === right) {
maxLen = Math.max(maxLen, left * 2)
} else if (right > left) {
left = 0;
right = 0
}
}
left = 0, right = 0
for (let i = s.length - 1; i >= 0; i--) {
if (s[i] === '(') {
left++
} else {
right++
}
if (left === right) {
maxLen = Math.max(maxLen, right * 2)
} else if (left > right) {
left = 0;
right = 0
}
}
return maxLen
};
总体来说,动态规划类的题目理解起来还是需要花一些时间的,建议看看B站上的一些相关视频,加深理解,但是动态规划题的解题代码量其实还是偏少的,几行代码就搞定了。