目录
简介:在上一篇博文(动态规划不再难:一步一步教你攻克经典问题 (1))中,我们介绍了动态规划的基本概念与思想,并举了两个基本的例子,斐波那契数列与 0/1 背包问题。这篇文章将介绍另外三个长见的动态规划问题,最长公共子序列,矩阵路径问题,和硬币找零问题。
1. 最长公共子序列
问题描述:
给定两个字符串 X
和 Y
,求这两个字符串的最长公共子序列的长度。子序列是由原字符串中字符顺序不变的子集组成的,但不要求是连续的。
动态规划解法:
-
定义状态:
-
令
dp[i][j]
表示字符串X[0..i-1]
和Y[0..j-1]
的最长公共子序列的长度。 -
即
dp[i][j]
表示X
和Y
的前i
个字符与前j
个字符的最长公共子序列的长度。
-
-
状态转移方程:
-
如果
dp [ i ] [ j ] = dp [ i - 1 ] [ j - 1 ] + 1X[i-1] == Y[j-1]
,那么这两个字符可以被加入到公共子序列中,即: -
如果
dp [ i ] [ j ] = max(dp [ i - 1 ] [ j ], dp [ i ] [ j - 1 ])X[i-1] != Y[j-1]
,那么我们需要去掉当前的字符,比较两种情况: -
边界条件:如果
dp [ i ] [ 0 ] = 0, dp [ 0 ] [ j ] = 0i
或j
为 0,说明其中一个字符串为空,最长公共子序列的长度为 0:
-
-
最终结果:
-
最终的解是
dp[m][n]
,其中m
和n
分别是X
和Y
的长度。
-
例子:假设 X = "ABCBDAB"
和 Y = "BDCABB"
,它们的最长公共子序列是 "BCAB"
,所以最长公共子序列的长度是 4
。程序如下:
def longestCommonSubsequence(X, Y):
m, n = len(X), len(Y)
# 创建 dp 数组,大小为 (m+1) x (n+1)
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 # LCS 长度加 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # 否则取前一个子问题的最大值
# 返回 dp[m][n],即最长公共子序列的长度
return dp[m][n]
# 示例
X = "ABCBDAB"
Y = "BDCABB"
result = longestCommonSubsequence(X, Y)
print(f"最长公共子序列的长度: {result}")
代码时间复杂度:O(m * n)
,其中 m
和 n
分别是字符串 X
和 Y
的长度。空间复杂度:O(m * n)
,需要一个二维数组来存储中间结果。
程序运行结果:
最长公共子序列的长度: 4
对应的 dp
数组(部分)填充过程如下:
i,j | 0 | B | D | C | A | B | B |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
A | 0 | 0 | 0 | 0 | 1 | 1 | 1 |
B | 0 | 1 | 1 | 1 | 1 | 2 | 2 |
C | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
B | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
D | 0 | 1 | 2 | 2 | 2 | 3 | 3 |
A | 0 | 1 | 2 | 2 | 3 | 3 | 3 |
B | 0 | 1 | 2 | 2 | 3 | 4 | 4 |
最终 dp[7][6] = 4
,即 X
和 Y
的最长公共子序列的长度为 4
。
2. 最小路径问题
问题描述:
给定一个 m x n
的矩阵,其中每个元素代表从该位置到达目标的代价(或路径的权重)。你从矩阵的左上角((0, 0)
)出发,只能向右或向下移动,最终到达矩阵的右下角((m-1, n-1)
)。求从左上角到右下角的路径的最小代价。
动态规划解法:
-
定义状态:
-
dp[i][j]
表示从(0, 0)
到(i, j)
的最小路径和。
-
-
状态转移方程:
-
如果
dp [X[i-1] == Y[j-1]
,那么这两个字符可以被加入到公共子序列中,即:i
] [j
] = matrix [i
] [j
] + min( dp [i
- 1 ] [ j ], dp [i
] [j
- 1 ]) -
边界条件1:
第一行:dp[0][j] = dp[0][j-1] + matrix[0][j]
,只能从左方过来。 -
边界条件2:
第一列:dp[i][0] = dp[i-1][0] + matrix[i][0]
,只能从上方过来。
-
-
最终结果:
-
最终的解是
dp[m - 1][n - 1]
,路径的最小代价。
-
例子:
matrix = [[1, -3, 1],
[1, 5, 1],
[4, -2, 1]]
程序如下:
def minPathSum(matrix):
if not matrix:
return 0
m, n = len(matrix), len(matrix[0])
dp = [[0] * n for _ in range(m)]
dp[0][0] = matrix[0][0] # 初始化起点
# 填充第一行
for j in range(1, n):
dp[0][j] = dp[0][j-1] + matrix[0][j]
# 填充第一列
for i in range(1, m):
dp[i][0] = dp[i-1][0] + matrix[i][0]
# 填充其余部分
for i in range(1, m):
for j in range(1, n):
dp[i][j] = matrix[i][j] + min(dp[i-1][j], dp[i][j-1])
return dp[m-1][n-1]
# 示例
matrix = [
[1, 3, 1],
[1, 5, 1],
[4, 2, 1]]
print(minPathSum(matrix))
时间复杂度:O(m * n)
,需要遍历整个矩阵。空间复杂度:O(m * n)
,需要一个 m x n
的二维数组来存储中间结果。
程序运行结果:
# 输出 (路径为 1 → 3 → 1 → 1 → 1)
7
dp 数组填充过程大家自己尝试一下,不是很难。
3. 硬币找零问题
问题描述:
给定一个面值数组 coins
和一个目标金额 amount
,你需要找到凑成 amount
的最少硬币数量。如果无法凑成目标金额,返回 -1。
动态规划解法:
-
状态定义:
-
定义
dp[i]
为凑成金额i
所需要的最少硬币数量。
-
-
状态转移方程:
-
dp [
i
] = min(dp [i
],dp[i - coin] + 1
) for all coins coin ∈ coins -
其中
dp[i - coin] + 1
表示从金额i-coin
转移到金额i
时需要加上一个硬币coin
。
-
-
初始化:
-
dp[0] = 0
,表示凑成金额0
需要0
个硬币。 -
对于其他金额,初始化为
inf
,表示无法凑成。
-
-
边界条件:
-
如果目标金额无法通过给定的硬币组合凑成,返回
-1
。
-
例子:
coins = [1 2 5], 三种硬币,amoount = 11
程序如下:
def coinChange(coins, amount):
# 初始化 dp 数组,大小为 amount + 1,初始值为正无穷
dp = [float('inf')] * (amount + 1)
dp[0] = 0 # 凑成 0 元需要 0 个硬币
for i in range(1, amount + 1):
for coin in coins:
if i - coin >= 0:
dp[i] = min(dp[i], dp[i - coin] + 1)
# 如果 dp[amount] 仍然是无穷大,表示无法凑成目标金额
return dp[amount] if dp[amount] != float('inf') else -1
# 示例
coins = [1, 2, 5]
amount = 11
print(coinChange(coins, amount))
时间复杂度:O(n * m)
,其中 n
是 amount
,m
是硬币的种类数。我们需要计算从 1
到 amount
的每个金额,每次循环中我们遍历每个硬币。空间复杂度:O(n)
,我们只需要一个大小为 amount + 1
的数组来存储中间结果
程序运行结果:
# 输出 (11 = 5 + 5 + 1)
3
我们可以看到,找零钱问题其实是 0/1 背包问题的一个变种。dp 表格的话大家自己试一试看。
4. 全文总结
本文总结了三个动态规划问题的基本解法,最长公共子序列,矩阵最短路径,和找零钱问题。通过不停的练习,可以锻炼大家对于算法递推公式的推导能力,更精确的把握如何设置边界条件和起始条件。最后,希望大家能更好的理解动态规划这个算法。
参考文献: