导语
leetcode刷题笔记记录,本篇博客是动态规划部分的第1期,主要记录题目包括:
知识点
动态规划
动态规划(Dynamic Programming)是一种算法设计策略,主要用于求解具有重叠子问题和最优子结构的问题。动态规划的核心思想是将复杂问题分解为简单的子问题,并存储每个子问题的答案,以避免重复计算。通常采用表格的方式存储子问题的结果。
下面用一个通俗的例子来解释动态规划:
钢条切割问题
假设你是一家制造钢条的公司,你有一根长度为 n 英寸的钢条,并且你有一个价格表,显示了不同长度的钢条可以卖多少钱。
| 长度 i | 1 | 2 | 3 | 4 | 5 | ... |
|---|---|---|---|---|---|---|
| 价格 p | 1 | 5 | 8 | 10 | 13 | ... |
现在你的任务是确定切割这根钢条的最佳方法,以便最大化收益。你可以选择不切割钢条,直接出售,或者你可以选择将其切割为两段或更多段,然后将这些段单独出售。
考虑一个简单的例子:长度为 4 英寸的钢条。你可以:
- 不切割,直接卖 $10。
- 切割为 1 英寸和 3 英寸,总共卖 $9。
- 切割为 2 英寸和 2 英寸,总共卖 $10。
- 切割为 1 英寸、1 英寸、1 英寸和 1 英寸,总共卖 $4。
为了解决这个问题,我们可以采用动态规划的方法,将问题分解为更小的子问题,并计算每个可能长度的最大价值,然后从中选择一个最优解。
动态规划的关键步骤是:从最小的子问题开始,逐步计算每个子问题的解,直到得到原始问题的解。这样,对于每个子问题,你只需要考虑更小的子问题,而不是整个问题。
在钢条切割问题中,动态规划的优势是我们只计算每个长度的最大价值一次,并存储这些结果,以避免不必要的重复计算。
这就是动态规划的基本思想。当然,实际应用中可能会遇到更复杂的问题,但基本原理是相同的。
动态规划的核心概念
动态规划的核心是将原问题分解为一系列子问题,并使用子问题的解来逐步建立原问题的解。为了更好地理解动态规划,我们需要掌握以下几个关键概念:
-
重叠子问题:当我们分解一个问题时,我们通常会发现某些子问题会被重复计算多次。为了提高效率,我们可以存储这些子问题的解,避免重复计算。这就是动态规划与其他递归问题的区别。
-
最优子结构:原问题的最优解可以通过其子问题的最优解构建。换句话说,我们可以通过组合子问题的解来得到原问题的解。
-
状态:描述问题的某种配置或局面。例如,在钢条切割问题中,状态可以是钢条的长度。
-
状态转移方程(递推公式) :这是动态规划的核心。它描述了如何根据已知的状态得到下一个状态的解。这通常是一个递推公式,它说明了从一个状态到另一个状态的变化过程。以钢条切割问题为例,可以使用以下状态转移方程来描述:
其中,r(n) 是长度为 n 的钢条的最大收益,p[n] 是长度为 n 的钢条的价格。
-
边界条件:为了递推,我们需要知道某些初始状态的值。这些初始状态称为边界条件。
-
存储结构:为了避免重复计算,我们通常使用一维或二维数组来存储子问题的解。有时,我们也可能需要其他数据结构,如字典或哈希表。
-
计算顺序:为了确保当我们计算某个状态的解时,所需的所有子问题的解都已经被计算过,我们需要确定正确的计算顺序。
总的来说,动态规划是一种强大的算法策略,可以解决许多看似复杂的问题。其关键是将问题分解为子问题,并找到状态转移方程。
动态规划五部曲
根据代码随想录的总结,解答动态规划题目时,应该注重这五个方面:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
Leetcode 509.斐波那契数列
题目描述
斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(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
解法
按动态规划五部曲来解答:
- 确定dp数组含义:这里令第i个Fib数值为dp[i]
- 递归公式:题目中已给出dp[i]=dp[i-1]+dp[i-2]
- dp数组初始化:dp[0]=1,dp[1]=1
- 遍历顺序:从前向后;
- 举例推导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
解法
这道题目如果没有接触过的话,会感觉比较难,多举几个例子,就可以发现其规律。
- 爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
- 那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。
动规五部曲:
- dp[i]: 爬到第i层楼梯,有dp[i]种方法
- 确定递推公式:dp[i] = dp[i - 1] + dp[i - 2] 。
- dp数组如何初始化:dp[1] = 1,dp[2] = 2,这个初始化大家应该都没有争议的。
- 确定遍历顺序:从前向后遍历
- 举例推导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 <= 10000 <= cost[i] <= 999
解法
使用动规五部曲:
- dp[i]为到达i位置的花费为dp[i];
- 递推公式:
- 初始化:dp[0]=0,dp[1]=0
- 遍历顺序:从前往后;
- 举例推导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]