引言
动态规划(Dynamic Programming,简称DP)是算法设计中的核心思想之一,也是面试与竞赛中的高频考点。它通过将复杂问题分解为相互重叠的子问题,并利用子问题的解构建原问题的解,从而避免重复计算,显著提升效率。对于初学者而言,掌握动态规划的精髓往往从简单的经典题目开始,通过理解状态定义、转移方程和边界条件这三大要素,逐步建立解题直觉。
本文精选力扣(LeetCode)hot100中三道简单难度的动态规划入门题目,涵盖不同的应用场景:
- 70.爬楼梯:一维线性递推,斐波那契数列的典型变体
- 118.杨辉三角:二维矩阵递推,组合数学的直观体现
- 121.买卖股票的最佳时机:状态机DP,单状态转移与极值维护
每道题目我们将按照题目概述→核心思路→复杂度分析→代码实现→优化讨论→面试考点的逻辑展开,并在关键知识点处添加"注:"形式的补充说明。文章最后给出动态规划在简单题目中的共性总结与学习建议。
70.爬楼梯
题目概述与链接
题目链接 :70. Climbing Stairs
问题描述 :假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。计算有多少种不同的方法可以爬到楼顶。
示例:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
核心解题思路
爬楼梯问题是动态规划最经典的入门例题,其本质是斐波那契数列 的变体。定义状态 dp[i] 为"到达第 i 阶楼梯的不同方法数"。由于每次只能爬 1 或 2 阶,要到达第 i 阶,只能从第 i-1 阶爬 1 阶上来,或者从第 i-2 阶爬 2 阶上来。因此,状态转移方程为:
d p [ i ] = d p [ i − 1 ] + d p [ i − 2 ] dp[i] = dp[i-1] + dp[i-2] dp[i]=dp[i−1]+dp[i−2]
边界条件:
dp[0] = 1:到达第0阶(地面)只有一种方法(不动)dp[1] = 1:到达第1阶只能爬1阶
注:有些实现将 dp[0] 设为 1 是为了让递推式在 i=2 时成立(dp[2] = dp[1] + dp[0] = 1 + 1 = 2),这与实际意义相符(到达第 2 阶有两种方法:1+1 或 2)。
注: 这里 dp[0] = 1 是一种数学上的便利定义,使得递推式统一。在实际问题中,也可以直接从 dp[1] 和 dp[2] 开始计算,避免对 dp[0] 的讨论。
时间复杂度与空间复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n)。需要计算从
2到n的每个dp[i],每次计算为常数时间加法。 - 空间复杂度 :
- 若使用长度为
n+1的数组: O ( n ) O(n) O(n) - 若使用滚动变量(仅保存前两个状态): O ( 1 ) O(1) O(1)
- 若使用长度为
Python代码实现
python
def climbStairs(n: int) -> int:
"""
70.爬楼梯:动态规划解法
"""
if n <= 2:
return n
# 方法1:使用数组保存所有状态(易于理解)
# dp = [0] * (n + 1)
# dp[0], dp[1] = 1, 1
# for i in range(2, n + 1):
# dp[i] = dp[i - 1] + dp[i - 2]
# return dp[n]
# 方法2:滚动数组优化(空间O(1))
prev1, prev2 = 1, 1 # prev1表示dp[i-1],prev2表示dp[i-2]
for i in range(2, n + 1):
curr = prev1 + prev2
prev2, prev1 = prev1, curr # 滚动更新
return prev1
# 测试用例
if __name__ == "__main__":
print(climbStairs(2)) # 2
print(climbStairs(3)) # 3
print(climbStairs(5)) # 8
代码注释:
- 边界处理:当
n <= 2时直接返回n(因为dp[1]=1, dp[2]=2) - 滚动数组实现:用
prev1和prev2分别记录前两个状态,每次迭代更新,将空间复杂度优化到 O ( 1 ) O(1) O(1) - 关键代码段控制在20行以内,突出动态规划的核心逻辑
优化讨论
空间优化 :如上所述,使用滚动变量可将空间复杂度从 O ( n ) O(n) O(n) 降至 O ( 1 ) O(1) O(1)。这是斐波那契类问题的常见优化技巧。
数学优化 :爬楼梯问题本质是求斐波那契数列第 n 项,可利用矩阵快速幂将时间复杂度降至 O ( log n ) O(\log n) O(logn)。递推关系可表示为:
d p \[ i \] d p \[ i − 1 \] \] = \[ 1 1 1 0 \] \[ d p \[ i − 1 \] d p \[ i − 2 \] \] \\begin{bmatrix} dp\[i\] \\\\ dp\[i-1\] \\end{bmatrix} = \\begin{bmatrix} 1 \& 1 \\\\ 1 \& 0 \\end{bmatrix} \\begin{bmatrix} dp\[i-1\] \\\\ dp\[i-2\] \\end{bmatrix} \[dp\[i\]dp\[i−1\]\]=\[1110\]\[dp\[i−1\]dp\[i−2\]
通过计算转移矩阵的 n 次幂,可在对数时间内得到结果。但这在简单题目中通常不要求。
变体思考 :若每次可以爬 1、2 或 3 个台阶,则转移方程变为 dp[i] = dp[i-1] + dp[i-2] + dp[i-3]。推广到任意步长集合 steps,则成为完全背包问题(每个步长可使用无限次)。
注: 当台阶数 n 很大时(如 n > 10 7 n > 10^7 n>107),滚动数组优化是必要的,否则内存可能溢出。此外,结果可能超过32位整数范围,需使用大整数或取模处理(如题目要求结果对 10 9 + 7 10^9+7 109+7 取模)。
面试考点
- 状态定义 :能否清晰定义
dp[i]的含义 - 转移方程 :能否推导出
dp[i] = dp[i-1] + dp[i-2]并解释原因 - 边界条件 :正确处理
dp[0]和dp[1],避免数组越界 - 空间优化:能否给出滚动数组的实现
- 变体问题:若步长变化或加入约束(如"不能连续爬2阶"),如何调整状态定义
常见错误:
- 忘记处理
n=0或n=1的边界情况 - 错误地将
dp[0]设为0,导致dp[2]计算错误 - 使用递归不加记忆化,导致指数级时间复杂度
118.杨辉三角
题目概述与链接
题目链接 :118. Pascal's Triangle
问题描述 :给定一个非负整数 numRows,生成杨辉三角的前 numRows 行。
示例:
输入:numRows = 5
输出:
[
[1],
[1,1],
[1,2,1],
[1,3,3,1],
[1,4,6,4,1]
]
杨辉三角性质:
- 第
i行(从0开始)有i+1个元素 - 每行首尾元素为
1 - 对于第
i行第j个元素(0 < j < i),有triangle[i][j] = triangle[i-1][j-1] + triangle[i-1][j] - 第
i行第j个元素等于组合数 C i j C_i^j Cij
核心解题思路
杨辉三角是二维动态规划的典型例题,其递推关系直接给出了状态转移方程。定义二维数组 triangle,其中 triangle[i][j] 表示第 i 行第 j 列的元素。
状态转移方程:
- 边界条件:
triangle[i][0] = triangle[i][i] = 1 - 内部元素:
triangle[i][j] = triangle[i-1][j-1] + triangle[i-1][j],其中1 ≤ j ≤ i-1
动态规划过程:
- 初始化空列表
triangle - 逐行生成:对于第
i行,创建长度为i+1的列表row - 设置首尾为
1 - 根据上一行的值计算中间元素
- 将当前行加入结果
注: 杨辉三角的递推关系体现了组合数的递推公式 C n k = C n − 1 k − 1 + C n − 1 k C_n^k = C_{n-1}^{k-1} + C_{n-1}^k Cnk=Cn−1k−1+Cn−1k,这是组合数学中的基本恒等式。
时间复杂度与空间复杂度分析
- 时间复杂度 : O ( numRows 2 ) O(\text{numRows}^2) O(numRows2)。总元素个数约为 numRows ( numRows + 1 ) 2 \frac{\text{numRows}(\text{numRows}+1)}{2} 2numRows(numRows+1),每个元素计算一次。
- 空间复杂度 : O ( numRows 2 ) O(\text{numRows}^2) O(numRows2) 用于存储整个三角形。若只要求输出而不存储,可降至 O ( numRows ) O(\text{numRows}) O(numRows)(只保存上一行)。
Python代码实现
python
def generate(numRows: int):
"""
118.杨辉三角:动态规划解法
"""
if numRows <= 0:
return []
triangle = []
for i in range(numRows):
# 创建当前行,长度为 i+1
row = [1] * (i + 1)
# 计算中间元素(当 i >= 2 时才有中间元素)
for j in range(1, i):
row[j] = triangle[i - 1][j - 1] + triangle[i - 1][j]
triangle.append(row)
return triangle
# 测试用例
if __name__ == "__main__":
result = generate(5)
for row in result:
print(row)
# 输出:
# [1]
# [1, 1]
# [1, 2, 1]
# [1, 3, 3, 1]
# [1, 4, 6, 4, 1]
代码注释:
- 边界处理:当
numRows <= 0时返回空列表 - 核心循环:外层
i控制行号,内层j计算中间元素 - 利用
triangle[i-1]获取上一行数据,实现递推 - 代码简洁,关键逻辑控制在20行以内
优化讨论
空间优化 :如果只需要生成杨辉三角的某一行(如第 k 行),可使用滚动数组思想,只保存上一行数据,将空间复杂度降至 O ( k ) O(k) O(k)。进一步,利用对称性( C n k = C n n − k C_n^k = C_n^{n-k} Cnk=Cnn−k)可减少一半计算。
组合数计算 :杨辉三角第 i 行第 j 列就是组合数 C i j C_i^j Cij。可直接用组合数公式计算:
C n k = n ! k ! ( n − k ) ! C_n^k = \frac{n!}{k!(n-k)!} Cnk=k!(n−k)!n!
但阶乘计算可能溢出,且时间复杂度较高。对于较大 n,通常使用递推关系更稳妥。
变体问题:
- 杨辉三角II :只返回第
k行(从0开始) - 杨辉三角中的路径和:从顶部到底部的最小/最大路径和(类似三角形最小路径和问题)
注: 杨辉三角在算法题中常作为动态规划入门题,但其数学内涵丰富(二项式定理、组合恒等式等)。面试中可能要求解释递推关系的数学背景。
面试考点
- 二维DP定义:能否正确构建二维状态表
- 递推关系理解 :能否解释
triangle[i][j] = triangle[i-1][j-1] + triangle[i-1][j]的数学意义 - 边界处理 :正确处理首尾元素为
1 - 空间优化:若只需求某一行,如何优化空间
- 组合数知识:了解杨辉三角与组合数的关系
常见错误:
- 索引混乱:误用
i和j导致数组越界 - 忘记首尾赋值为
1 - 对
numRows=0或1的特殊情况处理不当
121.买卖股票的最佳时机
题目概述与链接
题目链接 :121. Best Time to Buy and Sell Stock
问题描述 :给定一个数组 prices,其中 prices[i] 表示第 i 天的股票价格。你只能选择某一天 买入这支股票,并选择在未来的某一天 卖出。设计算法计算你所能获取的最大利润。如果你不能获取任何利润,返回 0。
示例:
输入:prices = [7,1,5,3,6,4]
输出:5
解释:在第 2 天(价格=1)买入,在第 5 天(价格=6)卖出,利润=6-1=5。
输入:prices = [7,6,4,3,1]
输出:0
解释:价格持续下跌,无法获利。
关键约束:
- 只能进行一次交易(一次买入,一次卖出)
- 卖出必须在买入之后
- 可以当天买入当天卖出(利润为0)
核心解题思路
这是经典的"状态机"动态规划问题,也可用贪心思想解决。定义状态 dp[i] 为"在前 i 天中,进行一次交易能获得的最大利润"。但更高效的做法是维护历史最低价格 min_price,并在遍历过程中计算当前价格与历史最低的差值作为潜在利润。
状态定义(动态规划视角):
dp[i][0]:第i天结束时,持有股票的最大利润dp[i][1]:第i天结束时,不持有股票的最大利润
但本题限制只能交易一次,且状态可简化。
贪心/一次遍历解法:
- 初始化
min_price = float('inf'),max_profit = 0 - 遍历每天价格
price:- 更新历史最低价:
min_price = min(min_price, price) - 计算当前卖出利润:
profit = price - min_price - 更新最大利润:
max_profit = max(max_profit, profit)
- 更新历史最低价:
- 返回
max_profit
动态规划解法(扩展性更强):
dp[i] = max(dp[i-1], prices[i] - min_price)- 其中
min_price = min(min_price, prices[i])
注: 虽然本题可用贪心思想解决,但其本质仍是动态规划------状态为"当前最大利润",转移方程为 dp[i] = max(dp[i-1], prices[i] - min_price)。这种简化得益于问题约束(单次交易)。
时间复杂度与空间复杂度分析
- 时间复杂度 : O ( n ) O(n) O(n)。只需一次遍历。
- 空间复杂度 :
- 贪心解法: O ( 1 ) O(1) O(1),只使用常数变量
- 动态规划数组: O ( n ) O(n) O(n)(若保存每天的最大利润)
Python代码实现
python
def maxProfit(prices):
"""
121.买卖股票的最佳时机:一次遍历(贪心/DP)解法
"""
if not prices or len(prices) < 2:
return 0
# 方法1:直观贪心解法
min_price = float('inf')
max_profit = 0
for price in prices:
min_price = min(min_price, price)
profit = price - min_price
max_profit = max(max_profit, profit)
return max_profit
# 方法2:动态规划数组(便于理解状态转移)
# n = len(prices)
# dp = [0] * n # dp[i]表示前i天的最大利润
# min_price = prices[0]
#
# for i in range(1, n):
# min_price = min(min_price, prices[i])
# dp[i] = max(dp[i-1], prices[i] - min_price)
#
# return dp[-1]
# 测试用例
if __name__ == "__main__":
print(maxProfit([7,1,5,3,6,4])) # 5
print(maxProfit([7,6,4,3,1])) # 0
print(maxProfit([1,2,3,4,5])) # 4
代码注释:
- 边界处理:数组为空或长度小于2时直接返回0(无法交易)
- 贪心解法:维护
min_price和max_profit,遍历中更新 - 动态规划版本:用数组
dp记录每天的最大利润,便于追踪状态变化 - 两种方法本质相同,贪心解法是动态规划的空间优化版本
优化讨论
空间优化 :如上所述,贪心解法已将空间复杂度优化至 O ( 1 ) O(1) O(1),是最优解。
状态机DP的通用性:本题的完整状态机DP解法为:
dp[i][0] = max(dp[i-1][0], -prices[i])# 持有股票dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])# 不持有股票
其中 dp[i][0] 表示第 i 天持有股票的最大利润(要么之前持有,要么今天买入),dp[i][1] 表示第 i 天不持有股票的最大利润(要么之前已卖出,要么今天卖出)。初始化 dp[0][0] = -prices[0], dp[0][1] = 0。最终答案为 dp[n-1][1]。
这种解法虽然稍复杂,但易于扩展到"多次交易"、"含冷冻期"、"含手续费"等变体问题。
变体问题:
- 买卖股票的最佳时机II:不限交易次数
- 买卖股票的最佳时机III:最多两次交易
- 买卖股票的最佳时机IV:最多k次交易
- 含冷冻期:卖出后有一天冷冻期不能买入
- 含手续费:每次交易需支付固定手续费
注: 股票问题系列是动态规划的经典应用,掌握状态机模型是解决此类问题的关键。建议从本题出发,逐步学习更复杂的变体。
面试考点
- 问题转化能力:能否将利润计算转化为"找最大差值"问题
- 一次遍历解法 :能否设计 O ( n ) O(n) O(n) 时间、 O ( 1 ) O(1) O(1) 空间的算法
- 边界条件:处理价格递减、空数组等情况
- 状态机DP理解:能否给出完整的状态定义和转移方程
- 变体扩展:了解股票问题系列的其他变体
常见错误:
- 误用"最大价格减最小价格",忽略时间顺序(卖出必须在买入后)
- 忘记处理利润为0的情况(价格持续下跌)
- 在价格数组很小时未做边界判断
动态规划在简单题目中的共性总结
通过以上三道题目的分析,我们可以总结出动态规划解决简单问题的共性模式:
1. 状态定义
- 爬楼梯 :一维状态
dp[i],表示到达第i阶的方法数 - 杨辉三角 :二维状态
triangle[i][j],表示第i行第j列的值 - 买卖股票 :可简化为一维状态
dp[i](前i天最大利润),或状态机dp[i][0/1]
关键:状态应能完整描述问题的某个阶段,且包含做出决策所需的信息。
2. 转移方程
- 线性递推 :
dp[i] = dp[i-1] + dp[i-2](爬楼梯) - 二维递推 :
triangle[i][j] = triangle[i-1][j-1] + triangle[i-1][j](杨辉三角) - 极值维护 :
dp[i] = max(dp[i-1], prices[i] - min_price)(买卖股票)
关键:找出当前状态与之前状态的关系,用数学公式表达。
3. 边界条件
- 爬楼梯 :
dp[0] = 1,dp[1] = 1 - 杨辉三角 :每行首尾为
1 - 买卖股票 :第一天利润为
0,历史最低价为第一天价格
关键:正确初始化初始状态,避免递推起点错误。
4. 空间优化
- 滚动数组:仅保存必要的前几个状态(爬楼梯)
- 压缩状态:用变量替代数组(买卖股票)
- 对称性利用:减少计算量(杨辉三角)
关键:识别状态依赖关系,消除不必要的存储。
5. 思维进阶
从简单题目出发,可向以下方向扩展:
- 增加维度:如股票问题增加交易次数维度
- 增加约束:如爬楼梯增加"不能连续爬2阶"约束
- 改变目标:如杨辉三角求路径和而非生成整个三角
学习建议
- 从简单题目入手:掌握状态定义、转移方程、边界条件这三要素
- 理解优化本质:空间优化不是炫技,而是对状态依赖关系的深刻理解
- 建立解题模板:对常见DP类型(线性、二维、状态机等)形成思维框架
- 多做变体练习:同一核心思想在不同约束下的表现,锻炼思维灵活性
- 重视数学基础:递推关系往往有深刻的数学背景(如组合数、斐波那契)