动态规划(Dynamic Programming,DP)是一种解决复杂问题的算法思想,核心是通过分解问题、存储中间结果来避免重复计算。它的核心目标是优化递归或分治过程中出现的重叠子问题,通过空间换时间的方式大幅提升效率。
核心思想
分解子问题 将大问题拆解为相互关联的小问题,找到问题的递归结构。例如:
斐波那契数列:f(n) = f(n-1) + f(n-2)
编辑距离:将字符串转换拆解为字符级别的插入、删除、替换操作。DNA序列编辑距离
重叠子问题 子问题之间重复出现,若直接递归会重复计算多次。例如斐波那契数列中,计算 f(5) 需要多次计算 f(3) 和 f(2)。
最优子结构 大问题的最优解可以由子问题的最优解组合得到。例如,最短路径问题中,从 A 到 C 的最短路径必然经过中间点 B 的最短路径。
记忆化存储(Memoization) 将子问题的解存储起来(通常用数组或哈希表),避免重复计算。
一般步骤
- 定义状态:确定问题的状态表示,通常使用一个或多个变量来表示子问题。
- 确定状态转移方程:根据问题的最优子结构性质,找出状态之间的转移关系。
- 初始化边界条件:确定初始状态的值,这些值是不需要通过状态转移方程计算的。
- 计算最终结果:根据状态转移方程和边界条件,逐步计算出最终问题的解。
斐波那契数列
其数值为:0、1、1、2、3、5、8、13、21、34……在数学上,这一数列以如下递推的方法定义:F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)
def fibonacci(n):
# 创建一个列表来存储斐波那契数
dp = [0] * (n + 1)
# 初始化前两个斐波那契数
dp[0], dp[1] = 0, 1
# 从第三个数字开始计算到第 n 个数字
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
# 返回第 n 个斐波那契数
return dp[n]
# 测试函数
print(fibonacci(10)) # 输出: 55
-
定义状态
状态表示的是子问题,对于斐波那契数列,那么子问题就是计算第ii个斐波那契数( 0 ≤ i ≤ n 0≤i≤n 0≤i≤n)。我们可以定义一个一维数组 dpdp , 其中 dp[i] dp[i] 表示第 ii 个斐波那契数。 -
确定状态转移方程
根据斐波那契数列的定义,第 nn 个斐波那契数等于第 n−1n-1 个斐波那契数和第 n−2n-2 个斐波那契数之和。所以状态转移方程为 dp[i]=dp[i−1]+dp[i−2]dp[i] = dp[i-1] + dp[i-2] :,其中 i≥2i≥2 。 -
初始化边界条件
在斐波那契数列的定义中,明确给出了第 0 个和第 1 个斐波那契数的值,即 F(0)=0,F(1)=1F(0)=0,F(1)=1 。 在动态规划的数组 dpdp 中,对应的初始化就是 dp[0]=0,dp[1]=1dp[0]=0,dp[1]=1 。这些初始值是不需要通过状态转移方程计算的,是已知的。 -
计算最终结果
根据状态转移方程和边界条件,我们可以从 i=2i=2 开始,逐步计算出 dp[2],dp[3]…dp[n]dp[2],dp[3]…dp[n] 。 最终, 就是第 dp[n]dp[n] 个斐波那契数,也就是我们要求解的最终结果。
DNA序列编辑距离
输入:dna1 = “AGT”,dna2 = “AGCT” 输出:1
输入:dna1 = “AACCGGTT”,dna2 = “AACCTTGG” 输出:4
输入:dna1 = “A”,dna2 = “T” 输出:1
def solution(dna1: str, dna2: str) -> int:
n, m = len(dna1), len(dna2)
if n * m == 0:
return n + m # 任一DNA序列为空时,直接返回长度和
# 初始化动态规划表 (n+1)x(m+1) 二位数组
f = [[0] * (m + 1) for _ in range(n + 1)]
# 3.初始化边界条件
for i in range(n + 1):
f[i][0] = i # dna2为空,删除所有dna1的字符
for j in range(m + 1):
f[0][j] = j # dna1为空,插入所有dna2的字符
# 填充动态规划表
for i in range(1, n + 1):
for j in range(1, m + 1):
# 计算三种操作的代价
delete = f[i-1][j] + 1
insert = f[i][j-1] + 1
replace_or_match = f[i-1][j-1] + (dna1[i-1] != dna2[j-1])
# 取最小值作为当前状态
f[i][j] = min(delete, insert, replace_or_match)
return f[n][m] # 最终结果
- 定义状态
设两个 DNA 序列分别为 dna1 和 dna2,长度分别为 n 和 m。我们要定义子问题来逐步求解将 dna1 转换为 dna2 的编辑距离。
定义一个二维数组 f,其中 f[i][j] 表示将 dna1 的前 i 个字符转换为 dna2 的前 j 个字符所需的最少操作次数。这里 i 的取值范围是 0 到 n,j 的取值范围是 0 到 m。 - 确定状态转移方程
对于 f[i][j],可以通过三种不同的操作从之前的状态转移过来,分别是删除、插入和替换(或匹配)操作,我们需要取这三种操作代价中的最小值作为 f[i][j] 的值:
删除操作:如果要将 dna1 的前 i 个字符转换为 dna2 的前 j 个字符,且选择删除 dna1 的第 i 个字符,那么只需要在将 dna1 的前 i - 1 个字符转换为 dna2 的前 j 个字符的基础上再进行一次删除操作。所以这种操作的代价为 f[i - 1][j] + 1。
插入操作:若选择在 dna1 的末尾插入一个字符使其与 dna2 的第 j 个字符匹配,那么就是在将 dna1 的前 i 个字符转换为 dna2 的前 j - 1 个字符的基础上再进行一次插入操作。这种操作的代价为 f[i][j - 1] + 1。
替换或匹配操作:比较 dna1 的第 i 个字符和 dna2 的第 j 个字符,如果它们相等,说明不需要额外操作;如果不相等,则需要进行一次替换操作。该操作的代价为 f[i - 1][j - 1] + (dna1[i - 1] != dna2[j - 1]),这里 (dna1[i - 1] != dna2[j - 1]) 是一个布尔表达式,若不相等结果为 1,相等则为 0。
因此,状态转移方程为:
- 初始化边界条件
当 j = 0 时,意味着要将 dna1 的前 i 个字符转换为空字符串,显然需要进行 i 次删除操作,所以 f[i][0] = i,其中 i 从 0 到 n。
当 i = 0 时,即要将空字符串转换为 dna2 的前 j 个字符,需要进行 j 次插入操作,所以 f[0][j] = j,其中 j 从 0 到 m。 - 计算最终结果
根据状态转移方程和边界条件,我们可以通过两层嵌套循环来填充二维数组 f。外层循环遍历 i 从 1 到 n,内层循环遍历 j 从 1 到 m,每次根据状态转移方程计算 f[i][j] 的值。
最终,f[n][m] 就表示将 dna1 的全部字符转换为 dna2 的全部字符所需的最少操作次数,也就是两个 DNA 序列的编辑距离。
以样例1为例分布讲解代码各部分含义:
初始状态
当 i = 1,j = 1 时:A 与 A
dna1[i - 1] = “A”,dna2[j - 1] = “A”。
delete = f[0][1] + 1 = 1 + 1 = 2,表示先将空字符串转换为 “A”(f[0][1]),再删除 dna1 的第 1 个字符 “A”。
insert = f[1][0] + 1 = 1 + 1 = 2,表示先将 “A” 转换为空字符串(f[1][0]),再插入字符 “A”。
replace_or_match = f[0][0] + (dna1[0] != dna2[0]) = 0 + 0 = 0,因为 “A” == “A”,不需要替换操作。
当 i = 1,j = 2 时:A 与 AG
dna1[i - 1] = “A”,dna2[j - 1] = “G”。
delete = f[0][2] + 1 = 2 + 1 = 3。 插入“AG” + 删除A
insert = f[1][1] + 1 = 0 + 1 = 1。 无需删除 + 插入G
replace_or_match = f[0][1] + (dna1[0] != dna2[1]) = 1 + 1 = 2。插入A --> AA 将第二个A替换为G A --> AA --> AG
f[1][2] = min(3, 1, 2) = 1。
以此类推,逐步填充整个 f 数组。最终填充完的 f 数组如下:
- 前三步引领分析
复杂度分析
时间复杂度: O(n×m)O(n×m) ,因为使用了两层嵌套循环来填充二维数组 f。
空间复杂度: O(n×m)O(n×m) ,主要用于存储二维数组 f。