动态规划中的自底向上与自顶向下
动态规划中的自顶向下与自底向上
一、动态规划的概念
动态规划(Dynamic Programming,DP)是一种解决复杂问题的方法,它将问题分解为更小的子问题,通过解决这些子问题并存储其解,避免重复计算,从而高效地解决原问题。动态规划的核心思想是利用重叠子问题(overlapping subproblems)和最优子结构(optimal substructure)的性质。
二、自顶向下的概念
2.1 自顶向下的思考流程方式
自顶向下是一种递归的思考方式,从问题的最终目标出发,逐步分解为更小的子问题。在动态规划中,自顶向下通常结合记忆化技术(Memoization)来存储已解决的子问题,避免重复计算。
具体步骤:
- 定义问题的递归关系,明确如何将原问题分解为子问题。
- 使用递归函数实现,每次递归调用解决一个更小的子问题。
- 引入记忆化机制(如字典或数组),存储已解决的子问题的解,防止重复计算。
2.2 如何确定递归边界
递归边界是动态规划自顶向下方法中的关键部分。通常,我们需要考虑以下因素来确定递归终止条件:
- 问题的最小规模:如果问题规模已经不可再分(如
n = 0
或n = 1
),则可以直接返回结果。 - 子问题的已知解:如果某些情况的结果已知,可以作为递归边界,例如斐波那契数列中的
F(0) = 0
和F(1) = 1
。 - 避免无限递归:确保递归在每次调用时都朝着终止条件收敛,否则可能会导致无限递归。
2.3 结合 LeetCode 例题来讲解示范
例题:和为 n 的完全平方数的最少数量(Perfect Squares)
给定整数 n
,返回和为 n
的完全平方数(如 1, 4, 9, 16,...
)的最少数量。
代码分析:递归 + 记忆化搜索(@cache)求最少完全平方数
算法思路
这段代码使用 递归 + 记忆化搜索(Memoization)来解决最少完全平方数求和问题。
其核心思想是:
- 使用
dp(i, j)
表示:- 在
1
到i
之间选择完全平方数,使得它们的和等于j
,所需的最小数量。
- 在
- 递归状态转移方程:
dp(i, j) = dp(i-1, j)
:不使用i^2
这个完全平方数的情况。dp(i, j) = dp(i, j - i^2) + 1
:使用i^2
这个完全平方数,并减少目标和j
。- 取两者的最小值,即
dp(i, j) = min(dp(i-1, j), dp(i, j - i^2) + 1)
。
代码
from functools import cache
from math import isqrt
def numSquares(n):
@cache
def dp(i, j):
if i == 1:
return j # 只能用 1^2 拼出 j,则需要 j 个 1
if i**2 > j:
return dp(i-1, j) # i^2 超过 j,不能使用它,只能使用更小的数
return min(dp(i-1, j), dp(i, j - i**2) + 1) # 选择是否使用 i^2
return dp(isqrt(n), n) # 从 sqrt(n) 开始递归
三、自底向上的概念
3.1 自底向上的思考流程方式
自底向上是一种迭代的思考方式,从最简单的子问题开始,逐步构建解决方案,直到解决原问题。在动态规划中,自底向上通常使用动态规划表(Dynamic Programming Table)来存储子问题的解。
具体步骤:
- 定义动态规划表的结构,确定如何存储子问题的解。
- 初始化动态规划表的初始值,通常是最简单的子问题的解。
- 使用循环逐步构建更大的解,直到解决原问题。
3.2 结合 LeetCode 例题来讲解示范
例题:和为 n 的完全平方数的最少数量
算法思路
这段代码使用 动态规划 来解决最少数量的完全平方数求和问题。核心思想是:
- 状态定义:
- 设
dp[i]
表示和为 ( i ) 的最少完全平方数的数量。
- 设
- 状态转移方程:
dp[i] = min(dp[i], dp[i - j * j] + 1)
,其中 ( j^2 ) 是不超过 ( i ) 的某个完全平方数。- 通过遍历所有可能的完全平方数 ( j^2 )(( j \leq \sqrt{i} )),找到能得到最少数量的组合。
- 初始化:
dp[0] = 0
,因为和为 0 时不需要任何数。- 其他
dp[i]
初始设为无穷大 (float('inf')
),表示尚未计算。
- 遍历顺序:
i
从1
到n
遍历,计算dp[i]
。j
从1
到sqrt(i)
遍历,尝试所有可能的完全平方数j * j
,并更新dp[i]
。
自底向上解决方案:
import math
def numSquares(n):
dp = [float('inf')] * (n + 1)
dp[0] = 0
for i in range(1, n + 1):
for j in range(1, int(math.sqrt(i)) + 1):
dp[i] = min(dp[i], dp[i - j * j] + 1)
return dp[n]
分析:
- 动态规划表:使用数组
dp
存储每个斐波那契数的值。 - 初始化:
dp[0] = 0
,dp[1] = 1
。 - 迭代构建:通过循环从
i=2
到i=n
计算每个dp[i]
。
四、两者的对比,如何判断何时使用哪种方式
特性 | 自顶向下(Top-Down) | 自底向上(Bottom-Up) |
---|---|---|
实现方式 | 递归调用 | 迭代实现 |
子问题存储 | 使用记忆化技术存储已解决的子问题 | 使用动态规划表存储子问题的解 |
问题分解 | 从最终目标分解为子问题 | 从最简单的子问题逐步构建解决方案 |
适用场景 | 适用于问题可以自然分解为子问题的情况 | 适用于子问题之间存在重叠且需要逐步构建的情况 |
时间复杂度 | 可能较高,但可以通过记忆化优化 | 通常较低,因为避免了递归调用的开销 |
空间复杂度 | 可能较高,因为需要存储递归调用栈和记忆化表 | 通常较低,因为只需要存储动态规划表 |
如何选择
- 问题分解的自然性:如果问题可以自然地分解为子问题,且递归关系明确,自顶向下可能更直观。
- 性能要求:如果对性能要求较高,且递归调用的开销不可接受,自底向上可能更合适。
- 空间限制:如果内存空间有限,自底向上通常更节省空间,因为它不需要递归调用栈。