在Python中使用动态规划解决问题时,通常会采用自顶向下的递归方法(结合备忘录)或者自底向上的迭代方法。以下是解题思路的常见步骤以及一个带备忘录的示例。
思考状态转移方程的一个基本方法是数学归纳法,即明确 dp
函数或数组的定义,然后使用这个定义,从已知的「状态」中推导出未知的「状态」。
动态规划解题思路
- 明确问题的递推关系:找出问题如何通过子问题的解来得到最终解。这个关系通常是一个递归公式。
- 确定边界条件:找出最小的子问题,并确定其解。这些解通常作为动态规划的初始条件。
- 选择自顶向下或自底向上的实现方法:
- 自顶向下:通过递归来解决问题,同时使用备忘录(字典或数组)来保存已经计算过的子问题的解,避免重复计算。
- 自底向上:从最小的子问题开始,逐步计算出较大的子问题的解,最终得到全局问题的解。
- 优化空间复杂度(可选):如果在迭代过程中只需要存储部分结果,可以尝试优化空间复杂度。
自顶向下的方法,用备忘录存储已经计算过子问题的解,避免重复进行计算
状态转移方程是动态规划中将复杂问题转化为子问题并递推求解的关键工具。理解并正确建立状态转移方程,是动态规划问题成功解决的基础。它不仅帮助我们找到最优解,也提供了递归或迭代的实际计算路径。
如何理解状态转移方程
- 问题的状态:
- 在动态规划中,"状态"通常指的是问题的某个子问题或子问题的某个阶段的情况。状态是对问题当前情况的描述,通常由一组变量来表示。
- 状态的选择决定了问题的划分方式,也影响到问题的递推关系。
- 转移:
- 转移是指从一个状态到另一个状态的变化过程。这种转移反映了通过计算当前状态所需要的资源(时间、空间等),以及如何根据前一个状态的结果推导出当前状态的结果。
- 方程的表达:
- 状态转移方程通常是一个递推公式,它描述了如何根据先前计算的状态值来计算当前状态值。
- 一般形式为:
dp[i] = f(dp[i-1], dp[i-2], ..., dp[i-k])
,其中f
是一个函数,用于描述如何将之前的状态组合起来得到当前状态
class Solution:
memo = []
def minFallingPathSum(self, matrix: List[List[int]]) -> int:
n = len(matrix)
res = float('inf')
self.memo = [[66666 for _ in range(n)] for _ in range(n)]
for j in range(n):
res = min(res, self.dp(matrix, n - 1, j))
return res
def dp(self, matrix, i, j) -> int:#dp得到的答案是到matrix[i][j]的下降路径最小和
if i < 0 or j < 0 or i >= len(matrix) or j >= len(matrix):
return 66666
if i == 0:
return matrix[0][j]
if self.memo[i][j] != 66666:
return self.memo[i][j]
self.memo[i][j] = matrix[i][j] + min(self.dp(matrix, i - 1, j - 1), self.dp(matrix, i - 1, j),
self.dp(matrix, i - 1,j + 1))
return self.memo[i][j]
二叉树问题把递归穷举划分为「遍历」和「分解问题」两种思路,其中「遍历」的思路扩展延伸一下就是回溯算法,「分解问题」的思路可以扩展成动态规划算法。
可以发现其实动态规划问题也就是抓住dp函数的定义,明确dp函数定义。
标准的动态规划问题一定是求最值的 但其实用了备忘录的dfs也愿意称之为动态规划。
memo
数组的更新是在递归函数 dp
中的最后一步完成的。具体来说,memo
数组的更新是在确定了当前子问题的最终结果之后才进行的。这样做的目的是确保 memo
数组中存储的每个值都是准确且完整的。
3. 二者的关系
- 相同点:
memo
数组的每个元素存储的值就是对应dp
函数计算得到的结果。可以认为memo[i]
记录的是dp(s, i, wordDict)
的计算结果。 - 不同点:
dp
函数是一个动态计算过程,在运行过程中会调用自己去解决子问题。memo
数组则是用于记忆化这些计算结果的,以避免在递归中重复计算同一个子问题。
4. 总结
memo
数组是用来记录dp
函数的计算结果的存储结构,它将动态规划中子问题的解缓存起来,以提高算法效率。- 你可以认为
memo
数组的每个元素对应于dp
函数在某个位置的返回值,而dp
函数通过检查memo
来决定是否需要进行计算。
这样理解下来,memo
数组和 dp
函数的作用是协同的,但 memo
是静态存储,而 dp
是动态计算,它们共同构成了记忆化递归的核心机制。\
class Solution:
memo = []
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
self.memo = [-1] * len(s) #memo数组就是存储子问题的结果,防止重复计算,也就是存储[i...]能否被wordDict拼出来
return self.dp(s, 0, wordDict)
def dp(self, s: str, i: int, wordDict: List[str]) -> bool:
#定义,返回s[i..]能否被wordDict拼出,返回bool变量
if i == len(s):
return True
if self.memo[i] != -1:#-1d代表未被计算,0代表false,1代表true
return self.memo[i] == 1 #进行一次判断
for word in wordDict:#遍历所有单词,尝试匹配s[i..]
length = len(word)
if i + length > len(s):
continue
subStr = s[i: i + length]
if subStr != word:
continue
if self.dp(s, i + length, wordDict):
self.memo[i] = 1
return True
self.memo[i] = 0
return False
动态规划总结
符合最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果。让你算每个班的最优成绩就是子问题,你知道所有子问题的答案后,就可以借此推出全校学生的最优成绩这个规模更大的问题的答案。 最优子结构:子问题之间必须互相独立
最优子结构并不是动态规划独有的一种性质,能求最值的问题大部分都具有这个性质;但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的,以后碰到那种恶心人的最值题,思路往动态规划想就对了,这就是套路
例如求二叉树的最大值,也是最优子结构问题,但不是动态规划。 二叉树最大值可以通过左子树最大值与右子树最大值比较得到。
找最优子结构的过程,其实就是证明状态转移方程正确性的过程,方程符合最优子结构就可以写暴力解了,写出暴力解就可以看出有没有重叠子问题了,有则优化,无则 OK。这也是套路,经常刷题的读者应该能体会
# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
for 选择 in 所有可能的选择:
# 此时的状态已经因为做了选择而改变
result = 求最值(result, dp(状态1, 状态2, ...))
return result
状态就是因为选择而会发生变化的值, 选择就是在所有可能的里面做选择, 状态转移方程就是在选择里面进行的具体操作。 再完善base case
要通俗理解动态规划中的“状态”、“选择”、“dp函数的定义”以及“状态转移方程”,可以从日常生活的例子出发:
1. 状态
状态代表你在某个时刻所处的“情境”或“局面”。可以理解为你做决策时面对的具体情况。
例子:假设你正在攀爬一座山,目标是到达山顶。状态就是你目前所在的位置(比如你爬到了第5个台阶)。
2. 选择
选择是你在某个状态下可以采取的行动,影响你如何从当前状态进入下一个状态。
例子:在爬山的例子中,你可以有几种选择:
- 从第5个台阶走到第6个台阶
- 或者跳过第6个台阶直接到第7个台阶
每个选择会带来不同的结果,动态规划要做的就是找到一条最优的路线,让你用最少的力气到达山顶。
3. dp函数的定义
dp函数是用来表示在某个状态下,最优解的结果,也就是说,这个函数告诉你在当前状态下,如何做出最优选择。
例子:假设 dp(i)
表示从第 i
个台阶到达山顶所需的最小力气。那么 dp(5)
就表示从第5个台阶到达山顶时最优的路线选择,以及对应的力气消耗。
4. 状态转移方程
状态转移方程描述了如何通过选择从一个状态“转移”到下一个状态,并且能够保证最终找到最优解。
例子:在爬山的例子中,状态转移方程可以是:
$[ dp(i) = \min(dp(i+1), dp(i+2)) + cost(i) $
意思是:从第 i
个台阶到山顶的最优解,取决于走一步到 i+1
或者跳到 i+2
所需的最小力气,再加上当前台阶的消耗 cost(i)
。
总结
- 状态:你现在在哪个台阶
- 选择:你可以走一步或跳两步
- dp函数:从某个台阶到山顶的最优路径所需的最小力气
- 状态转移方程:如何从当前台阶,基于下一步或下两步,计算出最优的力气消耗
动态规划的核心就是通过递归地定义状态,并通过选择进行状态转移来找到整体的最优解。
专业解释:
在动态规划问题中,状态、选择、dp函数的定义和状态转移方程是核心概念。专业的解释如下:
1. 状态
状态是问题的某一阶段的特征描述,表示你在求解过程中处于哪个阶段或情境。它通常包括决定当前情况的一些关键信息,能够帮助推导出最终解。
例子:在最短路径问题中,状态可以表示当前到达某个顶点的最短路径长度;在背包问题中,状态可以表示当前考虑了哪些物品以及背包的剩余容量。
- 状态一般是问题中的子问题。动态规划通过求解子问题的最优解,逐步构建出整个问题的解。
2. 选择
选择是从当前状态出发,可以采取的所有可能操作。这些操作会导致状态发生变化,也就是状态转移。选择的结果会影响你如何从当前状态转移到下一个状态。
例子:在背包问题中,每个物品可以选择“放入背包”或“不放入背包”;在爬楼梯问题中,你可以选择走一步或走两步。
- 每个状态下的选择不同,直接影响最终的结果,动态规划通过穷尽所有选择,并从中找到最优选择。
3. dp函数的定义
dp函数(动态规划函数)是用来表示子问题的最优解,它定义了如何计算某一状态下的最优解。dp函数通过递推式或状态转移方程,逐步从小规模问题推导出大规模问题的最优解。
例子:如果定义 dp(i)
表示从第 i
个状态到达终点的最优解,那么 dp(i)
是基于从 i+1
或 i+2
转移过来的结果,表示当前状态的最优选择。
- dp函数的定义通常遵循最优子结构原则,即一个问题的最优解可以通过其子问题的最优解推导出来。
4. 状态转移方程
状态转移方程定义了如何从一个状态转移到另一个状态。它描述了当前状态与下一状态之间的递推关系。状态转移方程是动态规划的核心,因为它体现了如何通过子问题的解递归地得到整个问题的解。
例子:在爬楼梯问题中,状态转移方程可以写为:
[ dp(i) = \min(dp(i+1), dp(i+2)) + cost(i) ]
这表示从第 i
个位置到达终点的最优解等于从 i+1
或 i+2
位置到达终点的最优解加上当前位置的代价。
- 状态转移方程体现了问题的递归特性,确保通过已知子问题的解逐步解决更复杂的问题。
动态规划问题的步骤总结
- 确定状态:定义问题中的状态,通常是通过若干变量描述问题的子结构。
- 定义dp函数:dp函数表示子问题的最优解,通常以状态为输入。
- 找到状态转移方程:根据子问题的解,定义状态之间的递推关系,利用已知小问题的解来推导大问题的解。
- 初始化边界条件:确定问题的初始条件,即最简单子问题的解。
- 通过递推求解:从最小问题开始,逐步构建出最终问题的解。
总结
- 状态表示问题中的某个子问题或阶段。 当前所处的状态
- 选择是当前状态下所有可能的操作。 比如可以选择走一步或者走两部
- dp函数表示在某一状态下的最优解。 子问题要符合最优子结构
- 状态转移方程描述状态之间的递推关系。(用到dp子问题,再结合一些参数,得到大问题的解)
动态规划本质上是一种优化问题求解技术,使用子问题的解来推导全局问题的最优解。
将递归的 dp
函数改为 dp
数组的方式,通常称为将 自顶向下的递归(带备忘录) 转换为 自底向上的迭代。这种转换可以通过明确地维护一个 dp
数组来实现。在递归过程中,函数通过状态转移方程计算各个子问题的解,而在数组版本中,我们通过迭代逐步计算并存储每个状态的值。
以下是如何将递归的 dp
函数改为 dp
数组的步骤:
1. 明确递归的状态转移方程
递归 dp
函数的定义和状态转移方程往往是问题的核心,先明确递归版本中的状态是什么、如何转移到下一个状态。
例如,在斐波那契数列中,状态转移方程为:
[
dp(n) = dp(n-1) + dp(n-2)
]
递归函数可以通过这种方式直接调用。
2. 初始化 dp
数组
将递归版本的中间结果存储到 dp
数组中,初始化时注意边界条件,例如初始值 dp[0]
和 dp[1]
等。
3. 根据递推公式从底部向上迭代
通过迭代循环代替递归的函数调用,利用 dp
数组来存储结果并逐步构建。
示例:斐波那契数列
递归的斐波那契数列函数是:
class Solution:
def fib(self, n: int) -> int:
self.memo = [-1] * (n + 1)
return self.dp(n)
def dp(self, n: int) -> int:
if n == 0 or n == 1:
return n
if self.memo[n] != -1:
return self.memo[n]
self.memo[n] = self.dp(n - 1) + self.dp(n - 2)
return self.memo[n]
改成 dp
数组版本:
class Solution:
def fib(self, n: int) -> int:
if n == 0:
return 0
if n == 1:
return 1
# 初始化dp数组
dp = [0] * (n + 1)
dp[0] = 0
dp[1] = 1
# 迭代计算dp数组
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
斐波那契问题的变化:
- 递归版本是自顶向下的,通过递归调用处理子问题。
- 数组版本是自底向上的迭代方式,直接通过循环从最小问题(
n=0
和n=1
)计算到n
。
示例:编辑距离问题
递归版本的编辑距离:
class Solution:
def __init__(self):
self.memo = []
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
self.memo = [[-1] * n for _ in range(m)]
return self.dp(word1, m - 1, word2, n - 1)
def dp(self, s1: str, i: int, s2: str, j: int) -> int:
if i == -1:
return j + 1
if j == -1:
return i + 1
if self.memo[i][j] != -1:
return self.memo[i][j]
if s1[i] == s2[j]:
self.memo[i][j] = self.dp(s1, i - 1, s2, j - 1)
else:
self.memo[i][j] = min(
self.dp(s1, i, s2, j - 1) + 1, # 插入
self.dp(s1, i - 1, s2, j) + 1, # 删除
self.dp(s1, i - 1, s2, j - 1) + 1 # 替换
)
return self.memo[i][j]
改成 dp
数组版本:
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# 初始化dp数组
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 边界条件,空字符串的情况
for i in range(1, m + 1):
dp[i][0] = i # word2为空,删除操作
for j in range(1, n + 1):
dp[0][j] = j # word1为空,插入操作
# 迭代填充dp数组
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # 如果字符相同,不需要额外操作
else:
dp[i][j] = min(
dp[i - 1][j] + 1, # 删除
dp[i][j - 1] + 1, # 插入
dp[i - 1][j - 1] + 1 # 替换
)
return dp[m][n]
总结:
从递归 dp
函数转换成 dp
数组的主要步骤:
- 找到状态转移方程。
- 初始化
dp
数组:设定基本的初始值,特别是边界条件。 - 自底向上迭代:用循环代替递归调用,逐步计算每个状态的解。
这样做的好处是避免递归带来的栈溢出问题,并且通常会提高性能。