动态规划(Dynamic Programming,简称 DP)
动态规划是一种优化技术,用来解决具有重叠子问题和最优子结构性质的问题。它通过将大问题分解成小的子问题,保存每个子问题的解来避免重复计算,从而降低计算复杂度。
基本思路:
- 问题分解:将大问题分解成子问题,子问题之间相互依赖,且有重叠的计算。
- 状态定义:明确子问题的解如何表示,一般定义一个数组或者表格来存储子问题的解。
- 状态转移方程:找到不同子问题之间的关系,用方程表达子问题之间的依赖关系。
- 边界条件:初始化子问题的基础解,即最小子问题的解。
动态规划的两种实现方式:
- 自顶向下(Top-Down):通过递归进行求解,通常结合记忆化搜索(Memoization)来存储已经计算过的子问题结果,避免重复计算。
- 自底向上(Bottom-Up):通过迭代的方式,从最小子问题开始逐步推导到最终问题的解。
二维动态规划(2D DP)
二维动态规划是动态规划的一种扩展,它用于求解那些涉及两个变量的最优子问题。例如,处理两维数据结构(如二维矩阵、网格、字符串对比等)的问题时,常常使用二维动态规划。
常见问题:
- 最长公共子序列(Longest Common Subsequence, LCS):给定两个字符串,求它们的最长公共子序列的长度。
- 背包问题:在二维背包问题中,一个背包不仅考虑物品的重量,还有容量。
- 编辑距离问题(Levenshtein Distance):计算两个字符串之间最少的编辑操作(插入、删除、替换)次数。
2D DP的基本思想:
在二维动态规划中,我们常常使用一个二维数组(dp[i][j])来存储每个子问题的解。例如,假设有两个字符串 word1 和 word2,我们用二维数组 dp[i][j] 表示 word1[0:i] 和 word2[0:j] 之间的编辑距离。每个子问题的解通过已有的子问题逐步推导出来。
2D DP 示例 - 编辑距离
假设我们有两个字符串 word1 和 word2,我们想找出它们的编辑距离(最少的编辑操作数:插入、删除、替换)。我们可以用一个二维 DP 数组 dp[i][j] 来表示 word1[0:i] 转换成 word2[0:j] 所需的最少操作数。
状态转移方程:
- 如果
word1[i-1] == word2[j-1]:不需要操作,dp[i][j] = dp[i-1][j-1]。 - 如果
word1[i-1] != word2[j-1]:我们可以进行插入、删除或替换,转移方程为:dp[i][j] = min(dp[i-1][j-1] + 1, dp[i-1][j] + 1, dp[i][j-1] + 1)
初始化条件:
dp[0][j] = j,表示将空字符串转换成word2[0:j]的操作数是j(即插入j个字符)。dp[i][0] = i,表示将word1[0:i]转换为空字符串的操作数是i(即删除i个字符)。
代码实现:
go
func minDistance(word1 string, word2 string) int {
m, n := len(word1), len(word2)
// 创建 dp 数组,dp[i][j] 表示 word1[0:i] 转换为 word2[0:j] 的最小操作数
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
// 初始化 dp 数组
for i := 0; i <= m; i++ {
dp[i][0] = i // 将 word1[0:i] 转换为空字符串的操作数为 i(删除操作)
}
for j := 0; j <= n; j++ {
dp[0][j] = j // 将空字符串转换为 word2[0:j] 的操作数为 j(插入操作)
}
// 填充 dp 数组
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
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] + 1, // 替换操作
dp[i-1][j] + 1, // 删除操作
dp[i][j-1] + 1, // 插入操作
)
}
}
}
return dp[m][n] // 返回将 word1 转换为 word2 的最小操作数
}
// 辅助函数:返回三个数中的最小值
func min(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}2D DP 示例 - 最长公共子序列
另一个常见的二维动态规划问题是求解 最长公共子序列。给定两个字符串 text1 和 text2,我们想要找出它们的最长公共子序列的长度。
状态转移方程:
- 如果
text1[i-1] == text2[j-1]:dp[i][j] = dp[i-1][j-1] + 1,因为我们找到了一个公共字符。 - 如果
text1[i-1] != text2[j-1]:dp[i][j] = max(dp[i-1][j], dp[i][j-1]),表示我们要么忽略text1[i-1],要么忽略text2[j-1]。
代码实现:
go
func longestCommonSubsequence(text1 string, text2 string) int {
m, n := len(text1), len(text2)
// 创建 dp 数组,dp[i][j] 表示 text1[0:i] 和 text2[0:j] 的最长公共子序列长度
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
// 填充 dp 数组
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if text1[i-1] == text2[j-1] {
dp[i][j] = dp[i-1][j-1] + 1 // 如果字符相同,最长公共子序列长度加1
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) // 否则取最大值
}
}
}
return dp[m][n] // 返回最长公共子序列的长度
}
// 辅助函数:返回两个数中的最大值
func max(a, b int) int {
if a > b {
return a
}
return b
}总结:
- 二维动态规划是处理涉及两维数据的优化问题的一种方法,常用于二维数组或矩阵问题(如编辑距离、最长公共子序列等)。
- 状态定义是解决问题的关键,需要清晰地定义出每个子问题的解如何表示。
- 状态转移方程通过分析子问题之间的关系,决定如何通过已有的解来得到当前问题的解。