在寻宝游戏的地图中,各个地点之间由不同长度的道路相连,每条道路可能有不同的通行代价(如距离、时间或风险值)。玩家需要从起点出发,以最小的总代价到达宝藏所在的位置。这个问题在图论中被称为单源最短路径问题,而Dijkstra算法正是解决此类问题的经典方法。本文将用Java实现Dijkstra算法,从基础版本到优先队列优化版本逐步深入,并探讨其在实际游戏场景中的应用。
一、问题建模:将寻宝地图抽象为带权图
我们将游戏地图抽象为一个带权有向图(Weighted Directed Graph),其中节点代表地点,边的权重代表通行代价。
1.1 核心数据结构
import java.util.*;
/**
* 图的边:表示两个地点之间的通路及代价
*/
public class Edge {
public final int to; // 目标节点编号
public final int weight; // 边的权重(距离、时间等)
public Edge(int to, int weight) {
this.to = to;
this.weight = weight;
}
@Override
public String toString() {
return String.format("Edge(to=%d, w=%d)", to, weight);
}
}
/**
* 带权有向图,使用邻接表存储
* 适合稀疏图,空间复杂度 O(V + E)
*/
public class WeightedGraph {
private final int vertexCount; // 顶点数
private final List<List<Edge>> adjList; // 邻接表
public WeightedGraph(int vertexCount) {
this.vertexCount = vertexCount;
this.adjList = new ArrayList<>(vertexCount);
for (int i = 0; i < vertexCount; i++) {
adjList.add(new ArrayList<>());
}
}
/**
* 添加有向边
* @param from 起点
* @param to 终点
* @param weight 权重
*/
public void addEdge(int from, int to, int weight) {
adjList.get(from).add(new Edge(to, weight));
}
/**
* 添加无向边(双向添加)
*/
public void addUndirectedEdge(int u, int v, int weight) {
addEdge(u, v, weight);
addEdge(v, u, weight);
}
public List<Edge> getNeighbors(int vertex) {
return adjList.get(vertex);
}
public int getVertexCount() {
return vertexCount;
}
}
1.2 寻宝地图示例
/**
* 构建一个示例寻宝地图
* 节点0: 起点村庄
* 节点1: 森林入口
* 节点2: 河流渡口
* 节点3: 古老神庙
* 节点4: 龙之巢穴(宝藏所在地)
* 节点5: 隐秘捷径
*/
public static WeightedGraph createTreasureMap() {
WeightedGraph graph = new WeightedGraph(6);
// 起点到各区域
graph.addUndirectedEdge(0, 1, 4); // 村庄 -> 森林 (距离4)
graph.addUndirectedEdge(0, 2, 2); // 村庄 -> 河流 (距离2)
graph.addUndirectedEdge(1, 2, 1); // 森林 -> 河流 (距离1)
graph.addUndirectedEdge(1, 3, 5); // 森林 -> 神庙 (距离5)
graph.addUndirectedEdge(2, 3, 8); // 河流 -> 神庙 (距离8)
graph.addUndirectedEdge(2, 4, 10); // 河流 -> 龙巢 (距离10)
graph.addUndirectedEdge(3, 4, 2); // 神庙 -> 龙巢 (距离2)
graph.addUndirectedEdge(3, 5, 3); // 神庙 -> 捷径 (距离3)
graph.addUndirectedEdge(2, 5, 6); // 河流 -> 捷径 (距离6)
graph.addUndirectedEdge(4, 5, 7); // 龙巢 -> 捷径 (距离7)
return graph;
}
二、Dijkstra算法原理:贪心策略与松弛操作
Dijkstra算法的核心思想是贪心策略:每次从未确定最短路径的节点中,选择距离起点最近的一个,然后利用它去”松弛”(更新)其邻接节点的距离估计值。
2.1 算法步骤
- 初始化:起点距离设为0,其余所有节点距离设为无穷大。
- 选择最小距离节点:在未访问节点中,选择距离起点最近的节点
u。 - 标记访问:将
u标记为已确定最短路径。 - 松弛操作:遍历
u的所有邻接节点v,如果dist[u] + weight(u,v) < dist[v],则更新dist[v]。 - 重复步骤2-4,直到所有节点都被访问或目标节点被确定。
2.2 为什么Dijkstra算法有效
Dijkstra算法成立的前提是所有边的权重非负。由于每次选择的都是当前距离最小的未确定节点,且边权非负,不存在通过其他未确定节点使该节点距离更小的可能。这保证了贪心选择的正确性。
三、基础实现:数组版Dijkstra
使用数组维护距离和访问状态,时间复杂度为 O(V²),适合稠密图或节点数较少的场景。
import java.util.*;
/**
* 基础版Dijkstra算法(数组实现)
* 时间复杂度: O(V²)
* 空间复杂度: O(V)
*/
public class DijkstraArray {
/**
* 计算从起点source到所有节点的最短距离
* @return dist数组,dist[i]表示source到i的最短距离
*/
public static int[] shortestPath(WeightedGraph graph, int source) {
int n = graph.getVertexCount();
int[] dist = new int[n];
boolean[] visited = new boolean[n];
int[] parent = new int[n]; // 记录前驱节点,用于重建路径
// 初始化:距离设为无穷大(用Integer.MAX_VALUE / 2避免溢出)
Arrays.fill(dist, Integer.MAX_VALUE / 2);
Arrays.fill(parent, -1);
dist[source] = 0;
// 进行n轮迭代,每轮确定一个节点的最短距离
for (int i = 0; i < n; i++) {
// 步骤1:在未访问节点中选择距离最小的
int u = -1;
int minDist = Integer.MAX_VALUE / 2;
for (int j = 0; j < n; j++) {
if (!visited[j] && dist[j] < minDist) {
minDist = dist[j];
u = j;
}
}
// 如果没有可达的未访问节点,提前结束
if (u == -1) break;
visited[u] = true;
// 步骤2:松弛u的所有邻接节点
for (Edge edge : graph.getNeighbors(u)) {
int v = edge.to;
int weight = edge.weight;
if (!visited[v] && dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
parent[v] = u; // 记录v的前驱是u
}
}
}
return dist;
}
/**
* 计算最短路径,同时返回前驱数组用于路径重建
*/
public static Result compute(WeightedGraph graph, int source) {
int n = graph.getVertexCount();
int[] dist = new int[n];
boolean[] visited = new boolean[n];
int[] parent = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE / 2);
Arrays.fill(parent, -1);
dist[source] = 0;
for (int i = 0; i < n; i++) {
int u = -1;
int minDist = Integer.MAX_VALUE / 2;
for (int j = 0; j < n; j++) {
if (!visited[j] && dist[j] < minDist) {
minDist = dist[j];
u = j;
}
}
if (u == -1) break;
visited[u] = true;
for (Edge edge : graph.getNeighbors(u)) {
int v = edge.to;
if (!visited[v] && dist[u] + edge.weight < dist[v]) {
dist[v] = dist[u] + edge.weight;
parent[v] = u;
}
}
}
return new Result(dist, parent);
}
public record Result(int[] dist, int[] parent) {}
/**
* 根据前驱数组重建从source到target的最短路径
*/
public static List<Integer> reconstructPath(int[] parent, int source, int target) {
LinkedList<Integer> path = new LinkedList<>();
int cur = target;
while (cur != -1) {
path.addFirst(cur);
if (cur == source) break;
cur = parent[cur];
}
// 如果起点无法到达终点
if (path.getFirst() != source) {
return Collections.emptyList();
}
return path;
}
}
3.1 基础版测试
public class Main {
public static void main(String[] args) {
WeightedGraph graph = createTreasureMap();
int source = 0; // 起点:村庄
int target = 4; // 终点:龙巢
DijkstraArray.Result result = DijkstraArray.compute(graph, source);
int[] dist = result.dist();
int[] parent = result.parent();
System.out.println("=== 基础版Dijkstra结果 ===");
System.out.println("从村庄(0)到各点的最短距离:");
String[] names = {"村庄", "森林", "河流", "神庙", "龙巢", "捷径"};
for (int i = 0; i < dist.length; i++) {
System.out.printf(" 到%s(%d): %d%n", names[i], i,
dist[i] == Integer.MAX_VALUE / 2 ? -1 : dist[i]);
}
List<Integer> path = DijkstraArray.reconstructPath(parent, source, target);
System.out.println("\n最短路径: " + path);
System.out.println("路径详情: " + path.stream()
.map(i -> names[i] + "(" + i + ")")
.reduce((a, b) -> a + " -> " + b).orElse(""));
System.out.println("总代价: " + dist[target]);
}
}
输出结果:
从村庄(0)到各点的最短距离:
到村庄(0): 0
到森林(1): 3
到河流(2): 2
到神庙(3): 8
到龙巢(4): 10
到捷径(5): 11
最短路径: [0, 2, 1, 3, 4]
路径详情: 村庄(0) -> 河流(2) -> 森林(1) -> 神庙(3) -> 龙巢(4)
总代价: 10
四、优先队列优化:堆实现Dijkstra
基础版每次都要线性扫描所有未访问节点来找出最小值,效率较低。当使用优先队列(最小堆)维护未确定节点时,可以将时间复杂度优化至 O(E log V),这在稀疏图中是巨大的提升。
import java.util.*;
/**
* 优先队列优化版Dijkstra算法
* 时间复杂度: O(E log V)
* 空间复杂度: O(V)
*/
public class DijkstraHeap {
/**
* 优先队列中的节点,按当前距离排序
*/
private record PQNode(int vertex, int dist) implements Comparable<PQNode> {
@Override
public int compareTo(PQNode other) {
return Integer.compare(this.dist, other.dist);
}
}
public static DijkstraArray.Result compute(WeightedGraph graph, int source) {
int n = graph.getVertexCount();
int[] dist = new int[n];
boolean[] visited = new boolean[n];
int[] parent = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE / 2);
Arrays.fill(parent, -1);
dist[source] = 0;
// 最小堆,按距离排序
PriorityQueue<PQNode> pq = new PriorityQueue<>();
pq.offer(new PQNode(source, 0));
while (!pq.isEmpty()) {
PQNode current = pq.poll();
int u = current.vertex;
// 如果该节点已经被处理过,跳过(处理过期条目)
if (visited[u]) continue;
// 如果堆中距离已经大于当前已知最短距离,跳过
if (current.dist > dist[u]) continue;
visited[u] = true;
// 松弛邻接节点
for (Edge edge : graph.getNeighbors(u)) {
int v = edge.to;
int newDist = dist[u] + edge.weight;
if (!visited[v] && newDist < dist[v]) {
dist[v] = newDist;
parent[v] = u;
pq.offer(new PQNode(v, newDist));
}
}
}
return new DijkstraArray.Result(dist, parent);
}
/**
* 优化版:只计算到目标节点的最短路径,提前终止
* 当只需要单目标最短路径时,目标确定后即可结束
*/
public static DijkstraArray.Result computeToTarget(
WeightedGraph graph, int source, int target) {
int n = graph.getVertexCount();
int[] dist = new int[n];
boolean[] visited = new boolean[n];
int[] parent = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE / 2);
Arrays.fill(parent, -1);
dist[source] = 0;
PriorityQueue<PQNode> pq = new PriorityQueue<>();
pq.offer(new PQNode(source, 0));
while (!pq.isEmpty()) {
PQNode current = pq.poll();
int u = current.vertex;
if (visited[u]) continue;
if (current.dist > dist[u]) continue;
visited[u] = true;
// 提前终止:目标节点已确定最短距离
if (u == target) {
break;
}
for (Edge edge : graph.getNeighbors(u)) {
int v = edge.to;
int newDist = dist[u] + edge.weight;
if (!visited[v] && newDist < dist[v]) {
dist[v] = newDist;
parent[v] = u;
pq.offer(new PQNode(v, newDist));
}
}
}
return new DijkstraArray.Result(dist, parent);
}
}
五、复杂度对比与适用场景
| 实现方式 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 数组版 | O(V²) | O(V) | 稠密图(E ≈ V²)、节点数较少 |
| 优先队列版 | O(E log V) | O(V) | 稀疏图(E << V²)、大规模图 |
| 优先队列+提前终止 | O(E’ log V) | O(V) | 只需要单目标最短路径 |
在寻宝游戏中,地图通常是稀疏图(每个地点只与少数相邻地点连通),因此优先队列版本是最佳选择。
六、游戏扩展:带障碍的动态地图
实际游戏中,地图可能动态变化(如桥梁被摧毁、新道路开通)。Dijkstra算法可以配合增量更新或定期重新计算来适应动态环境。
/**
* 动态路径规划器:支持边权变更后快速重新计算
*/
public class DynamicPathPlanner {
private final WeightedGraph graph;
private int[] currentDist;
private int[] currentParent;
private final int source;
public DynamicPathPlanner(WeightedGraph graph, int source) {
this.graph = graph;
this.source = source;
recompute();
}
/**
* 重新计算最短路径
*/
public void recompute() {
DijkstraArray.Result result = DijkstraHeap.compute(graph, source);
this.currentDist = result.dist();
this.currentParent = result.parent();
}
/**
* 获取到目标点的当前最短路径
*/
public List<Integer> getPathTo(int target) {
return DijkstraArray.reconstructPath(currentParent, source, target);
}
/**
* 获取到目标点的当前距离
*/
public int getDistanceTo(int target) {
return currentDist[target];
}
/**
* 模拟地图变化:某条道路通行代价改变
*/
public void onEdgeChanged(int from, int to, int newWeight) {
// 简化的处理:直接重新计算
// 更高效的方案可以使用动态Dijkstra或A*增量算法
System.out.printf("地图更新: %d -> %d 的权重变为 %d%n", from, to, newWeight);
recompute();
}
}
七、总结
本文从寻宝游戏场景出发,完整讲解了Dijkstra算法的原理与Java实现:
- 数组版(O(V²))适合小规模稠密图,代码直观易于理解。
- 优先队列版(O(E log V))是稀疏图的标准选择,配合提前终止可进一步优化单目标查询。
- 通过前驱数组可以方便地重建完整的最短路径,而非仅得到距离值。
- 算法成立的前提是边权非负,若存在负权边需要使用Bellman-Ford算法。
Dijkstra算法不仅是图论的基石,也是游戏开发中NPC寻路、网络路由、物流规划等领域的核心工具。掌握它的实现细节,对理解更复杂的A*算法(在Dijkstra基础上加入启发函数)也大有裨益。