这篇文章,我介绍了动态规划的思想并且在豆包MarsCode AI刷题(代码练习)题库中选取了一道中等动态规划题进行解析。
动态规划
动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为更简单的子问题来解决问题的算法技术。它通常用于优化问题,其中问题的解可以通过组合子问题的解来获得。动态规划的核心思想是“记忆化”,即存储子问题的解以避免重复计算。
动态规划的基本概念
- 状态:问题的子问题通常由一组状态来描述。状态是描述问题当前情况的一组变量。
- 状态转移方程:状态转移方程描述了如何从一个状态转移到另一个状态。这是动态规划的核心,它定义了子问题之间的关系。
- 初始条件:初始条件是状态转移方程的起点,通常是问题的最简单情况。
- 边界条件:边界条件是状态转移方程的终点,通常是问题的最终解。
动态规划的步骤
- 定义状态:明确问题的状态是什么,通常用一个或多个变量来表示。
- 写出状态转移方程:根据问题的性质,写出状态之间的转移关系。
- 确定初始条件和边界条件:明确问题的起点和终点。
- 计算顺序:确定计算状态的顺序,通常是从初始状态开始,逐步计算到最终状态。
- 优化空间复杂度(可选):在某些情况下,可以通过滚动数组等方式优化空间复杂度。
动态规划的例子一:斐波那契数列
斐波那契数列是一个经典的动态规划问题。数列的定义如下:
F(0) = 0F(1) = 1F(n) = F(n-1) + F(n-2)对于n >= 2
步骤
- 定义状态:设
dp[i]表示第i个斐波那契数。 - 状态转移方程:
dp[i] = dp[i-1] + dp[i-2] - 初始条件:
dp[0] = 0,dp[1] = 1 - 计算顺序:从
dp[2]开始计算,直到dp[n] - 优化空间复杂度:由于
dp[i]只依赖于dp[i-1]和dp[i-2],我们可以只用两个变量来存储前两个状态,从而将空间复杂度从O(n)优化到O(1)。
代码
def fibonacci(n):
if n == 0:
return 0
elif n == 1:
return 1
# 初始条件
dp_i_minus_2 = 0
dp_i_minus_1 = 1
# 计算顺序
for i in range(2, n + 1):
dp_i = dp_i_minus_1 + dp_i_minus_2
dp_i_minus_2 = dp_i_minus_1
dp_i_minus_1 = dp_i
return dp_i_minus_1
# 测试
print(fibonacci(10)) # 输出 55
动态规划的例子二:最长公共子序列(LCS)
最长公共子序列(LCS)是动态规划中的一个经典问题,用于寻找两个序列中最长的公共子序列。子序列是指从原序列中删除一些元素(可以不删除任何元素)后得到的新序列,且新序列中的元素顺序与原序列中的顺序一致。所有的查重基本上都是用LCS解决。
问题描述
给定两个序列 X 和 Y,找到它们的最长公共子序列的长度。
动态规划步骤
- 定义状态:设
dp[i][j]表示序列X的前i个元素和序列Y的前j个元素的最长公共子序列的长度。 - 状态转移方程:
- 如果
X[i-1] == Y[j-1],则dp[i][j] = dp[i-1][j-1] + 1 - 如果
X[i-1] != Y[j-1],则dp[i][j] = max(dp[i-1][j], dp[i][j-1])
- 如果
- 初始条件:
dp[0][j] = 0对于所有j,表示X为空序列时,LCS 长度为 0。dp[i][0] = 0对于所有i,表示Y为空序列时,LCS 长度为 0。
- 计算顺序:从
dp[1][1]开始计算,直到dp[len(X)][len(Y)]。
代码
def longest_common_subsequence(X, Y):
m, n = len(X), len(Y)
# 创建一个 (m+1) x (n+1) 的二维数组 dp
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 计算 dp 数组
for i in range(1, m + 1):
for j in range(1, n + 1):
if X[i - 1] == Y[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# 返回 LCS 的长度
return dp[m][n]
# 测试
X = "ABCBDAB"
Y = "BDCAB"
print(longest_common_subsequence(X, Y)) # 输出 4
解释
-
定义状态:
dp[i][j]表示X的前i个元素和Y的前j个元素的 LCS 长度。 -
状态转移方程:
- 如果
X[i-1] == Y[j-1],说明当前元素可以加入 LCS,因此dp[i][j] = dp[i-1][j-1] + 1。 - 如果
X[i-1] != Y[j-1],说明当前元素不能同时加入 LCS,因此dp[i][j] = max(dp[i-1][j], dp[i][j-1]),即取X的前i-1个元素和Y的前j个元素的 LCS 长度,或者X的前i个元素和Y的前j-1个元素的 LCS 长度中的较大值。
- 如果
-
初始条件:
dp[0][j]和dp[i][0]都为 0,因为空序列与任何序列的 LCS 长度为 0。 -
计算顺序:从
dp[1][1]开始计算,直到dp[m][n],其中m和n分别是X和Y的长度。
动态规划的应用场景
- 最短路径问题:如Dijkstra算法、Floyd-Warshall算法。
- 背包问题:如0/1背包问题、完全背包问题。
- 序列问题:如最长递增子序列(LIS)、最长公共子序列(LCS)。
- 字符串匹配问题:如编辑距离(Levenshtein距离)。
总结
动态规划是一种强大的算法技术,适用于许多优化问题。通过将问题分解为子问题并记忆化子问题的解,动态规划可以显著提高算法的效率。掌握动态规划的关键在于理解状态、状态转移方程、初始条件和边界条件,并通过实践不断加深理解。
DNA序列编辑距离(中等题)
问题描述
小R正在研究DNA序列,他需要一个函数来计算将一个受损DNA序列(dna1)转换成一个未受损序列(dna2)所需的最少编辑步骤。编辑步骤包括:增加一个碱基、删除一个碱基或替换一个碱基。
思路
使用动态规划的思路,dp[i][j]代表将dna1的前i个字符转化为dna2的前j个字符所需要的最少编辑步骤。dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
代码
def solution(dna1, dna2):
len1, len2 = len(dna1), len(dna2)
# 初始化dp数组
dp = [[0] * (len2 + 1) for _ in range(len1 + 1)]
# 初始化边界条件
for i in range(len1 + 1):
dp[i][0] = i
for j in range(len2 + 1):
dp[0][j] = j
for i in range(1, len1 + 1):
for j in range(1, len2 + 1):
if dna1[i - 1] == dna2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
# 返回最终的编辑距离
return dp[len1][len2]
代码解释
-
边界条件:
dp[i][0] = i:表示将dna1的前i个字符转换为空字符串需要i次删除操作。dp[0][j] = j:表示将空字符串转换为dna2的前j个字符需要j次插入操作。
-
填充dp数组:
-
如果
dna1[i-1] == dna2[j-1],则不需要任何编辑操作,dp[i][j] = dp[i-1][j-1]。 -
否则,我们需要考虑三种操作(插入、删除、替换),取最小值并加1:
dp[i-1][j]:删除dna1的第i个字符。dp[i][j-1]:在dna1中插入dna2的第j个字符。dp[i-1][j-1]:将dna1的第i个字符替换为dna2的第j个字符。
-