【算法学习专栏】动态规划基础·简单三题精讲(70.爬楼梯、118.杨辉三角、121.买卖股票的最佳时机)

引言

动态规划(Dynamic Programming,简称DP)是算法设计中的核心思想之一,也是面试与竞赛中的高频考点。它通过将复杂问题分解为相互重叠的子问题,并利用子问题的解构建原问题的解,从而避免重复计算,显著提升效率。对于初学者而言,掌握动态规划的精髓往往从简单的经典题目开始,通过理解状态定义、转移方程和边界条件这三大要素,逐步建立解题直觉。

本文精选力扣(LeetCode)hot100中三道简单难度的动态规划入门题目,涵盖不同的应用场景:

  1. 70.爬楼梯:一维线性递推,斐波那契数列的典型变体
  2. 118.杨辉三角:二维矩阵递推,组合数学的直观体现
  3. 121.买卖股票的最佳时机:状态机DP,单状态转移与极值维护

每道题目我们将按照题目概述→核心思路→复杂度分析→代码实现→优化讨论→面试考点的逻辑展开,并在关键知识点处添加"注:"形式的补充说明。文章最后给出动态规划在简单题目中的共性总结与学习建议。


70.爬楼梯

题目概述与链接

题目链接70. Climbing Stairs

问题描述 :假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 12 个台阶。计算有多少种不同的方法可以爬到楼顶。

示例

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

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

核心解题思路

爬楼梯问题是动态规划最经典的入门例题,其本质是斐波那契数列 的变体。定义状态 dp[i] 为"到达第 i 阶楼梯的不同方法数"。由于每次只能爬 12 阶,要到达第 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)。需要计算从 2n 的每个 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
  • 滚动数组实现:用 prev1prev2 分别记录前两个状态,每次迭代更新,将空间复杂度优化到 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 次幂,可在对数时间内得到结果。但这在简单题目中通常不要求。

变体思考 :若每次可以爬 123 个台阶,则转移方程变为 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 取模)。

面试考点

  1. 状态定义 :能否清晰定义 dp[i] 的含义
  2. 转移方程 :能否推导出 dp[i] = dp[i-1] + dp[i-2] 并解释原因
  3. 边界条件 :正确处理 dp[0]dp[1],避免数组越界
  4. 空间优化:能否给出滚动数组的实现
  5. 变体问题:若步长变化或加入约束(如"不能连续爬2阶"),如何调整状态定义

常见错误

  • 忘记处理 n=0n=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

动态规划过程

  1. 初始化空列表 triangle
  2. 逐行生成:对于第 i 行,创建长度为 i+1 的列表 row
  3. 设置首尾为 1
  4. 根据上一行的值计算中间元素
  5. 将当前行加入结果

注: 杨辉三角的递推关系体现了组合数的递推公式 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,通常使用递推关系更稳妥。

变体问题

  1. 杨辉三角II :只返回第 k 行(从0开始)
  2. 杨辉三角中的路径和:从顶部到底部的最小/最大路径和(类似三角形最小路径和问题)

注: 杨辉三角在算法题中常作为动态规划入门题,但其数学内涵丰富(二项式定理、组合恒等式等)。面试中可能要求解释递推关系的数学背景。

面试考点

  1. 二维DP定义:能否正确构建二维状态表
  2. 递推关系理解 :能否解释 triangle[i][j] = triangle[i-1][j-1] + triangle[i-1][j] 的数学意义
  3. 边界处理 :正确处理首尾元素为 1
  4. 空间优化:若只需求某一行,如何优化空间
  5. 组合数知识:了解杨辉三角与组合数的关系

常见错误

  • 索引混乱:误用 ij 导致数组越界
  • 忘记首尾赋值为 1
  • numRows=01 的特殊情况处理不当

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 天结束时,不持有股票的最大利润

但本题限制只能交易一次,且状态可简化。

贪心/一次遍历解法

  1. 初始化 min_price = float('inf'), max_profit = 0
  2. 遍历每天价格 price
    • 更新历史最低价:min_price = min(min_price, price)
    • 计算当前卖出利润:profit = price - min_price
    • 更新最大利润:max_profit = max(max_profit, profit)
  3. 返回 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_pricemax_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]

这种解法虽然稍复杂,但易于扩展到"多次交易"、"含冷冻期"、"含手续费"等变体问题。

变体问题

  1. 买卖股票的最佳时机II:不限交易次数
  2. 买卖股票的最佳时机III:最多两次交易
  3. 买卖股票的最佳时机IV:最多k次交易
  4. 含冷冻期:卖出后有一天冷冻期不能买入
  5. 含手续费:每次交易需支付固定手续费

注: 股票问题系列是动态规划的经典应用,掌握状态机模型是解决此类问题的关键。建议从本题出发,逐步学习更复杂的变体。

面试考点

  1. 问题转化能力:能否将利润计算转化为"找最大差值"问题
  2. 一次遍历解法 :能否设计 O ( n ) O(n) O(n) 时间、 O ( 1 ) O(1) O(1) 空间的算法
  3. 边界条件:处理价格递减、空数组等情况
  4. 状态机DP理解:能否给出完整的状态定义和转移方程
  5. 变体扩展:了解股票问题系列的其他变体

常见错误

  • 误用"最大价格减最小价格",忽略时间顺序(卖出必须在买入后)
  • 忘记处理利润为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阶"约束
  • 改变目标:如杨辉三角求路径和而非生成整个三角

学习建议

  1. 从简单题目入手:掌握状态定义、转移方程、边界条件这三要素
  2. 理解优化本质:空间优化不是炫技,而是对状态依赖关系的深刻理解
  3. 建立解题模板:对常见DP类型(线性、二维、状态机等)形成思维框架
  4. 多做变体练习:同一核心思想在不同约束下的表现,锻炼思维灵活性
  5. 重视数学基础:递推关系往往有深刻的数学背景(如组合数、斐波那契)
相关推荐
wsoz2 小时前
Leetcode子串-day4
c++·算法·leetcode
汀、人工智能2 小时前
[特殊字符] 第27课:环形链表II
数据结构·算法·链表·数据库架构··环形链表ii
会编程的土豆2 小时前
【数据结构与算法】二叉树大总结
数据结构·算法·leetcode
嵌入式小企鹅2 小时前
阿里编程模型赶超、半导体涨价蔓延、RISC-V新品密集上线
人工智能·学习·ai·程序员·risc-v·芯片
沉鱼.442 小时前
第十届题目
算法
y = xⁿ2 小时前
【LeetCode Hot100】动态规划:T70:爬楼梯 T118:杨辉三角形 T198:打家劫舍
算法·leetcode·动态规划
Liangwei Lin2 小时前
洛谷 P1460 [USACO2.1] 健康的荷斯坦奶牛 Healthy Holsteins
数据结构·算法
汀、人工智能2 小时前
02 - 变量与数据类型
数据结构·算法·链表·数据库架构··02 - 变量与数据类型
hello!树2 小时前
函数极限的概念和性质
算法