动态规划常见问题及解决办法
一、啥是动态规划
动态规划是个挺实用的算法思想,用它能解决不少复杂问题。简单来说,动态规划就是把一个大问题拆成一堆小问题,然后先把小问题解决了,再根据小问题的答案得出大问题的答案。
举个例子,假如你要爬楼梯,一次能走 1 步或者 2 步,问爬到第 n 级台阶有多少种走法。这就是个能用动态规划解决的问题。咱们可以把爬到第 n 级台阶的走法,拆分成爬到第 n - 1 级台阶的走法加上爬到第 n - 2 级台阶的走法。
下面用 Java 代码来实现这个爬楼梯问题:
// Java 技术栈
public class ClimbingStairs {
public static int climbStairs(int n) {
if (n <= 2) {
return n;
}
// 初始化前两个状态
int first = 1;
int second = 2;
for (int i = 3; i <= n; i++) {
// 计算当前状态
int third = first + second;
first = second;
second = third;
}
return second;
}
public static void main(String[] args) {
int n = 5;
System.out.println("爬到第 " + n + " 级台阶的走法有 " + climbStairs(n) + " 种。");
}
}
在这个代码里,我们先处理了 n 小于等于 2 的情况,然后通过循环不断更新状态,最终得到爬到第 n 级台阶的走法数量。
二、状态定义错误的问题
2.1 状态定义错误的表现
状态定义错误是动态规划里常见的误区之一。简单来说,就是你对问题状态的理解出了偏差,导致状态定义得不对。这样一来,后续的状态转移方程肯定也不对,最终得到的结果自然也是错的。
举个例子,还是爬楼梯问题。假如你错误地把状态定义成“第 i 步走到第 j 级台阶的走法”,这就把问题复杂化了,而且会让状态转移变得很混乱。
2.2 如何避免状态定义错误
要避免状态定义错误,关键是要深入理解问题的本质。你得弄清楚问题的输入、输出,以及问题的变化规律。然后根据这些来合理地定义状态。
还是以爬楼梯问题为例,我们发现爬到第 n 级台阶的走法只和爬到第 n - 1 级台阶和第 n - 2 级台阶的走法有关。所以我们可以把状态定义为“爬到第 i 级台阶的走法数量”,这样就很清晰了。
下面是一个错误状态定义的反面例子,用 Java 代码表示:
// Java 技术栈
public class WrongClimbingStairs {
public static int wrongClimbStairs(int n) {
// 错误的状态定义,把问题复杂化
int[][] dp = new int[n + 1][n + 1];
dp[1][1] = 1;
dp[2][1] = 1;
dp[2][2] = 1;
for (int i = 3; i <= n; i++) {
for (int j = 1; j <= i; j++) {
// 错误的状态转移方程
dp[i][j] = dp[i - 1][j - 1] + dp[i - 2][j - 2];
}
}
int result = 0;
for (int j = 1; j <= n; j++) {
result += dp[n][j];
}
return result;
}
public static void main(String[] args) {
int n = 5;
System.out.println("错误状态定义下爬到第 " + n + " 级台阶的走法有 " + wrongClimbStairs(n) + " 种。");
}
}
这个代码里,状态定义得很复杂,而且状态转移方程也不对,最终得到的结果可能是错误的。
三、子问题重叠的问题
3.1 子问题重叠的表现
子问题重叠也是动态规划里常见的问题。就是在解决大问题的过程中,会多次重复计算相同的小问题。这样会浪费很多时间和资源,导致算法效率低下。
还是以爬楼梯问题为例,假如我们用递归的方法来解决,就会出现子问题重叠的情况。比如计算爬到第 5 级台阶的走法,会多次计算爬到第 3 级台阶和第 2 级台阶的走法。
3.2 如何避免子问题重叠
要避免子问题重叠,我们可以使用记忆化搜索或者动态规划的迭代方法。记忆化搜索就是在递归的过程中,把已经计算过的子问题的结果保存起来,下次再遇到相同的子问题时,直接使用保存的结果,而不用重新计算。
下面是使用记忆化搜索解决爬楼梯问题的 Java 代码:
// Java 技术栈
import java.util.Arrays;
public class MemoizedClimbingStairs {
public static int climbStairs(int n) {
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
return helper(n, memo);
}
private static int helper(int n, int[] memo) {
if (n <= 2) {
return n;
}
if (memo[n] != -1) {
return memo[n];
}
memo[n] = helper(n - 1, memo) + helper(n - 2, memo);
return memo[n];
}
public static void main(String[] args) {
int n = 5;
System.out.println("使用记忆化搜索爬到第 " + n + " 级台阶的走法有 " + climbStairs(n) + " 种。");
}
}
在这个代码里,我们使用了一个数组 memo 来保存已经计算过的子问题的结果。每次递归调用时,先检查 memo 数组里是否已经有了结果,如果有就直接返回,没有就计算并保存结果。
四、动态规划的应用场景
4.1 背包问题
背包问题是动态规划的经典应用场景之一。假如你有一个容量为 W 的背包,有 n 个物品,每个物品有自己的重量和价值。你要在不超过背包容量的前提下,选择一些物品,使得背包里物品的总价值最大。
下面是使用动态规划解决 0 - 1 背包问题的 Java 代码:
// Java 技术栈
public class KnapsackProblem {
public static int knapsack(int[] weights, int[] values, int capacity) {
int n = weights.length;
int[][] dp = new int[n + 1][capacity + 1];
for (int i = 1; i <= n; i++) {
for (int w = 1; w <= capacity; w++) {
if (weights[i - 1] <= w) {
dp[i][w] = Math.max(values[i - 1] + dp[i - 1][w - weights[i - 1]], dp[i - 1][w]);
} else {
dp[i][w] = dp[i - 1][w];
}
}
}
return dp[n][capacity];
}
public static void main(String[] args) {
int[] weights = {2, 3, 4, 5};
int[] values = {3, 4, 5, 6};
int capacity = 8;
System.out.println("背包能装的最大价值是 " + knapsack(weights, values, capacity));
}
}
在这个代码里,我们使用了一个二维数组 dp 来保存状态,通过状态转移方程来计算最大价值。
4.2 最长公共子序列问题
最长公共子序列问题也是动态规划的常见应用场景。给定两个字符串,求它们的最长公共子序列的长度。
下面是使用动态规划解决最长公共子序列问题的 Java 代码:
// Java 技术栈
public class LongestCommonSubsequence {
public static int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.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];
}
public static void main(String[] args) {
String text1 = "abcde";
String text2 = "ace";
System.out.println("最长公共子序列的长度是 " + longestCommonSubsequence(text1, text2));
}
}
在这个代码里,我们使用了一个二维数组 dp 来保存状态,通过状态转移方程来计算最长公共子序列的长度。
五、动态规划的优缺点
5.1 优点
动态规划的优点很明显。首先,它能把复杂的大问题拆成小问题,让问题变得更容易解决。其次,通过避免子问题的重复计算,能提高算法的效率。最后,动态规划的代码实现相对简单,容易理解。
5.2 缺点
动态规划也有一些缺点。比如,它需要额外的空间来保存状态,对于一些大规模的问题,可能会占用大量的内存。另外,状态定义和状态转移方程的设计比较困难,需要对问题有深入的理解。
六、注意事项
6.1 状态定义要合理
前面已经说过,状态定义是动态规划的关键。要根据问题的本质来合理定义状态,避免状态定义错误。
6.2 处理边界条件
在动态规划中,边界条件的处理很重要。比如在爬楼梯问题中,当 n 小于等于 2 时,需要特殊处理。如果边界条件处理不好,可能会导致结果错误。
6.3 空间优化
对于一些动态规划问题,我们可以通过空间优化来减少内存的使用。比如在爬楼梯问题中,我们只需要保存前两个状态,就可以计算出当前状态,不需要使用一个数组来保存所有状态。
七、文章总结
动态规划是一种非常有用的算法思想,能解决很多复杂的问题。但是在使用动态规划的过程中,我们要注意避免状态定义错误和子问题重叠的问题。要深入理解问题的本质,合理定义状态,使用记忆化搜索或者迭代方法来避免子问题的重复计算。同时,我们要了解动态规划的应用场景、优缺点和注意事项,这样才能更好地使用动态规划来解决问题。
Comments