一、动态规划的核心概念
动态规划(Dynamic Programming,DP)是一种用于求解优化问题的算法策略,其核心基于两个关键性质:最优子结构和重叠子问题。最优子结构指的是问题的最优解可以通过其子问题的最优解来构建,这意味着如果我们已经知道子问题的最优解,就能够通过某种方式组合这些解,得到原问题的最优解。重叠子问题则表明在求解问题的过程中,会重复地遇到相同的子问题。动态规划通过记录子问题的解,避免重复计算,从而提升算法效率。
动态规划通常采用两种实现方式:自顶向下(Top - Down)的备忘录(Memoization)方法和自底向上(Bottom - Up)的迭代方法。自顶向下方法使用递归求解问题,并将已解决的子问题的解存储起来,避免重复计算;自底向上方法则从最小的子问题开始,逐步求解更大的子问题,直到得到原问题的解。
二、动态规划的解题步骤
- 定义状态:明确问题的状态表示,通常用数组或二维数组来存储不同状态下的解。状态定义需要能够完整地描述问题的子问题,并且满足最优子结构。
- 推导状态转移方程:确定不同状态之间的关系,即如何通过已知状态的解推导出未知状态的解。状态转移方程是动态规划的核心,它体现了问题的求解逻辑。
- 确定初始条件:定义最小子问题的解,这些解是后续推导更大子问题解的基础。
- 计算顺序:根据状态转移方程确定计算状态的顺序,确保在计算某个状态时,其所依赖的状态已经被求解。
- 返回结果:根据问题的要求,从计算得到的状态中提取最终的解。
三、经典案例分析
3.1 斐波那契数列
斐波那契数列是一个经典的动态规划入门案例,其定义为:(F(n) = F(n - 1) + F(n - 2)),其中(F(0) = 0),(F(1) = 1)。
Python 实现(自底向上) :
def fibonacci_bottom_up(n):
if n == 0:
return 0
elif n == 1:
return 1
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
Java 实现(自底向上) :
public class Fibonacci {
public static int fibonacciBottomUp(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
int[] dp = new int[n + 1];
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
3.2 0 - 1 背包问题
0 - 1 背包问题描述为:给定(n)个物品,每个物品有重量(w_i)和价值(v_i),背包容量为(W),每个物品只能选择放入或不放入背包,求在不超过背包容量的情况下,能装入背包的物品的最大价值。
设(dp[i][j])表示考虑前(i)个物品,背包容量为(j)时的最大价值。状态转移方程为:( dp[i][j] = \begin{cases} dp[i - 1][j] & \text{if } w_i > j \ \max(dp[i - 1][j], dp[i - 1][j - w_i] + v_i) & \text{if } w_i \leq j \end{cases} )
Python 实现(自底向上) :
def knapsack_01(w, v, W):
n = len(w)
dp = [[0 for _ in range(W + 1)] for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, W + 1):
if w[i - 1] > j:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1])
return dp[n][W]
Java 实现(自底向上) :
public class Knapsack01 {
public static int knapsack01(int[] w, int[] v, int W) {
int n = w.length;
int[][] dp = new int[n + 1][W + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= W; j++) {
if (w[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
}
}
}
return dp[n][W];
}
}
3.3 最长公共子序列(Longest Common Subsequence, LCS)
最长公共子序列问题是指在两个给定序列中,找出一个最长的子序列,该子序列在两个原序列中都按顺序出现,但不一定是连续的。例如,序列"AGGTAB"和"GXTXAYB"的最长公共子序列是"GTAB"。
设dp[i][j]表示序列X的前i个元素和序列Y的前j个元素的最长公共子序列的长度。状态转移方程为:
Python 实现(自底向上) :
def longest_common_subsequence(X, Y):
m, n = len(X), len(Y)
dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
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])
return dp[m][n]
Java 实现(自底向上) :
public class LongestCommonSubsequence {
public static int longestCommonSubsequence(String X, String Y) {
int m = X.length();
int n = Y.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X.charAt(i - 1) == Y.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
}
最长公共子序列问题在生物信息学中用于 DNA 序列比对,通过比对 DNA 序列的相似性,能够帮助科学家推断物种之间的进化关系,了解基因的功能和疾病的遗传机制等。在文本处理领域,它可用于检测抄袭,通过比较两篇文章的最长公共子序列长度,判断它们的相似程度。
5.2 编辑距离(Edit Distance,又称 Levenshtein 距离)
编辑距离是指两个字符串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符、插入一个字符、删除一个字符。例如,将"kitten"转成"sitting"需要 3 次编辑操作:"sitten"(k→s)、"sittin"(e→i)、"sitting"(→g)。
设dp[i][j]表示字符串str1的前i个字符转换为字符串str2的前j个字符所需的最少编辑操作次数。状态转移方程为:( dp[i][j] = \begin{cases} i & \text{if } j = 0 \ j & \text{if } i = 0 \ \min( dp[i - 1][j] + 1, \ dp[i][j - 1] + 1, \ dp[i - 1][j - 1] + (str1[i - 1] == str2[j - 1]? 0 : 1) ) & \text{otherwise} \end{cases} )
Python 实现(自底向上) :
def edit_distance(str1, str2):
m, n = len(str1), len(str2)
dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if str1[i - 1] == str2[j - 1]:
cost = 0
else:
cost = 1
dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost)
return dp[m][n]
Java 实现(自底向上) :
public class EditDistance {
public static int editDistance(String str1, String str2) {
int m = str1.length();
int n = str2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
dp[i][0] = i;
}
for (int j = 0; j <= n; j++) {
dp[0][j] = j;
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
int cost = (str1.charAt(i - 1) == str2.charAt(j - 1)? 0 : 1);
dp[i][j] = Math.min(Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
}
}
return dp[m][n];
}
}
编辑距离在拼写检查中,当用户输入一个可能拼写错误的单词时,系统可以通过计算该单词与字典中所有单词的编辑距离,找出距离最小的单词作为正确的拼写建议。在语音识别领域,它用于衡量识别结果与真实文本之间的差异,评估识别系统的准确性。
5.3 矩阵链乘法(Matrix Chain Multiplication)
给定一系列矩阵,确定矩阵相乘的最优顺序,使得总的乘法运算次数最少。矩阵乘法的运算次数取决于矩阵的维度,例如,一个m×n的矩阵与一个n×p的矩阵相乘,需要进行m×n×p次标量乘法运算。
设dp[i][j]表示从第i个矩阵到第j个矩阵相乘的最少乘法运算次数。状态转移方程为:( dp[i][j] = \begin{cases} 0 & \text{if } i = j \ \min_{i \leq k < j} (dp[i][k] + dp[k + 1][j] + p_{i - 1}p_{k}p_{j}) & \text{if } i < j \end{cases} )
其中p数组存储每个矩阵的维度,p[i]表示第i + 1个矩阵的行数,p[i - 1]表示第i个矩阵的列数。
Python 实现(自底向上) :
def matrix_chain_order(p):
n = len(p) - 1
dp = [[0 for _ in range(n + 1)] for _ in range(n + 1)]
for L in range(2, n + 1):
for i in range(1, n - L + 2):
j = i + L - 1
dp[i][j] = float('inf')
for k in range(i, j):
q = dp[i][k] + dp[k + 1][j] + p[i - 1] * p[k] * p[j]
if q < dp[i][j]:
dp[i][j] = q
return dp[1][n]
Java 实现(自底向上) :
public class MatrixChainMultiplication {
public static int matrixChainOrder(int[] p) {
int n = p.length - 1;
int[][] dp = new int[n + 1][n + 1];
for (int L = 2; L <= n; L++) {
for (int i = 1; i <= n - L + 1; i++) {
int j = i + L - 1;
dp[i][j] = Integer.MAX_VALUE;
for (int k = i; k < j; k++) {
int q = dp[i][k] + dp[k + 1][j] + p[i - 1] * p[k] * p[j];
if (q < dp[i][j]) {
dp[i][j] = q;
}
}
}
}
return dp[1][n];
}
}
矩阵链乘法在数值计算、计算机图形学等领域有着广泛应用。在数值计算中,当需要进行大量矩阵运算时,合理安排矩阵乘法顺序可以显著减少计算量,提高计算效率。在计算机图形学中,矩阵变换用于描述物体的旋转、平移、缩放等操作,通过优化矩阵链乘法的顺序,可以加快图形渲染速度,提升用户体验。
四、动态规划的应用与拓展
动态规划在实际应用中有着广泛的场景,除了上述的斐波那契数列和 0 - 1 背包问题,还常用于求解最长公共子序列(LCS)、编辑距离、矩阵链乘法等问题。在实际编程中,我们可以根据问题的特点,灵活选择合适的状态定义和状态转移方程,运用动态规划高效地解决问题。
同时,动态规划还有一些优化技巧,如空间压缩,当状态转移只依赖于前几个状态时,可以通过滚动数组等方式减少空间复杂度。此外,对于一些复杂问题,可能需要结合其他算法策略,如贪心算法、图论算法等,来实现更优的解决方案。