LeetCode热题100动态规划题解析

难度标识:⭐:简单,⭐⭐:中等,⭐⭐⭐:困难
tips:这里的难度不是根据LeetCode难度定义的,而是根据我解题之后体验到题目的复杂度定义的。

1.爬楼梯

思路

这个问题实际上是一个经典的动态规划问题。我们可以这样思考:

  1. 如果你在第 i 阶,那么你可能是从第 i−1 阶爬上来的,也可能是从第 i−2 阶爬上来的。

  2. 为了到达第 i 阶,你有 ways[i-1] 种方法从第 i−1 阶爬上来,有 ways[i-2] 种方法从第 i−2 阶爬上来。

  3. 所以,总的方法数 ways[i] = ways[i-1] + ways[i-2]

以上面的递推关系,我们可以从低到高地计算出到达每一阶的方法数。

具体步骤如下:

  1. 初始化:如果 n = 1,则只有一种方法,即 ways[1] = 1;如果 n = 2,则有两种方法,即 ways[2] = 2

  2. 建立一个长度为 n+1 的数组 ways,其中 ways[i] 表示到达第 i 阶的方法数。

  3. 设置 ways[1] = 1ways[2] = 2

  4. 对于 i 从 3 到 n,计算 ways[i] = ways[i-1] + ways[i-2]

  5. 返回 ways[n],即为到达第 n 阶的方法数。

从这个递推关系中,我们可以看出到达楼顶的方法数实际上是一个斐波那契数列。所以,你还可以使用斐波那契数列的公式或其他相关算法来解决这个问题。

但是,动态规划方法在这里是最直观和容易理解的。理解了动态规划我们可以对空间进行优化。

  1. 斐波那契数列 :到达每一级台阶的方法数是前两级的方法数之和。这与斐波那契数列的生成规则相同,即 F(i) = F(i-1) + F(i-2)

  2. 空间优化 :我们可以不使用一个数组来存储每一级的方法数,而是使用了两个变量 ab 来迭代计算。这是因为在计算某一级台阶的方法数时,你只关心前两级的方法数,而不关心之前所有级别的方法数。

  3. 值交换 :使用 [a, b] = [b, a + b] 解构赋值的写法进行交换操作,不需要引入临时变量,你在每次迭代中都交换了 ab 的值,同时更新了 b 为下一个级别的方法数。

  4. 结果输出 :循环结束后,变量 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 行,我们可以使用以下核心思路:

  1. 初始化

    • 创建一个空的结果数组 result

    • 如果 numRows 为0,直接返回空数组。

  2. 第一行

    • 第一行总是 [1]。我们可以直接将其添加到结果数组中。
  3. 构建后续行

    • 从第二行开始迭代到第 numRows 行。

    • 每行的第一个和最后一个数字总是 1

    • 对于第 i 行的中间数字(如果存在的话),它等于第 i-1 行的相邻两个数字之和。也就是说,假设上一行为 prevRow,则当前行的第 j 个数字为 prevRow[j-1] + prevRow[j]

  4. 更新结果数组

    • 按照上述规则构建每一行,并将其添加到结果数组 result 中。
  5. 返回结果

    • 最后,返回填充好的 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 个房子,有两种选择:

  1. 偷第 i 个房子:那么就不能偷第 i-1 个房子,所以可以偷到的最大金额是第 i-2 个房子的最大金额加上第 i 个房子的金额,即 dp[i-2] + nums[i]

  2. 不偷第 i 个房子:那么最大金额就是偷第 i-1 个房子的金额,即 dp[i-1]

于是,动态规划的转移方程为: dp[i]=max(dp[i−1],dp[i−2]+nums[i])

最终的答案是 dp[n-1],其中 n 是房子的数量。跟第一题一样,这题我们也可以使用空间优化

  1. 两种选择:对于每个房屋,小偷可以选择偷窃它或跳过它。但如果他决定偷窃当前的房屋,那么他就不能偷窃前一个房屋。

  2. 动态规划:通过遍历每个房屋,并在每步中选择"偷"或"不偷"的最优解,小偷可以确定偷窃到的最大金额。

  3. 状态变量 :使用两个变量 preres 保存之前的状态。其中,pre 保存前一个房屋的最大偷窃金额,而 res 保存当前房屋的最大偷窃金额。

  4. 状态转移:对于每个房屋,小偷的选择是继续保持之前的最大偷窃金额(即不偷当前的房屋),或者偷窃当前的房屋加上前前一个房屋的最大偷窃金额(因为不能偷窃相邻的房屋)。

  5. 优化:由于每次的决策只依赖于前两个房屋的决策,因此不需要保存整个数组的状态,只需要两个变量即可。这大大减少了空间的使用。

这种方法的关键是理解如何使用两个变量来跟踪前两个房屋的最优决策,并在每步中更新这些变量。

代码

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.完全平方数 ⭐⭐

思路

这个问题可以使用动态规划来解决。核心思路如下:

  1. 初始化 :创建一个数组 dp,长度为 n + 1,用于存储从 0n 每个数字所需要的最少完全平方数的数量。初始化为正无穷大,因为我们想要计算的是最小值。但是 dp[0] 需要初始化为 0,因为数字 0 不需要任何完全平方数。

  2. 完全平方数的列表 :首先,生成一个小于或等于 n 的完全平方数列表。例如,对于 n = 12,完全平方数列表为 [1, 4, 9]

  3. 状态转移方程 : 对于数组中的每个数字 i 和上面提到的完全平方数列表中的每个完全平方数 j,我们有以下状态转移方程: dp[i]=min(dp[i],dp[i−j]+1) 这意味着为了得到数字 i 所需的最少完全平方数的数量,我们可以查看数字 i - j 所需的最少完全平方数的数量,并添加一个(即 j 是一个完全平方数,所以加 1)。

  4. 结果 :最后,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.零钱兑换

思路

这个问题也是一个典型的动态规划问题。以下是解决该问题的核心思路:

  1. 状态定义

    • 使用数组 dp,其中 dp[i] 表示组成金额 i 所需的最少硬币数量。数组的长度应该为 amount + 1,并且初始化为一个较大的值(例如 amount + 1),因为我们想要找的是最小值。将 dp[0] 初始化为 0,因为金额为 0 时不需要任何硬币。
  2. 状态转移方程

    • 遍历所有的硬币面额 coin。对于每个 coin,再从 coin 遍历到 amount,更新 dp 的值: dp[i]=min(dp[i],dp[i−coin]+1) 这里的意思是:如果选择使用当前的 coin,那么金额 i 所需的硬币数量就是金额 i - coin 所需的硬币数量加上这一个硬币。
  3. 结果返回

    • 如果 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.单词拆分

思路

解这题的核心思路:

  1. 状态定义

    • 使用一个布尔数组 dp,其中 dp[i] 表示字符串 s 的前 i 个字符是否可以被 wordDict 中的单词拼接。dp[0] 代表空字符串,因此初始化为 true
  2. 状态转移方程

    • 对于字符串 s 的前 i 个字符是否可以被拼接,我们遍历 j,其中 j 是从 0i。如果 dp[j]true 并且 s.substring(j, i)(即从 ji 的子串)在 wordDict 中,那么 dp[i] 就是 true

      形式上,状态转移方程是: dp[i]=dp[j] && s.substring(j,i) 存在于 wordDict 中

      对于每一个 i,我们检查所有可能的 j(即所有可能的前一个单词的结束位置)。

  3. 结果返回

    • 如果 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.最长递增子序列

思路

解这题的核心思路:

  1. 状态定义

    • 使用一个整数数组 dp,其中 dp[i] 表示以 nums[i] 结尾的最长严格递增子序列的长度。这意味着任何一个有效的子序列,它的最后一个数字就是 nums[i]
  2. 初始化

    • 我们可以将整个 dp 数组初始化为 1,因为每个元素本身都可以形成一个长度为 1 的子序列。
  3. 状态转移方程

    • 遍历数组,对于每个 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]

  4. 找到最大值

    • 遍历完数组后,我们只需要从 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] 结尾的乘积最大的子数组以及乘积最小的子数组。这样我们可以处理数组中的负数和零。

以下是解决问题的核心思路:

  1. 状态定义

    • maxEndHere[i] 表示以 nums[i] 结尾的最大乘积子数组的乘积。

    • minEndHere[i] 表示以 nums[i] 结尾的最小乘积子数组的乘积。

  2. 初始化

    • maxEndHere[0]minEndHere[0] 都初始化为 nums[0]

    • maxProduct 初始化为 nums[0]。这是最终的答案,表示到目前为止的最大乘积。

  3. 状态转移方程

    • 对于每个 i1n-1n 是数组长度),我们更新 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]

  4. 更新全局最大值

    • 对于每个 i,我们更新 maxProduct = max(maxProduct, maxEndHere[i])
  5. 返回结果

    • 返回 maxProduct

简而言之,我们不仅跟踪了乘积最大的子数组,而且还跟踪了乘积最小的子数组,因为负数的存在可能会反转乘积的符号。最后,最大的连续乘积必然在这两者之一。这题我们也可以进行空间优化。

  1. 双重状态跟踪 :对于每个数组元素,我们都维护两个状态,一个是到该位置的最大乘积(maxVal),另一个是到该位置的最小乘积(minVal)。我们需要跟踪最小乘积是因为一个负数乘以最小乘积可能会变成一个更大的正数。

  2. 负数的处理 :如果当前的数字是负数,那么乘上它会使最大值变成最小值,最小值变成最大值。因此,当我们遇到负数时,我们交换maxValminVal

  3. 更新状态 :对于每个数组元素,我们更新maxValminValmaxVal是当前元素和maxVal与当前元素的乘积中的较大者。同样,minVal是当前元素和minVal与当前元素的乘积中的较小者。

  4. 更新结果 :在每一步中,我们都用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.分割等和子集 ⭐⭐

思路

解决这题的核心思路:

  1. 总和的计算 :首先,计算数组 nums 的总和。如果总和是奇数,那么不可能将其分成两个和相等的子集,因为两个整数的和如果是奇数,那么这两个整数必然一个是奇数,一个是偶数。

  2. 目标和 :如果总和是偶数,我们的目标是找到一个子集,其和为总和的一半,即 sum(nums) / 2

  3. 动态规划

    • 使用一个布尔型的二维数组 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]]

  4. 结果 :我们只需检查 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.最长有效括号

思路

这题我们不使用动态规划的解法,使用更简单的双指针的解法。

  1. 初始化计数器

    • 使用两个计数器:leftright。这两个计数器分别跟踪遇到的左括号 '(' 和右括号 ')' 的数量。
  2. 从左到右遍历

    • 遍历字符串中的每一个字符。

    • 对于每一个字符:

      • 如果是 '(',增加 left 计数器。

      • 如果是 ')',增加 right 计数器。

      • 如果 leftright 相等,说明到目前为止我们遇到的左括号和右括号可以形成有效的括号子串。此时,记录当前有效子串的长度,并与之前的最长有效子串长度进行比较,更新最长长度。

      • 如果 right 超过 left,说明到目前为止的右括号太多,不能形成有效的括号子串。此时,需要重置 leftright 为0,因为这种情况下,任何之前的信息都不再有用。

  3. 从右到左遍历

    • 使用相似的方法再次遍历字符串,但这次是从右到左。

    • 与前一个方向相反,这次的目的是找到类似 "((())" 这样的子串,其中从左到右的方法会失败,但从右到左可以找到正确的结果。

    • 对于每一个字符:

      • 如果是 ')',增加 right 计数器。

      • 如果是 '(',增加 left 计数器

      • 如果 leftright 相等,再次更新最长有效子串的长度。

      • 如果 left 超过 right,重置两个计数器。

  4. 得到结果

    • 在两次遍历结束后,你会得到整个字符串中的最长有效括号子串的长度。

这种方法的优点是空间复杂度为 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站上的一些相关视频,加深理解,但是动态规划题的解题代码量其实还是偏少的,几行代码就搞定了。

相关推荐
℘团子এ8 分钟前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z13 分钟前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
daiyang123...21 分钟前
测试岗位应该学什么
数据结构
alphaTao25 分钟前
LeetCode 每日一题 2024/11/18-2024/11/24
算法·leetcode
kitesxian34 分钟前
Leetcode448. 找到所有数组中消失的数字(HOT100)+Leetcode139. 单词拆分(HOT100)
数据结构·算法·leetcode
前端百草阁37 分钟前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜37 分钟前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
Backstroke fish39 分钟前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple39 分钟前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five40 分钟前
TypeScript项目中Axios的封装
开发语言·前端·javascript