动态规划与贪心策略,从背包问题到最短路径解析

动态规划与贪心策略的核心差异

在算法进阶的道路上,很多开发者容易混淆动态规划(Dynamic Programming)与贪心算法(Greedy Algorithm)。虽然两者都致力于通过分解子问题来求解全局最优解,但它们的决策逻辑有着本质区别。动态规划着眼于"全局",它在每一步决策时都会考虑之前的状态,通过保存子问题的解来避免重复计算,确保最终结果是真正的最优解;而贪心算法则着眼于"局部",它在每个阶段都做出当前看起来最好的选择,寄希望于局部最优能导向全局最优。理解这一差异,是解决中等难度算法题的关键。

01 背包问题:动态规划的填表艺术

01 背包问题是动态规划的经典入门案例。假设我们有一个容量为 4 磅的背包,面前有三件物品:吉他(重 1 磅,价值 1500)、音响(重 4 磅,价值 3000)和笔记本电脑(重 3 磅,价值 2000)。目标是在不超重的前提下,让背包内的物品总价值最大。

状态转移方程的推导

解决这个问题的核心在于定义状态。我们设 v[i][j] 表示在前 i 个物品中,能够装入容量为 j 的背包中的最大价值。对于第 i 个物品,我们只有两种选择:装或不装。

  1. 不装第 i 个物品 :此时最大价值等于前 i-1 个物品在容量 j 下的最大价值,即 v[i-1][j]
  2. 装第 i 个物品 :前提是当前容量 j 大于等于该物品的重量 w[i]。此时价值为该物品的价值 val[i] 加上剩余容量 j-w[i] 下前 i-1 个物品的最大价值,即 val[i] + v[i-1][j-w[i]]

综合这两种情况,状态转移方程应运而生: v[i][j] = max(v[i-1][j], val[i] + v[i-1][j-w[i]]) (当 j >= w[i] 时)

Java 实现与填表过程

在代码实现中,我们通常创建一个二维数组来模拟填表过程。下表展示了部分关键节点的推导逻辑:

java 复制代码
public class KnapsackProblem {
    public static void main(String[] args) {
        int[] w = {1, 4, 3}; // 物品重量
        int[] val = {1500, 3000, 2000}; // 物品价值
        int m = 4; // 背包容量
        int n = val.length; // 物品个数

        // v[i][j] 表示前 i 个物品放入容量为 j 的背包的最大价值
        int[][] v = new int[n + 1][m + 1];
        // path[i][j] 记录路径,1 表示放入了第 i 个物品
        int[][] path = new int[n + 1][m + 1];

        // 动态规划填表
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (w[i - 1] > j) {
                    // 当前物品重量超过背包容量,只能不装
                    v[i][j] = v[i - 1][j];
                } else {
                    // 比较装与不装的价值,取最大值
                    if (val[i - 1] + v[i - 1][j - w[i - 1]] > v[i - 1][j]) {
                        v[i][j] = val[i - 1] + v[i - 1][j - w[i - 1]];
                        path[i][j] = 1;
                    } else {
                        v[i][j] = v[i - 1][j];
                    }
                }
            }
        }
        
        System.out.println("最大价值为:" + v[n][m]);
    }
}

通过这种自底向上的填表方式,我们避免了递归带来的重复计算,时间复杂度稳定在 O(N*W),其中 N 是物品数量,W 是背包容量。这种方法完美体现了动态规划"空间换时间"以及利用"重叠子问题"特性的优势。

Dijkstra 算法:贪心策略在图论中的应用

如果说背包问题展示了动态规划的严谨,那么 Dijkstra 算法则是贪心策略的典范。该算法用于解决非负权有向图的单源最短路径问题。它的核心思想非常"贪婪":每次从未确定最短路径的节点中,选择一个距离起点最近的节点,将其标记为"已知",并利用该节点更新其邻接点的距离。

邻接表存储结构

在处理图论问题时,邻接表是一种高效的存储方式,尤其适合稀疏图。在 Java 中,我们可以自定义一个内部类 Vertex 来封装节点信息,包括邻接列表、是否已访问标记、当前最短距离以及路径前驱节点。

java 复制代码
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class DijkstraDemo {
    private static final int MAX_LEN = 1000; // 代表无穷大,不可达

    static class Vertex {
        List<Integer> adjacency; // 邻接表,存储边权
        boolean known;           // 标记是否已找到最短路径
        int dist;                // 距离起点的当前最短距离
        Vertex path;             // 路径上的前驱节点
        int vname;               // 节点编号

        public Vertex(int name) {
            this.vname = name;
            this.known = false;
            this.dist = MAX_LEN;
            this.adjacency = new ArrayList<>();
            this.path = null;
        }
    }

    private Vertex[] vertices;

    public DijkstraDemo(int size) {
        vertices = new Vertex[size];
        for (int i = 0; i < size; i++) {
            vertices[i] = new Vertex(i);
        }
    }

    // 初始化图数据(示例略,实际需添加边权)
    public void initGraph() {
        // 假设 V0 到 V1 权重为 2,V0 到 V3 权重为 1 等
        vertices[0].adjacency = new ArrayList<>(Arrays.asList(MAX_LEN, 2, MAX_LEN, 1, MAX_LEN, MAX_LEN, MAX_LEN));
        // ... 其他节点初始化
    }
}

内部类使用的注意事项

在上述代码中,Vertex 被定义为静态内部类(static class)。这是一个至关重要的细节。如果去掉 static 修饰符,Vertex 就变成了非静态内部类,它的实例化必须依赖于外部类 DijkstraDemo 的实例。而在 main 方法(静态上下文)中直接 new Vertex() 会导致编译错误,提示无法访问外部类实例。因此,在编写算法工具类时,若内部类不需要访问外部类的成员变量,务必加上 static,或者在实例化时使用 new OuterClass().new InnerClass() 的形式,前者显然更加简洁高效。

贪心执行流程

算法执行时,首先将起点距离设为 0。进入循环后,遍历所有 knownfalse 的节点,找出 dist 最小的节点 u,将其 known 置为 true。接着,遍历 u 的所有邻接点 v,如果通过 u 到达 v 的距离(u.dist + weight)小于 v 当前的 dist,则更新 v.dist 并记录 v.path = u。这个过程不断重复,直到所有节点都被标记为已知。

这种"每一步都选当前最近"的策略之所以有效,是因为在非负权图中,一旦某个节点被标记为已知,其最短路径就不可能再被其他未访问节点缩短。这正是贪心算法在无后效性问题中的威力所在。

算法选择的思考

面对复杂问题时,判断使用动态规划还是贪心算法,关键在于验证"局部最优是否能推导至全局最优"。如果问题具有最优子结构且无后效性,但局部选择会影响后续状态导致无法回退(如 01 背包),则必须使用动态规划进行全局统筹;如果每一步的局部最优解都能安全地构成全局最优解的一部分(如 Dijkstra 最短路径),那么贪心算法将以更低的时间复杂度提供优雅解法。掌握这两种思维模式,能让开发者在面对数据结构挑战时更加游刃有余。