Leetcode刷题笔记38:动态规划1(理论基础-509.斐波那契数列-70.爬楼梯-746.最小代价爬楼梯)

312 阅读6分钟

导语

leetcode刷题笔记记录,本篇博客是动态规划部分的第1期,主要记录题目包括:

知识点

动态规划

动态规划(Dynamic Programming)是一种算法设计策略,主要用于求解具有重叠子问题和最优子结构的问题。动态规划的核心思想是将复杂问题分解为简单的子问题,并存储每个子问题的答案,以避免重复计算。通常采用表格的方式存储子问题的结果。

下面用一个通俗的例子来解释动态规划:

钢条切割问题

假设你是一家制造钢条的公司,你有一根长度为 n 英寸的钢条,并且你有一个价格表,显示了不同长度的钢条可以卖多少钱。

长度 i12345...
价格 p1581013...

现在你的任务是确定切割这根钢条的最佳方法,以便最大化收益。你可以选择不切割钢条,直接出售,或者你可以选择将其切割为两段或更多段,然后将这些段单独出售。

考虑一个简单的例子:长度为 4 英寸的钢条。你可以:

  1. 不切割,直接卖 $10。
  2. 切割为 1 英寸和 3 英寸,总共卖 $9。
  3. 切割为 2 英寸和 2 英寸,总共卖 $10。
  4. 切割为 1 英寸、1 英寸、1 英寸和 1 英寸,总共卖 $4。

为了解决这个问题,我们可以采用动态规划的方法,将问题分解为更小的子问题,并计算每个可能长度的最大价值,然后从中选择一个最优解。

动态规划的关键步骤是:从最小的子问题开始,逐步计算每个子问题的解,直到得到原始问题的解。这样,对于每个子问题,你只需要考虑更小的子问题,而不是整个问题。

在钢条切割问题中,动态规划的优势是我们只计算每个长度的最大价值一次,并存储这些结果,以避免不必要的重复计算。

这就是动态规划的基本思想。当然,实际应用中可能会遇到更复杂的问题,但基本原理是相同的。

动态规划的核心概念

动态规划的核心是将原问题分解为一系列子问题,并使用子问题的解来逐步建立原问题的解。为了更好地理解动态规划,我们需要掌握以下几个关键概念:

  1. 重叠子问题:当我们分解一个问题时,我们通常会发现某些子问题会被重复计算多次。为了提高效率,我们可以存储这些子问题的解,避免重复计算。这就是动态规划与其他递归问题的区别。

  2. 最优子结构:原问题的最优解可以通过其子问题的最优解构建。换句话说,我们可以通过组合子问题的解来得到原问题的解。

  3. 状态:描述问题的某种配置或局面。例如,在钢条切割问题中,状态可以是钢条的长度。

  4. 状态转移方程(递推公式) :这是动态规划的核心。它描述了如何根据已知的状态得到下一个状态的解。这通常是一个递推公式,它说明了从一个状态到另一个状态的变化过程。以钢条切割问题为例,可以使用以下状态转移方程来描述:

image.png

其中,r(n) 是长度为 n 的钢条的最大收益,p[n] 是长度为 n 的钢条的价格。

  1. 边界条件:为了递推,我们需要知道某些初始状态的值。这些初始状态称为边界条件。

  2. 存储结构:为了避免重复计算,我们通常使用一维或二维数组来存储子问题的解。有时,我们也可能需要其他数据结构,如字典或哈希表。

  3. 计算顺序:为了确保当我们计算某个状态的解时,所需的所有子问题的解都已经被计算过,我们需要确定正确的计算顺序。

总的来说,动态规划是一种强大的算法策略,可以解决许多看似复杂的问题。其关键是将问题分解为子问题,并找到状态转移方程。

动态规划五部曲

根据代码随想录的总结,解答动态规划题目时,应该注重这五个方面:

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

image.png

Leetcode 509.斐波那契数列

题目描述

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n ,请计算 F(n) 。

 

示例 1:

输入: n = 2
输出: 1
解释: F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

输入: n = 3
输出: 2
解释: F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

输入: n = 4
输出: 3
解释: F(4) = F(3) + F(2) = 2 + 1 = 3

 

提示:

  • 0 <= n <= 30

解法

按动态规划五部曲来解答:

  1. 确定dp数组含义:这里令第i个Fib数值为dp[i]
  2. 递归公式:题目中已给出dp[i]=dp[i-1]+dp[i-2]
  3. dp数组初始化:dp[0]=1,dp[1]=1
  4. 遍历顺序:从前向后;
  5. 举例推导dp数组

完整代码如下:

class Solution:
    def fib(self, n: int) -> int:
       
        # 排除 Corner Case
        if n == 0:
            return 0
        
        # 创建 dp table 
        dp = [0] * (n + 1)

        # 初始化 dp 数组
        dp[0] = 0
        dp[1] = 1

        # 遍历顺序: 由前向后。因为后面要用到前面的状态
        for i in range(2, n + 1):

            # 确定递归公式/状态转移公式
            dp[i] = dp[i - 1] + dp[i - 2]
        
        # 返回答案
        return dp[n]

可以做一步状态压缩,只使用三个变量:

class Solution:
    def fib(self, n: int) -> int:
        # 初始化斐波那契数列的值
        ans = 0
        
        # 如果n为0或1,则直接返回n,因为斐波那契数列的前两项就是0和1
        if n <= 1:
            return n
        
        # 初始化dp数组,用于存储当前项和前一项的值
        # dp[0] 用于存储前一项的值
        # dp[1] 用于存储当前项的值
        dp = [0, 1]
        
        # 开始从2遍历到n
        for i in range(2, n+1):
            # 当前项的值为前两项的和
            ans = dp[0] + dp[1]
            
            # 更新dp数组的值,即将当前项变为前一项,新计算出的ans变为当前项
            dp[0] = dp[1]
            dp[1] = ans

        # 返回计算出的斐波那契数列的值
        return ans

Leetcode 70.爬楼梯

题目描述

假设你正在爬楼梯。需要 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 阶

 

提示:

  • 1 <= n <= 45

解法

这道题目如果没有接触过的话,会感觉比较难,多举几个例子,就可以发现其规律。

  • 爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
  • 那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。

动规五部曲:

  1. dp[i]: 爬到第i层楼梯,有dp[i]种方法
  2. 确定递推公式:dp[i] = dp[i - 1] + dp[i - 2] 。
  3. dp数组如何初始化:dp[1] = 1,dp[2] = 2,这个初始化大家应该都没有争议的。
  4. 确定遍历顺序:从前向后遍历
  5. 举例推导dp数组

分析后发现,其代码与斐波那契数列一致,可以写出代码:

class Solution:
    def climbStairs(self, n: int) -> int:
        # 当台阶数为1或2时,直接返回相应的方法数
        if n <= 2::
            return n

        # 初始化dp数组。dp[i]表示到达第i阶台阶的方法数。
        # 由于我们是从1开始计数的,所以我们初始化数组长度为n+1。
        # dp[0]是一个占位符,不会使用。dp[1]和dp[2]表示1和2阶台阶的方法数。
        dp = [0, 1, 2] + [0] * (n-2)

        # 开始从3阶台阶计算到n阶台阶
        for i in range(3, n+1):
            # 到达第i阶台阶的方法数可以由以下两种方法组成:
            # 1. 从第i-1阶台阶爬上1个台阶
            # 2. 从第i-2阶台阶爬上2个台阶
            # 所以,dp[i]是以上两种方法的和。
            dp[i] = dp[i-1] + dp[i-2]

        # 返回到达第n阶台阶的方法数
        return dp[n]

Leetcode 746.最小代价爬楼梯

题目描述

给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。

请你计算并返回达到楼梯顶部的最低花费。

 

示例 1:

输入: cost = [10,15,20]
输出: 15
解释: 你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。

示例 2:

输入: cost = [1,100,1,1,1,100,1,1,100,1]
输出: 6
解释: 你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。

 

提示:

  • 2 <= cost.length <= 1000
  • 0 <= cost[i] <= 999

解法

使用动规五部曲:

  1. dp[i]为到达i位置的花费为dp[i];
  2. 递推公式:
dp[i]=min(dp[i1]+cost[i1],dp[i2]+cost[i2]dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2]
  1. 初始化:dp[0]=0,dp[1]=0
  2. 遍历顺序:从前往后;
  3. 举例推导dp数组

写出完整代码如下:

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        # 获取台阶数
        n = len(cost)
        
        # 初始化dp数组。dp[i]表示到达第i阶台阶的最小费用。
        # 因为我们可以从0或1开始,所以dp[0]和dp[1]都为0。
        dp = [0] * (n+1)
        
        # 从第2阶开始计算到达每一阶的最小费用
        for i in range(2, n+1):
            # 到达第i阶的最小费用可以从以下两种方法中选择:
            # 1. 从第i-1阶爬上1个台阶
            # 2. 从第i-2阶爬上2个台阶
            # 所以,dp[i]是以上两种方法中的最小费用。
            dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])
        
        # 返回到达最顶部台阶的最小费用
        return dp[n]