动态规划与贪心策略的核心差异
在算法进阶的道路上,很多开发者容易混淆动态规划(Dynamic Programming)与贪心算法(Greedy Algorithm)。虽然两者都致力于通过分解子问题来求解全局最优解,但它们的决策逻辑有着本质区别。动态规划着眼于"全局",它在每一步决策时都会考虑之前的状态,通过保存子问题的解来避免重复计算,确保最终结果是真正的最优解;而贪心算法则着眼于"局部",它在每个阶段都做出当前看起来最好的选择,寄希望于局部最优能导向全局最优。理解这一差异,是解决中等难度算法题的关键。
01 背包问题:动态规划的填表艺术
01 背包问题是动态规划的经典入门案例。假设我们有一个容量为 4 磅的背包,面前有三件物品:吉他(重 1 磅,价值 1500)、音响(重 4 磅,价值 3000)和笔记本电脑(重 3 磅,价值 2000)。目标是在不超重的前提下,让背包内的物品总价值最大。
状态转移方程的推导
解决这个问题的核心在于定义状态。我们设 v[i][j] 表示在前 i 个物品中,能够装入容量为 j 的背包中的最大价值。对于第 i 个物品,我们只有两种选择:装或不装。
- 不装第 i 个物品 :此时最大价值等于前
i-1个物品在容量j下的最大价值,即v[i-1][j]。 - 装第 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。进入循环后,遍历所有 known 为 false 的节点,找出 dist 最小的节点 u,将其 known 置为 true。接着,遍历 u 的所有邻接点 v,如果通过 u 到达 v 的距离(u.dist + weight)小于 v 当前的 dist,则更新 v.dist 并记录 v.path = u。这个过程不断重复,直到所有节点都被标记为已知。
这种"每一步都选当前最近"的策略之所以有效,是因为在非负权图中,一旦某个节点被标记为已知,其最短路径就不可能再被其他未访问节点缩短。这正是贪心算法在无后效性问题中的威力所在。
算法选择的思考
面对复杂问题时,判断使用动态规划还是贪心算法,关键在于验证"局部最优是否能推导至全局最优"。如果问题具有最优子结构且无后效性,但局部选择会影响后续状态导致无法回退(如 01 背包),则必须使用动态规划进行全局统筹;如果每一步的局部最优解都能安全地构成全局最优解的一部分(如 Dijkstra 最短路径),那么贪心算法将以更低的时间复杂度提供优雅解法。掌握这两种思维模式,能让开发者在面对数据结构挑战时更加游刃有余。