步步为营:动态规划经典问题解析

154 阅读24分钟

引言

在计算机科学与算法的世界里,动态规划作为一种强大的求解技术,它帮助我们找到复杂问题中的最优解。无论是背包问题、最长公共子序列,还是编辑距离等问题,动态规划都以其独特的方式揭示了问题背后的数学美和逻辑美。

本文将以“步步为营:动态规划经典问题解析”为主题,带你深入了解动态规划的核心思想和应用技巧。我们将从最基础的概念出发,逐步深入到一系列经典问题的解决方法中去。通过这些实例,你不仅能够学习到如何构建动态规划模型,还会掌握如何识别动态规划问题的关键特征,以及如何有效地解决这些问题。

不论是初学者还是有经验的开发者,本文都将为你提供宝贵的见解和实践指导。让我们一起步入动态规划的世界,一步步揭开它的神秘面纱,探索其中的无限可能。

基础知识回顾

一维动态规划

在一维动态规划中,状态通常只依赖于一个变量。这意味着我们可以通过一个一维数组来记录各个状态的解。一维动态规划适用于状态转移方程只涉及到前一个状态的情况。 特点:

  • 状态表示:状态通常可以用一个变量 ii 来表示,比如 dp[i]dp[i] 表示第 ii 个状态的最优解。
  • 状态转移:状态转移方程通常形如 dp[i]=f(dp[j])dp[i]=f(dp[j]),其中 j<ij<i
  • 存储空间:只需要一维数组来存储状态信息。

示例:

  • 斐波那契数列:计算第 nn 项斐波那契数 F(n)=F(n1)+F(n2)F(n)=F(n−1)+F(n−2)
  • 最大子数组和:给定一个整数数组,找到其中连续子数组的最大和。

二维动态规划

在二维动态规划中,状态通常依赖于两个变量。这意味着我们需要使用一个二维数组来记录各个状态的解。这类问题通常涉及到矩阵或网格,状态转移方程涉及到两个变量。

特点:

  • 状态表示:状态通常可以用两个变量 ii 和 jj 来表示,比如 dp[i][j]dp[i][j] 表示第 ii个状态下的第 jj 个子状态的最优解。
  • 状态转移:状态转移方程通常形如 dp[i][j]=f(dp[k][l])dp[i][j]=f(dp[k][l]),其中 k<ik<i 或 l<jl<j
  • 存储空间:需要一个二维数组来存储状态信息。

示例:

  • 最长公共子序列:给定两个字符串,找出它们的最长公共子序列。
  • 最小编辑距离:计算将一个字符串转换为另一个字符串所需的最少编辑操作次数(插入、删除、替换)。

一维动态规划和二维动态规划都是解决动态规划问题的有效手段,选择哪种类型取决于问题的状态空间和状态转移特性。一般来说,一维动态规划更加简洁高效,而二维动态规划则能处理更为复杂的问题。

一维动态规划

爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:

输入: n = 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:

输入: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

题解

这个问题可以通过动态规划的方法来解决。我们可以定义一个函数 climbStairs(n) 来计算到达第 n 阶楼梯的不同方法的数量。对于每一步,我们有两种选择:要么上一个台阶,要么上两个台阶。因此,到达第 n 阶楼梯的方法数量等于到达第 n-1 阶楼梯的方法数量加上到达第 n-2 阶楼梯的方法数量。

这是一个典型的斐波那契数列问题,可以用递归或迭代的方式来解决。这里我们使用迭代的方式,因为它具有更好的空间效率。

function climbStairs(n) {
  if (n <= 2) return n;

  let prevPrev = 1; // 到达第 1 阶楼梯的方法数量
  let prev = 2; // 到达第 2 阶楼梯的方法数量
  let current = 0; // 到达当前阶楼梯的方法数量

  for (let i = 3; i <= n; i++) {
    current = prevPrev + prev;
    prevPrev = prev;
    prev = current;
  }

  return current;
}

// 示例
console.log(climbStairs(2)); // 输出: 2
console.log(climbStairs(3)); // 输出: 3
console.log(climbStairs(4)); // 输出: 5

代码解析

  1. 边界情况:

    • 如果 n 小于等于 2,直接返回 n,因为到达第一阶楼梯的方法数量为 1,到达第二阶楼梯的方法数量为 2。
  2. 初始化变量:

    • prevPrev 表示到达第 1 阶楼梯的方法数量,初始化为 1。
    • prev 表示到达第 2 阶楼梯的方法数量,初始化为 2。
    • current 表示到达当前阶楼梯的方法数量,初始化为 0。
  3. 迭代计算:

    • 从第三阶楼梯开始迭代,直到第 n 阶楼梯。
    • 对于每一阶楼梯,当前的方法数量等于前两阶楼梯的方法数量之和。
    • 更新 prevPrev 和 prev 的值,准备计算下一阶楼梯的方法数量。
  4. 返回结果:

    • 返回到达第 n 阶楼梯的方法数量。

这种方法的时间复杂度为 O(n),因为我们只需要遍历一次从 3 到 n 的数字即可计算出结果。同时,这种方法的空间复杂度为 O(1),因为我们只使用了几个固定的变量来存储中间结果,而没有使用额外的空间来存储整个序列。

打家劫舍

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:

输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 2:

输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

题解

我们定义一个数组 dp,其中 dp[i] 表示偷窃到第 i 个房子时能获得的最大金额。对于每一个房子,我们有两种选择:要么偷窃这个房子,要么跳过这个房子。如果偷窃这个房子,那么就不能偷窃前一个房子;如果不偷窃这个房子,那么可以选择偷窃前一个房子或跳过前一个房子。

我们可以用以下递推公式来计算 dp[i]

  • dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1])

这里的 dp[i-2] + nums[i] 表示偷窃第 i 个房子加上前一个状态 dp[i-2] 的最大金额,而 dp[i-1] 表示不偷窃第 i 个房子,直接继承前一个状态的最大金额。

为了节省空间,我们可以使用两个变量来代替数组,因为我们只需要前两个状态的信息。

function rob(nums) {
    if (nums.length === 0) return 0;
    if (nums.length === 1) return nums[0];
    if (nums.length === 2) return Math.max(nums[0], nums[1]);
  
    let prevPrev = nums[0]; // 偷窃第 0 个房子的最大金额
    let prev = Math.max(nums[0], nums[1]); // 偷窃第 1 个房子的最大金额
    let current = 0; // 偷窃第 i 个房子的最大金额
  
    for (let i = 2; i < nums.length; i++) {
      current = Math.max(prevPrev + nums[i], prev);
      prevPrev = prev;
      prev = current;
    }
  
    return current;
  }
  
  // 示例
  console.log(rob([1, 2, 3, 1])); // 输出: 4
  console.log(rob([2, 7, 9, 3, 1])); // 输出: 12

代码解析

  1. 边界情况:

    • 如果数组为空,直接返回 0。
    • 如果数组只有一个元素,返回这个元素的值。
    • 如果数组有两个元素,返回两者中的较大值。
  2. 初始化变量:

    • prevPrev 表示偷窃第 0 个房子的最大金额,初始化为 nums[0]
    • prev 表示偷窃第 1 个房子的最大金额,初始化为 Math.max(nums[0], nums[1])
    • current 表示偷窃当前房子的最大金额,初始化为 0。
  3. 迭代计算:

    • 从第三个房子开始迭代,直到最后一个房子。
    • 对于每一个房子,当前的最大金额等于前两个房子的最大金额之中的较大值加上当前房子的价值,或者直接继承前一个房子的最大金额。
    • 更新 prevPrev 和 prev 的值,准备计算下一个房子的最大金额。
  4. 返回结果:

    • 返回最后一个房子的最大金额。

这种方法的时间复杂度为 O(n),因为我们只需要遍历一次数组即可得到结果。同时,这种方法的空间复杂度为 O(1),因为我们只使用了几个固定的变量来存储中间结果,而没有使用额外的空间来存储整个序列。

单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true

注意: 不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

 

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet""code" 拼接成。

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。
     注意,你可以重复使用字典中的单词。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

提示:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • s 和 wordDict[i] 仅由小写英文字母组成
  • wordDict 中的所有字符串 互不相同

题解

我们将问题分解为子问题,对于字符串 s 的每个前缀,我们试图找到是否存在一个前缀能够由字典中的单词组成。

function wordBreak(s, wordDict) {
  const wordSet = new Set(wordDict); // 将字典转换为 Set,便于查找
  const dp = Array(s.length + 1).fill(false); // 初始化 dp 数组
  dp[0] = true; // 空字符串总是可以由字典中的单词组成

  for (let i = 1; i <= s.length; i++) {
    for (let j = 0; j < i; j++) {
      // 如果 dp[j] 为 true 并且 s[j:i] 存在于字典中,则 dp[i] 为 true
      if (dp[j] && wordSet.has(s.substring(j, i))) {
        dp[i] = true;
        break; // 找到一个符合条件的子串就跳出循环
      }
    }
  }

  return dp[s.length]; // 返回最后一个状态
}

// 示例
console.log(wordBreak("leetcode", ["leet", "code"])); // 输出: true
console.log(wordBreak("applepenapple", ["apple", "pen"])); // 输出: true
console.log(wordBreak("catsandog", ["cats", "dog", "sand", "and", "cat"])); // 输出: false

代码解析

  1. 初始化:

    • 将 wordDict 转换为 Set,方便快速查找。
    • 初始化 dp 数组,dp[i] 表示字符串 s 的前 i 个字符能否由字典中的单词组成。
    • dp[0] 初始化为 true,因为空字符串总是可以由字典中的单词组成。
  2. 动态规划:

    • 使用两层循环来遍历字符串 s 的所有前缀。
    • 外层循环 i 从 1 到 s.length,表示当前考虑的前缀长度。
    • 内层循环 j 从 0 到 i-1,表示当前考虑的前缀的起始位置。
    • 如果 dp[j] 为 true 并且子串 s.substring(j, i) 存在于字典中,则 dp[i] 设置为 true
  3. 返回结果:

    • 返回 dp[s.length],即字符串 s 是否能由字典中的单词组成。

这种方法的时间复杂度为 O(n^2),其中 n 是字符串 s 的长度,因为我们使用了两层循环来遍历字符串的所有前缀。空间复杂度为 O(n),因为我们使用了一个 dp 数组来存储中间结果。

在这里你可能会有这样的疑问,dp[0] 为什么要初始化为 true

dp[0] 初始化为 true 是因为在动态规划的过程中,我们考虑的是字符串的前缀能否由字典中的单词组成。对于空字符串(也就是字符串的前 0 个字符),我们默认它可以由字典中的单词组成,因为没有任何字符的字符串总是可以被视为由零个单词组成的。

动态规划通常需要定义一些边界条件,以便递推公式能够正确地开始。对于这个问题,dp[0] 就是一个重要的边界条件,它告诉我们一个空字符串总是可以由字典中的单词组成。

如果还是不太好理解的话,我们以字符串s = "applepenapple" 和字典 ["apple", "pen"]来逐步分析。

  • dp[0] 表示空字符串 "",我们将其初始化为 true
  • dp[1] 表示 "a",我们需要检查 dp[0] 和 "a" 是否存在于字典中。由于 dp[0] 为 true,但我们知道 "a" 不在字典中,因此 dp[1] 为 false
  • dp[2] 表示 "ap", 类似地,dp[2] 也将为 false
  • dp[3] 表示 "app", 同样,dp[3] 为 false
  • dp[4] 表示 "appl", 依然为 false
  • dp[5] 表示 "apple", 此时 dp[5] 为 true,因为 dp[0] 为 true,并且 "apple" 存在于字典中。

因此,将 dp[0] 初始化为 true 是动态规划中的一种常见做法,它为我们提供了一个起始点,使得我们可以从那里开始构建递推关系。

零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

示例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1

示例 2:

输入: coins = [2], amount = 3
输出: -1

示例 3:

输入: coins = [1], amount = 0
输出: 0

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 231 - 1
  • 0 <= amount <= 104

题解

我们将问题分解为子问题,对于总金额 amount,我们试图找到组成该金额所需的最少硬币数量。

function coinChange(coins, amount) {
  const dp = Array(amount + 1).fill(Infinity); // 初始化 dp 数组
  dp[0] = 0; // 0 金额需要 0 个硬币

  // 遍历所有金额
  for (let i = 1; i <= amount; i++) {
    for (let coin of coins) {
      if (i >= coin && dp[i - coin] !== Infinity) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1);
      }
    }
  }

  return dp[amount] === Infinity ? -1 : dp[amount];
}

// 示例
console.log(coinChange([1, 2, 5], 11)); // 输出: 3
console.log(coinChange([2], 3)); // 输出: -1
console.log(coinChange([1], 0)); // 输出: 0

代码解析

  1. 初始化:

    • 初始化 dp 数组,dp[i] 表示金额为 i 时所需的最少硬币数量。
    • dp[0] 初始化为 0,因为组成 0 金额需要 0 个硬币。
    • 其他值初始化为 Infinity,表示无法用硬币组成对应的金额。
  2. 动态规划:

    • 使用两层循环来遍历所有金额和硬币。
    • 外层循环 i 从 1 到 amount,表示当前考虑的金额。
    • 内层循环遍历所有的硬币 coin
    • 如果 i >= coin 并且 dp[i - coin] 不为 Infinity(即 i - coin 可以由硬币组成),则更新 dp[i] 的值为 dp[i] 和 dp[i - coin] + 1 中较小的一个。
  3. 返回结果:

    • 返回 dp[amount],即组成金额 amount 所需的最少硬币数量。
    • 如果 dp[amount] 仍然为 Infinity,说明无法组成金额 amount,返回 -1

这种方法的时间复杂度为 O(amount * n),其中 amount 是目标金额,n 是硬币种类的数量。空间复杂度为 O(amount),因为我们使用了一个 dp 数组来存储中间结果。

详细解释

  1. dp[i - coin] 的含义:

    • dp[i - coin] 表示组成金额 i - coin 所需的最少硬币数量。
    • 这是因为我们已经处理了金额 i - coin,并且得到了所需的最少硬币数量。
  2. dp[i - coin] + 1 的含义:

    • dp[i - coin] + 1 表示使用当前硬币 coin 来组成金额 i 所需的最少硬币数量。
    • + 1 表示我们在已有的最少硬币数量基础上添加了一个硬币 coin
    • 因此,如果我们使用硬币 coin 来组成金额 i,那么所需的最少硬币数量就是 dp[i - coin] + 1
  3. 更新 dp[i] :

    • 我们需要找到组成金额 i 的最少硬币数量。
    • 在遍历所有硬币的过程中,如果 i >= coin 并且 dp[i - coin] 不为 Infinity,那么我们可以考虑使用当前硬币 coin
    • 我们比较 dp[i] 和 dp[i - coin] + 1,取其中较小的一个作为新的 dp[i] 的值。
    • 这是因为我们需要最小化组成金额 i 所需的硬币数量。

最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。 

示例 1:

输入: nums = [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:

输入: nums = [0,1,0,3,2,3]
输出: 4

示例 3:

输入: nums = [7,7,7,7,7,7,7]
输出: 1

提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

进阶:

  • 你能将算法的时间复杂度降低到 O(n log(n)) 吗?

题解

我们将问题分解为子问题,对于数组 nums 中的每个元素,我们试图找到以该元素结尾的最长递增子序列的长度。

function lengthOfLIS(nums) {
  if (nums.length === 0) return 0;

  const dp = Array(nums.length).fill(1); // 初始化 dp 数组
  let maxLength = 1; // 初始化最长递增子序列的长度

  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }
    maxLength = Math.max(maxLength, dp[i]);
  }

  return maxLength;
}

// 示例
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 输出: 4
console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 输出: 4
console.log(lengthOfLIS([7, 7, 7, 7, 7, 7, 7])); // 输出: 1

代码解析

  1. 初始化:

    • 初始化 dp 数组,dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。
    • dp 数组的所有元素初始化为 1,因为每个单独的元素都可以构成长度为 1 的递增子序列。
    • maxLength 初始化为 1,表示最长递增子序列的长度。
  2. 动态规划:

    • 使用两层循环来遍历数组 nums
    • 外层循环 i 从 1 到 nums.length - 1,表示当前考虑的元素。
    • 内层循环 j 从 0 到 i - 1,表示当前考虑的元素之前的元素。
    • 如果 nums[i] > nums[j],那么我们可以考虑将 nums[i] 添加到以 nums[j] 结尾的递增子序列中。
    • 更新 dp[i] 的值为 dp[i] 和 dp[j] + 1 中较大的一个。
    • 更新 maxLength 的值为 maxLength 和 dp[i] 中较大的一个。
  3. 返回结果:

    • 返回 maxLength,即最长递增子序列的长度。

这种方法的时间复杂度为 O(n2)O(n^2),其中 n 是数组 nums 的长度,因为我们使用了两层循环来遍历数组的所有元素。空间复杂度为 O(n)O(n),因为我们使用了一个 dp 数组来存储中间结果。

进阶:O(nlogn)O(n log n) 解法

要达到 O(nlogn)O(n log n) 的时间复杂度,我们可以使用贪心策略结合二分查找的方法。这种方法的核心思想是维护一个动态数组,其中每个元素都是当前最长递增子序列的结尾元素。我们使用二分查找来确定如何更新这个数组。

function lengthOfLIS(nums) {
  const tails = []; // 用来存储当前最长递增子序列的结尾元素

  for (const num of nums) {
    let left = 0;
    let right = tails.length;

    while (left < right) {
      const mid = Math.floor((left + right) / 2);
      if (tails[mid] < num) {
        left = mid + 1;
      } else {
        right = mid;
      }
    }

    if (left === tails.length) {
      tails.push(num);
    } else {
      tails[left] = num;
    }
  }

  return tails.length;
}

// 示例
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 输出: 4
console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 输出: 4
console.log(lengthOfLIS([7, 7, 7, 7, 7, 7, 7])); // 输出: 1

代码解析

  1. 初始化:

    • 初始化 tails 数组,用来存储当前最长递增子序列的结尾元素。
  2. 遍历数组:

    • 遍历数组 nums 中的每个元素 num
  3. 二分查找:

    • 使用二分查找来确定 tails 数组中应该更新的位置。
    • 初始化 left 为 0,right 为 tails 数组的长度。
    • 使用循环来查找 tails 数组中第一个大于等于 num 的元素的位置。
    • 如果找到了,那么更新 tails 数组中的该位置。
    • 如果没有找到,那么将 num 添加到 tails 数组的末尾。
  4. 返回结果:

    • 返回 tails 数组的长度,即最长递增子序列的长度。

这种方法的时间复杂度为 O(n log n),因为我们使用了二分查找来更新 tails 数组。空间复杂度为 O(n),因为我们使用了一个 tails 数组来存储中间结果。

二维动态规划

三角形最小路径和

给定一个三角形 triangle ,找出自顶向下的最小路径和。

每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。

示例 1:

输入: triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出: 11
解释: 如下面简图所示:
   2
  3 4
 6 5 7
4 1 8 3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

示例 2:

输入: triangle = [[-10]]
输出: -10

提示:

  • 1 <= triangle.length <= 200
  • triangle[0].length == 1
  • triangle[i].length == triangle[i - 1].length + 1
  • -104 <= triangle[i][j] <= 104

进阶:

  • 你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题吗?

题解

我们将问题分解为子问题,对于三角形的每个位置,我们试图找到到达该位置的最小路径和。

function minimumTotal(triangle) {
  if (triangle.length === 0) return 0;

  // 从倒数第二行开始向上更新
  for (let row = triangle.length - 2; row >= 0; row--) {
    for (let col = 0; col < triangle[row].length; col++) {
      // 对于当前行的每个位置,选择其下方两个位置中最小的那个加上当前值
      triangle[row][col] += Math.min(
        triangle[row + 1][col],
        triangle[row + 1][col + 1]
      );
    }
  }

  // 最终结果就是第一行的第一个元素
  return triangle[0][0];
}

// 示例
console.log(minimumTotal([[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]])); // 输出: 11
console.log(minimumTotal([[-10]])); // 输出: -10

代码解析

  1. 初始化:

    • 如果三角形为空,则返回 0。
    • 我们将从倒数第二行开始向上更新,因为我们已经知道最后一行每个位置的最小路径和就是它本身。
  2. 动态规划:

    • 使用两层循环来遍历三角形。
    • 外层循环 row 从倒数第二行开始,直到第一行。
    • 内层循环 col 遍历当前行的所有位置。
    • 对于每个位置,我们选择其下方两个位置中最小的那个加上当前值。
    • 这样,我们逐步更新了每个位置的最小路径和。
  3. 返回结果:

    • 最终结果就是第一行的第一个元素,即整个三角形的最小路径和。

这种方法的时间复杂度为 O(n2)O(n^2),其中 n 是三角形的行数,因为我们使用了两层循环来遍历三角形的所有位置。空间复杂度为 O(1)O(1),因为我们直接在原始的三角形数组上进行了修改,没有使用额外的空间来存储中间结果。

进阶题解

要实现 O(n) 的额外空间复杂度,我们可以使用一个额外的一维数组来存储每行的最小路径和。这样就不需要修改原始的三角形数组,同时也能减少空间复杂度。

function minimumTotal(triangle) {
  if (triangle.length === 0) return 0;

  const n = triangle.length;
  const dp = new Array(n).fill(0); // 初始化 dp 数组

  // 从最后一行开始填充 dp 数组
  for (let i = 0; i < triangle[n - 1].length; i++) {
    dp[i] = triangle[n - 1][i];
  }

  // 从倒数第二行开始向上更新
  for (let row = n - 2; row >= 0; row--) {
    for (let col = 0; col < triangle[row].length; col++) {
      // 对于当前行的每个位置,选择其下方两个位置中最小的那个加上当前值
      dp[col] = triangle[row][col] + Math.min(dp[col], dp[col + 1]);
    }
  }

  // 最终结果就是 dp 数组的第一个元素
  return dp[0];
}

// 示例
console.log(minimumTotal([[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]])); // 输出: 11
console.log(minimumTotal([[-10]])); // 输出: -10

代码解析

  1. 初始化:

    • 如果三角形为空,则返回 0。
    • 初始化 dp 数组,长度为三角形的行数,用于存储每行的最小路径和。
  2. 初始化最后一行:

    • 将 dp 数组的最后一行初始化为三角形最后一行的值,因为这些值就是它们自己的最小路径和。
  3. 动态规划:

    • 使用两层循环来遍历三角形。
    • 外层循环 row 从倒数第二行开始,直到第一行。
    • 内层循环 col 遍历当前行的所有位置。
    • 对于每个位置,我们选择其下方两个位置中最小的那个加上当前值。
    • 这样,我们逐步更新了每个位置的最小路径和。
  4. 返回结果:

    • 最终结果就是 dp 数组的第一个元素,即整个三角形的最小路径和。

这种方法的时间复杂度为 O(n^2),其中 n 是三角形的行数,因为我们使用了两层循环来遍历三角形的所有位置。空间复杂度为 O(n),因为我们使用了一个 dp 数组来存储中间结果。

两种解法的对比

第一种解法和第二种解法在本质上都采用了动态规划的思想,但它们在空间复杂度方面有所不同。

第一种解法:直接修改三角形数组
  • 空间复杂度:

    • 这种解法的空间复杂度为 O(1),因为我们直接在原始的三角形数组上进行修改,没有使用额外的空间来存储中间结果。
    • 这种方法通常被称为“原地修改”或“空间优化”的动态规划方法。
  • 动态规划性质:

    • 这种方法可以视为一种特殊的二维动态规划方法,因为它涉及到一个二维的结构(三角形),并且我们按照特定的方向(从下往上)遍历这个结构来更新每个位置的值。
    • 但由于我们是在原始数组上进行修改,实际上并没有显式地创建一个二维的 DP 数组。
第二种解法:使用一维 DP 数组
  • 空间复杂度:

    • 这种解法的空间复杂度为 O(n),其中 n 是三角形的行数。这是因为我们使用了一个一维的 DP 数组来存储中间结果。
    • 由于三角形的宽度随着行数增加而增加,因此 DP 数组的长度为三角形的行数。
  • 动态规划性质:

    • 这种方法同样采用动态规划的思想,但它使用了一个一维的 DP 数组来存储每行的最小路径和。
    • 这种方法可以被视为一种优化过的二维动态规划方法,因为我们仍然按照特定的顺序(从下往上)遍历三角形的每一行,并且更新 DP 数组中的值。
    • 但是,由于我们只需要存储当前行和下一行的信息,因此可以使用一个一维的数组来达到 O(n) 的空间复杂度。

这两种方法都有效解决了问题,并且各有优势。第一种方法在空间复杂度上更优,但第二种方法在某些情况下可能更容易理解和实现。

最小路径和

不同路径 II

最长回文子串

交错字符串

编辑距离

买卖股票的最佳时机 III

买卖股票的最佳时机 IV

最大正方形