动态规划常见问题及解决办法

一、啥是动态规划

动态规划是个挺实用的算法思想,用它能解决不少复杂问题。简单来说,动态规划就是把一个大问题拆成一堆小问题,然后先把小问题解决了,再根据小问题的答案得出大问题的答案。

举个例子,假如你要爬楼梯,一次能走 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 空间优化

对于一些动态规划问题,我们可以通过空间优化来减少内存的使用。比如在爬楼梯问题中,我们只需要保存前两个状态,就可以计算出当前状态,不需要使用一个数组来保存所有状态。

七、文章总结

动态规划是一种非常有用的算法思想,能解决很多复杂的问题。但是在使用动态规划的过程中,我们要注意避免状态定义错误和子问题重叠的问题。要深入理解问题的本质,合理定义状态,使用记忆化搜索或者迭代方法来避免子问题的重复计算。同时,我们要了解动态规划的应用场景、优缺点和注意事项,这样才能更好地使用动态规划来解决问题。