动态规划的三种方法:⏫⏬

动态规划是一种用于解决复杂问题的算法。它通过将一个大问题分解为多个小问题,并把小问题的解保存下来,从而避免重复计算。本文将使用 Java 语言来讲解动态规划的三种方法:自顶向下(⏫)自底向上(⏬)备忘录()。这些方法被广泛应用于很多问题中,比如背包问题、最短路径‍️问题和字符串匹配问题。我们会用多个例子来解释这些方法的原理、时间复杂度⏱️和空间复杂度。这些方法各具特点,适合于不同类型的问题,在实际应用中也各有优势和劣势,合理选择合适的方法可以大大提高算法的效率。

1. 自顶向下(⏫ 递归 + 备忘录 )

自顶向下方法是一种递归方法。从原问题开始,逐步分解为更小的问题,直到找到最简单的子问题。为了避免重复计算,我们使用备忘录来存储中间结果。这种方式可以加快速度,也更容易理解,尤其适合问题本身具有递归结构的场景。自顶向下方法的一个关键点在于其递归特性,它能够快速找到问题的解决路径,但如果递归深度过大,也可能会导致栈溢出。因此,备忘录是这个方法中不可缺少的部分,它确保每个子问题只被求解一次,从而显著减少了不必要的重复计算。

示例 1:斐波那契数列

问题描述:计算斐波那契数列的第 n 项。

斐波那契数列是经典的动态规划问题,定义为前两项是 0 和 1,从第三项开始每一项等于前两项之和。自顶向下的方式非常适合这个问题,因为它可以自然地通过递归来定义整个过程,同时使用备忘录来避免对相同子问题的重复求解。

import java.util.HashMap;

public class FibonacciTopDown {
    private HashMap<Integer, Integer> memo = new HashMap<>();

    public int fib(int n) {
        if (n <= 1) return n;
        return memo.computeIfAbsent(n, key -> fib(key - 1) + fib(key - 2));
    }

    public static void main(String[] args) {
        FibonacciTopDown fibCalculator = new FibonacciTopDown();
        System.out.println(fibCalculator.fib(10)); // 输出 55
    }
}

在这个实现中,我们通过递归求解斐波那契数列,并将已经计算过的值存储在 HashMap 中,避免了重复的计算过程。

数据变化表格
n结果
00
11
21
32
43
55
68
713
821
934
1055

这种方式的优点在于直观、易于理解,尤其在处理递归性问题时非常简洁。但如果没有备忘录,递归会导致大量的重复计算,时间复杂度会从 O(n) 增加到指数级。

示例 2:背包问题

问题描述:给定一些物品的重量和价值,还有一个背包的容量,找到放入背包的物品组合,使得总价值最大。

背包问题也是经典的动态规划问题。在自顶向下方法中,我们可以把问题逐步分解为包含更少物品和更小容量的子问题。通过备忘录保存每个子问题的解,我们可以显著减少重复计算,从而提高算法效率。

import java.util.HashMap;

public class KnapsackTopDown {
    private HashMap<String, Integer> memo = new HashMap<>();

    public int knapsack(int[] weights, int[] values, int capacity, int n) {
        if (n == 0 || capacity == 0) return 0;
        String key = n + "," + capacity;
        return memo.computeIfAbsent(key, k -> {
            if (weights[n - 1] > capacity) {
                return knapsack(weights, values, capacity, n - 1);
            } else {
                int include = values[n - 1] + knapsack(weights, values, capacity - weights[n - 1], n - 1);
                int exclude = knapsack(weights, values, capacity, n - 1);
                return Math.max(include, exclude);
            }
        });
    }

    public static void main(String[] args) {
        KnapsackTopDown solver = new KnapsackTopDown();
        int[] weights = {1, 2, 3, 8};
        int[] values = {10, 15, 40, 50};
        int capacity = 5;
        System.out.println(solver.knapsack(weights, values, capacity, weights.length)); // 输出 55
    }
}

在背包问题的自顶向下解决中,我们考虑每个物品是否放入背包,以及不同容量下的最优解,通过这种分解,问题被逐步解决。

数据变化表格
物品编号重量 ️价值 当前容量 最大价值
1110510
2215525
3340555
4850555

这种方式的优点是可以处理复杂的组合问题,通过递归实现分治,但递归深度较大时也容易导致性能问题。

示例 3:爬楼梯问题 ‍️

问题描述:你要爬 n 级台阶,每次可以爬 1 级或 2 级,问有多少种不同的方法可以到达楼顶。

这个问题也是一个典型的动态规划问题,可以通过递归解决并使用备忘录来保存中间结果。爬楼梯问题的递归定义非常简单,适合用自顶向下的方法进行解决。

import java.util.HashMap;

public class ClimbStairsTopDown {
    private HashMap<Integer, Integer> memo = new HashMap<>();

    public int climbStairs(int n) {
        if (n <= 2) return n;
        return memo.computeIfAbsent(n, key -> climbStairs(key - 1) + climbStairs(key - 2));
    }

    public static void main(String[] args) {
        ClimbStairsTopDown solver = new ClimbStairsTopDown();
        System.out.println(solver.climbStairs(5)); // 输出 8
    }
}
数据变化表格
n方法数
11
22
33
45
58

这种方法使得递归的层数减少,同时通过备忘录保存已经计算过的结果,避免了重复求解,极大提高了算法的效率。

原理

  • 使用递归来解决原问题,不断将其分解为更小的子问题。
  • 使用 HashMap 来存储每个子问题的结果,避免重复计算。
  • 递归加备忘录的结合使得算法既直观又高效,避免了指数级别的复杂度。

时间和空间复杂度

  • 时间复杂度 ⏱️:O(n),每个子问题只需要计算一次。
  • 空间复杂度 :O(n),需要存储中间结果和递归调用的栈空间。

2. 自底向上(⏬ 迭代)

自底向上是一种迭代的方法。它从最小的子问题开始,逐步构建到原问题的解。这种方法不需要递归调用,所以可以避免栈溢出问题。它通过一个数组来存储子问题的解,一步步推导出最终答案。相比于自顶向下,自底向上的方法更加适合于那些递归深度较大的问题,因为它可以有效地避免栈溢出的问题。

示例 1:斐波那契数列

问题描述:计算斐波那契数列的第 n 项。

通过自底向上的方式计算斐波那契数列的第 n 项,我们从最小的问题开始,一步一步向上计算,直到最终得到结果。这种方式通过迭代来替代递归,避免了递归栈溢出的问题。

public class FibonacciBottomUp {
    public int fib(int n) {
        if (n <= 1) return n;
        int prev1 = 0, prev2 = 1;
        for (int i = 2; i <= n; i++) {
            int current = prev1 + prev2;
            prev1 = prev2;
            prev2 = current;
        }
        return prev2;
    }

    public static void main(String[] args) {
        FibonacciBottomUp fibCalculator = new FibonacciBottomUp();
        System.out.println(fibCalculator.fib(10)); // 输出 55
    }
}
数据变化表格
iprev1prev2current
2011
3112
4123
5235
6358
75813
881321
9132134
10213455

这种方法的好处在于其空间复杂度较低,只需要常数的空间来存储当前值和前两个值。

示例 2:背包问题

问题描述:给定一些物品的重量和价值,还有一个背包的容量,找到放入背包的物品组合,使得总价值最大。

通过自底向上的方法,我们使用一个数组来记录每种容量下的最优解,从最小的容量开始,逐步向上计算到最终的目标容量。

public class KnapsackBottomUp {
    public int knapsack(int[] weights, int[] values, int capacity) {
        int n = weights.length;
        int[] dp = new int[capacity + 1];
        for (int i = 0; i < n; i++) {
            for (int w = capacity; w >= weights[i]; w--) {
                dp[w] = Math.max(dp[w], values[i] + dp[w - weights[i]]);
            }
        }
        return dp[capacity];
    }

    public static void main(String[] args) {
        KnapsackBottomUp solver = new KnapsackBottomUp();
        int[] weights = {1, 2, 3, 8};
        int[] values = {10, 15, 40, 50};
        int capacity = 5;
        System.out.println(solver.knapsack(weights, values, capacity)); // 输出 55
    }
}
数据变化表格
容量 dp[容量] (更新过程)
110
215
340
450
555

示例 3:爬楼梯问题 ‍️

问题描述:你要爬 n 级台阶,每次可以爬 1 级或 2 级,问有多少种不同的方法可以到达楼顶。

爬楼梯问题在自底向上的方法中,从最小的台阶数开始,逐步构建到最终的结果。

public class ClimbStairsBottomUp {
    public int climbStairs(int n) {
        if (n <= 2) return n;
        int prev1 = 1, prev2 = 2;
        for (int i = 3; i <= n; i++) {
            int current = prev1 + prev2;
            prev1 = prev2;
            prev2 = current;
        }
        return prev2;
    }

    public static void main(String[] args) {
        ClimbStairsBottomUp solver = new ClimbStairsBottomUp();
        System.out.println(solver.climbStairs(5)); // 输出 8
    }
}
数据变化表格
iprev1prev2current
3123
4235
5358

原理

  • 从最小的子问题开始计算,逐步向上构建到原问题。
  • 不使用递归,避免了递归带来的开销。
  • 自底向上的方法适用于递归深度较大可能导致栈溢出的情况。

时间和空间复杂度

  • 时间复杂度 ⏱️:O(n),需要计算每个子问题。
  • 空间复杂度 :O(1),只需要常数个变量来存储结果。

3. 备忘录(Memoization )

备忘录方法和自顶向下类似,通过递归解决问题,但在计算过程中用一个缓存来存储中间结果。这样可以减少重复计算,提高效率。备忘录的方法结合了递归的直观性和缓存技术的高效性,是解决很多复杂问题的有效手段。

示例:最小路径和 ️

问题描述:给定一个 m x n 的网格,每个位置有一个非负整数,找到从左上角到右下角的最小路径和。

import java.util.Arrays;

public class MinPathSumMemo {
    private int[][] memo;

    public int minPathSum(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        memo = new int[m][n];
        for (int[] row : memo) {
            Arrays.fill(row, -1);
        }
        return dfs(grid, m - 1, n - 1);
    }

    private int dfs(int[][] grid, int i, int j) {
        if (i < 0 || j < 0) return Integer.MAX_VALUE;
        if (i == 0 && j == 0) return grid[0][0];
        if (memo[i][j] != -1) return memo[i][j];
        int left = dfs(grid, i, j - 1);
        int up = dfs(grid, i - 1, j);
        memo[i][j] = grid[i][j] + Math.min(left, up);
        return memo[i][j];
    }

    public static void main(String[] args) {
        MinPathSumMemo solver = new MinPathSumMemo();
        int[][] grid = {{1, 3, 1}, {1, 5, 1}, {4, 2, 1}};
        System.out.println(solver.minPathSum(grid)); // 输出 7
    }
}
数据变化表格
i \ j012
0145
1276
2687

原理

  • 使用递归来查找每个子问题的解,同时用数组记录每个位置的最小路径和。
  • 避免对已经计算过的子问题重复求解。
  • 结合了递归的易理解性和缓存技术的高效性。

时间和空间复杂度

  • 时间复杂度 ⏱️:O(m * n),每个位置只需要计算一次。
  • 空间复杂度 :O(m * n),用于存储每个位置的结果。

总结

方法实现方式时间复杂度 ⏱️空间复杂度 适用场景
自顶向下递归 + 备忘录O(n)O(n)递归易于理解的问题
自底向上迭代 + 数组O(n)O(1)递归深度过大时适用
自底向上优化迭代 + 常量存储O(n)O(1)需要优化空间时适用
备忘录缓存递归中间结果O(m * n)O(m * n)多维问题,减少重复计算

每种方法都有自己的优缺点。自顶向下的方法适合递归容易表达的问题,而自底向上适合需要避免深递归的问题。根据具体情况选择合适的方法,能够让问题的解决更加高效。掌握这三种动态规划的方法,并灵活使用它们,是解决复杂问题的重要技能。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部