图经典算法
1 最小生成树-普利姆(Prim)算法
基本概念
什么是最小生成树(Minimum Spanning Tree, MST)?最小生成树就是连通加权无向图权值总和最小的生成树,包含所有的顶点,并且没有环。Prim算法是一种贪心算法,通过逐步扩建子树来构造MST,适用于稠密图。
算法思想
从任意顶点出发,每次选择链接已经访问过的顶点集U
和未访问过的顶点集合V
最小的权边,并且将已经新的访问过的顶点加入到顶点集合U
中,直到所有的顶点集合被访问。
可以看出,该算法的每一步操作,都保证了子问题的最优解,从而确保全局的最优解。
算法步骤
- 初始化
- 选择起始顶点,将其加入到已经访问过的集合
U
- 维护一个距离数组
distance[]
,记录每个顶点到U
的最小边权,初始时除了顶点之外设置为无穷大
- 迭代扩展
- 从
distance[]
数组中选择未访问过并且距离最小的的顶点v
- 将
v
加入到集合U
,累加其边权到总权值 - 遍历
v
的邻接顶点 <math xmlns="http://www.w3.org/1998/Math/MathML"> u u </math>u,若 <math xmlns="http://www.w3.org/1998/Math/MathML"> u ∉ U u \notin U </math>u∈/U并且边权小于dist[v]
,则更新dist[v]
- 终止条件
- 所有的顶点均被访问过
代码实现
java
public class T0PrimAlgorithm {
/**
* 距离无穷大,表示不可达
*/
private static final int INF = Integer.MAX_VALUE;
public static void primMST(Graph graph) {
// 记录每个顶点的父节点
int[] parents = new int[graph.vertexCounts + 1];
// 记录每个顶点到生成树的最小权值
int[] minDistance = new int[graph.vertexCounts + 1];
// 记录当前节点是否已经被访问过
boolean[] visited = new boolean[graph.vertexCounts + 1];
// 初始化,所有的距离都是INF,选择顶点 1 作为起始节点,将其distance置为 0
Arrays.fill(minDistance, INF);
minDistance[1] = 0;
minDistance[0] = 0;
visited[1] = true;
// 遍历未访问过的节点集合 U 和访问过的 V 找到他们的最短距离顶点 u
for (int i = 2; i <= graph.vertexCounts; i++) {
if (!visited[i]) {
// 遍历当前未访问过的顶点 i 到已经访问过的顶点 j 之间的距离
for (int j = 1; j <= graph.vertexCounts; j++) {
if (graph.matrix[i][j] != 0 && visited[j] && graph.matrix[i][j] < minDistance[i]) {
minDistance[i] = graph.matrix[i][j];
parents[i] = j;
}
}
// 将当前的节点标记为已经访问
visited[i] = true;
}
}
System.out.println(Arrays.stream(minDistance).sum());
System.out.println(Arrays.toString(parents));
}
}
/**
* 邻接矩阵图类
*/
class Graph {
/**
* 顶点集合
*/
int[] vertices;
/**
* 邻接矩阵
*/
int[][] matrix;
/**
* 顶点数量
*/
int vertexCounts;
public Graph(int vertexCounts, int[][] matrix) {
this.vertexCounts = vertexCounts;
this.matrix = matrix;
this.vertices = new int[vertexCounts + 1];
}
}
2 Kruskal最小生成树算法
Kruskal算法是一种基于贪心策略的最小生成树算法,适用于链接无向图。其核心思想就是通过选择权值最小的边并且不构成环路,逐步构建最小生成树。
算法步骤
- 边排序
- 将所有的边按照权值大小进行排序,时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( E log E ) O(E \log E) </math>O(ElogE),其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> E E </math>E为边的个数。
- 初始化并查集
- 使用并查集
UnionFind
来管理顶点连通性,每个顶点初始时各自称为一个集合
- 对每一条边进行处理
- 对排序之后的边进行顺序遍历,通过并查集辅助检测边的连通性,如果两个顶点输入不同的连通分量,则将该边加入生成树,并合并两个集合,否则跳过
- 终止条件
- 当生成树包含
V - 1
条边的时候,算法结束
算法实现
java
public class T1KruskalAlgorithm {
/**
* 算法实现
* @param vertexCounts
* @param edges
* @return
*/
public static List<Edge> KruskalMST(int vertexCounts, List<Edge> edges) {
// 首先对边进行排序
List<Edge> sortedEdges = edges.stream().sorted().collect(Collectors.toList());
// 构建并查集
UnionFind unionFind = new UnionFind(vertexCounts);
// 最小生成树
List<Edge> result = new ArrayList<>(vertexCounts - 1);
// 对边进行遍历,并加入到并查集
for (Edge edge : sortedEdges) {
if (!unionFind.isConnected(edge.source, edge.target)) {
unionFind.union(edge.source, edge.target);
result.add(edge);
}
}
result.forEach(System.out::println);
return result;
}
public static void main(String[] args) {
List<Edge> edges = new ArrayList<>();
edges.add(new Edge(0, 1, 10));
edges.add(new Edge(0, 2, 6));
edges.add(new Edge(0, 3, 5));
edges.add(new Edge(1, 3, 15));
edges.add(new Edge(2, 3, 4));
int vertexCounts = 4;
KruskalMST(vertexCounts, edges);
}
}
/**
* 边
*/
class Edge implements Comparable<Edge>{
int source;
int target;
int weight;
@Override
public int compareTo(Edge e) {
return this.weight - e.weight;
}
public Edge(int source, int target, int weight) {
this.source = source;
this.weight = weight;
this.target = target;
}
public Edge() {
}
@Override
public String toString() {
return "Edge{" +
"source=" + source +
", target=" + target +
", weight=" + weight +
'}';
}
}
算法复杂度分析
- 边排序: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( E log E ) O(E \log E) </math>O(ElogE)
- 并查集操作: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( E ⋅ α ( V ) ) O(E \cdot \alpha(V)) </math>O(E⋅α(V))
- 总复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( E log E ) O(E \log E) </math>O(ElogE)
3 拓扑排序
基本概念
拓扑排序(Topological Sorting)是针对有向无环图(DAG)的一种线性排序算法。其目标是将图中所有的顶点排列成一个序列,使得对于任意一条有向边u -> v
,顶点u
在序列中始终出现在顶点v
之前。其应用场景有:
- 任务调度(编译顺序、工程任务依赖等)
- 课程安排(前置课程约束)
- 依赖关系解析(软件包安装顺序等)
算法原理
拓扑排序常见的实现方式有两种:
Kahn算法(基于入度)
其核心思想是维护顶点的入度表,逐步选择入度为0的顶点,并更新其邻接顶点的入度
- 统计所有顶点的入度
- 将入度为 0 的顶点加入队列
- 依次取出队列中的顶点,并将其邻接顶点入度减去 1
- 若邻接顶点入度为 0 则加入队列
- 重复直到队列为空。若包含所有的顶点,则排序成功,否则则存在环
DFS后序遍历
其核心思想是通过深度优先搜索,记录后序遍历的逆序作为拓扑序列。
- 对未访问过的顶点执行DFS
- 递归访问当前节点的所有邻接节点
- 将当前节点加入栈
- 最终栈的逆序即为拓扑排序结果
实现
以Kahn算法为例。
java
/**
* 拓扑排序的实现方式
* @param edges
* @param vertexCounts
* @return
*/
public static List<Integer> topologicalSort(List<int[]> edges, int vertexCounts) {
List<Integer> resList = new ArrayList<>();
// 初始化入度数组 inDegree 以及 图
int[] inDegree = new int[vertexCounts];
Map<Integer, List<Integer>> graph = new HashMap<>();
for (int i = 0; i < vertexCounts; i++) {
graph.put(i, new ArrayList<>());
}
for (int[] edge : edges) {
inDegree[edge[1]]++;
graph.get(edge[0]).add(edge[1]);
}
// 将入度为 0 的顶点加入队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < vertexCounts; i++) {
if (inDegree[i] == 0) queue.add(inDegree[i]);
}
// 处理队列中的顶点,即入度为 0 的顶点
while (!queue.isEmpty()) {
int vertex = queue.poll();
resList.add(vertex);
// 邻居入度减去 1
for (int neighbor : graph.get(vertex)) {
inDegree[neighbor]--;
if (inDegree[neighbor] == 0) queue.add(neighbor);
}
}
// 检查是否存在环
if (resList.size() != vertexCounts) return new ArrayList<>();
return resList;
}
计算复杂度分析
- 时间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( E + V ) O(E + V) </math>O(E+V),其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> E E </math>E为边的条数而 <math xmlns="http://www.w3.org/1998/Math/MathML"> V V </math>V为顶点的个数
- 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( V ) O(V) </math>O(V),最坏的情况下,有
V - 1
个顶点为入度为 1 ,即星型拓扑结构,需要额外 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( V ) O(V) </math>O(V)的空间复杂度来存储顶点
4 Dijkstra算法
Dijkstra算法用于带权有向图或者无向图中求解单源最短路径的经典算法。其核心思想是通过贪心策略逐步扩展最短路径树。
算法步骤
- 初始化
- 初始化距离数组
int[] distance
设置起点到自身的距离为 0, 其他节点到起点的距离为无穷大 - 维护两个集合:已经确定的最短路径的集合
U
和未确定的集合V
- 迭代过程
- 从集合
V
中选择距离起点最近的节点v
,将其加入集合U
- 遍历
v
的邻居节点neighbors
,若起点source -> v -> neighbor
的路径比已知路径更短,则更新neighbor
的距离 - 重复上述步骤,直到所有的节点都被访问过
算法实现
java
/**
* Alipay.com Inc.
* Copyright (c) 2004-2024 All Rights Reserved.
*/
package com.programmer.carl.graph;
import java.util.Arrays;
import java.util.List;
import java.util.PriorityQueue;
import java.util.Queue;
/**
* 迪杰斯特拉算法
* @author chongli
* @version : T3DijkstraAlgorithm.java, v 0.1 2025/3/28 09:26 chongli Exp $$
*/
public class T3DijkstraAlgorithm {
/**
* Dijkstra算法
* @param graph 图邻接矩阵
* @param sourceId
* @return 当前节点到各个节点之间的最短距离
*/
public static int[] Dijkstra(int[][] graph, int sourceId) {
int[] distArr = new int[graph.length];
Arrays.fill(distArr, Integer.MAX_VALUE);
// 初始化起点
Vertex source = new Vertex(0, sourceId);
distArr[sourceId] = 0;
boolean[] visited = new boolean[graph.length];
// 初始化优先队列,用来寻找当前距离顶点最近的节点
Queue<Vertex> priorQueue = new PriorityQueue<>();
priorQueue.add(source);
// 遍历当前优先队列的所有节点
while (!priorQueue.isEmpty()) {
Vertex currentVertex = priorQueue.poll();
if (visited[currentVertex.id]) continue;
visited[currentVertex.id] = true;
// 遍历当前节点所有的未被访问过的邻居
for (int i = 0; i < graph.length; i++) {
// 如果当前节点的邻居未被访问,并且 source -> cur -> neighbor的距离更小
if (graph[i][currentVertex.id] != 0 && !visited[i]) {
// 新距离比当前距离更近
if (graph[i][currentVertex.id] + distArr[currentVertex.id] < distArr[i]) {
distArr[i] = graph[i][currentVertex.id] + distArr[currentVertex.id];
priorQueue.add(new Vertex(distArr[i], i));
}
}
}
}
return distArr;
}
}
/**
* 图的顶点类
*/
class Vertex implements Comparable<Vertex>{
// 距离源节点的距离
int distance;
// 编号
int id;
public Vertex(int distance, int id) {
this.distance = distance;
this.id = id;
}
public Vertex() { }
@Override
public int compareTo(Vertex v) {
return Integer.compare(this.distance, v.distance);
}
@Override
public String toString() {
return String.valueOf(this.id);
}
}
计算复杂度分析
- 时间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( ( V + E ) log V ) O((V + E)\log V) </math>O((V+E)logV)
- 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( V ) O(V) </math>O(V)
应用场景
- 路径规划:地图导航、物流配送
- 网络路由:数据包传输的最优路径选择
- 社交距离:用户之间的最短关系链分析
Bellman-Ford算法
基本概述
Bellman-Ford算法是一种用于求解单源最短路径问题 的图论算法。其核心优势在于能够处理含有负权边的图,并能够检测图中是否存在负权值环(权重总和为负数的环路)。这一特性使得其在需要处理复杂权重的场景,例如网络路由协议等,具有不可替代性。
算法核心思想
通过松弛(Relaxation) 操作,逐步逼近最短路径。算法执行以下步骤:
- 初始化:源点到自身的距离为 0,其他顶点距离源点初始化为无穷大
- 迭代松弛 :对所有边进行
V - 1
次松弛,其中V
为顶点个数,确保最短路径的传播 - 负环检测 :对第
V
次松弛仍然能够更新距离,则存在负权环
Q1 :为什么需要V - 1
次松弛?
对于无环图中,两个顶点之间的最短距离最多包含V - 1
条边。如果超过V - 1
条边,则说明路径中必然存在有环。因此V - 1
次松弛足以寻找所有的最短路径。
一次松弛的话,只能从源点逐层更新所有的邻接顶点,无法保证全局最优。
Q2:如何检测出来负权值环的?
若图中存在负权值环,则最佳每次经过环,路径总权值都可以得到缩短。所以,若算法在第V
次松弛之后仍然能够更新最短距离,则一定存在负权值环。
算法步骤
- 初始化距离数组:设定源点距离为0,其余顶点设置为无穷大
- 松弛所有的边v-1次 :对每条边
u -> v
,若dist[u] + w < dist[v]
,则更新dist[v]
- 检测负权环:再次遍历所有的边若仍然能够松弛则存在负环
算法实现
java
public class T4BellmanFordAlgorithm {
private int[] dist;
/**
* Bellman-Ford 算法具体实现
* @param edges 带权边集合
* @param vertexCount 顶点数量
* @param source 源点ID
* @return
*/
public int[] bellmanFord(List<Edge> edges, int vertexCount, int source) {
// 初始化距离数组
dist = new int[vertexCount];
// 源点自身距离设置为 0 ,其他顶点和源点距离初始化为 INF
Arrays.fill(dist, Integer.MAX_VALUE);
dist[source] = 0;
boolean negativeRing = false;
// 进行 V - 1 次松弛操作
for (int i = 0; i < vertexCount - 1; i++) {
for (Edge edge : edges) {
// 如果当前边 edge 顶点 source 可达,并且顶点source 加上边权重 weight 之后的距离小于 target 和源点的距离,则更新 dist[target]
if (edge.source != Integer.MAX_VALUE && dist[edge.source] + edge.weight < dist[edge.target]) {
dist[edge.target] = dist[edge.source] + edge.weight;
}
}
}
return dist;
}
/**
* 检测是否存在有负权值环
* @param edges 图
* @return
*/
public boolean checkNegativeRing(List<Edge> edges) {
// 检测是否存在负权环
for (Edge edge : edges) {
if (dist[edge.source] != Integer.MAX_VALUE && dist[edge.source] + edge.weight < dist[edge.target]) {
return true;
}
}
return false;
}
}
计算复杂度分析
- 时间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( V + E ) O(V + E) </math>O(V+E)
- 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( V + E ) O(V + E) </math>O(V+E),存储顶点和边的信息
Bellman-Ford算法队列优化算法(SPFA)
SPFA算法是对传统的Bellman-Ford算法的重要改进,通过减少无效松弛操作,显著提升效率。
优化原理
- 核心思想:仅仅对距离估计值发生变化额的顶点进行松弛操作,通过队列维护需要处理的顶点集合,这种优化避免了传统算法中需要遍历所有边的冗余操作。
- 松弛触发机制 :当顶点
u
的距离被更新时,其邻接顶点v
的最短路径可能被优化。队列机制确保每次只处理真正需要更新的顶点
算法流程
- 初始化阶段:源点入队,并标记为在队列状态,初始化距离数组为无穷大,源点距离为0
- 迭代松弛
- 取出队列首位元素
u
并解除标记 - 遍历
u
所有的邻接边,若发现更短的路径,则更新邻接顶点v
的距离 - 将发生更新的顶点
v
加入队列中,若未在队列中
- 终止条件:当队列为空的时候,表明所有顶点已经完成优化
关键优化特性
- 负权值检测:通过记录顶点的入队次数,如果某顶点入队次数大于等于
v
则判定存在负权环 - 队列选择策略:
- Small Label first: 距离源点较小的顶点优先插入队列头
- Large Label Last: 距离较大的顶点优先插入队尾,最后处理
java
public class T5SPFAAlgorithm {
/**
* 边
*/
private List<Edge> graph;
/**
* 存储所有节点到目标节点的距离
*/
private int[] dist;
/**
* 节点入队次数,若其 大于等于 vertexCounts,则说明有环
*/
private int[] counts;
/**
* 队列,存储节点
*/
private Deque<Integer> deque;
private boolean negativeRing;
public int[] SPFAAlgorithm(List<Edge> graph, int source) {
this.graph = graph;
int vertexCounts = graph.size(); // 顶点个数
this.dist = new int[vertexCounts];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[source] = 0;
this.counts = new int[vertexCounts];
Arrays.fill(counts, 0);
deque = new LinkedList<>();
// 将 source 节点添加到队列中
deque.addFirst(source);
counts[source]++;
while (!deque.isEmpty()) {
// 取出队首元素
Integer vertex = deque.pollFirst();
// 遍历队首顶点的邻接顶点
for (Edge edge : graph) {
// 邻接顶点与源点的距离发生变化
if (edge.source == vertex && dist[vertex] + edge.weight < dist[edge.target]) {
// 将当前节点加入队列,需要判断加入队列首位还是队尾
int newDistance = dist[vertex] + edge.weight;
if (deque.isEmpty() || newDistance < dist[deque.peek()]) {
deque.addFirst(edge.target);
} else {
deque.addLast(edge.target);
}
// 环检测,该顶点入队次数大于或者等于 vertexCounts
if (++counts[edge.target] >= vertexCounts) {
negativeRing = true;
return new int[vertexCounts];
}
// 更新 target 距离
dist[edge.target] = newDistance;
}
}
}
return dist;
}
@Test
public void testSPFAAlgorithm() {
List<Edge> edges = new ArrayList<>();
// 有效路径
edges.add(new Edge(0, 1, 2));
edges.add(new Edge(0, 2, 1));
edges.add(new Edge(1, 3, 3));
edges.add(new Edge(2, 3, -4));
edges.add(new Edge(3, 4, 2));
// 负权环部分
edges.add(new Edge(4, 2, -1));
edges.add(new Edge(2, 4, 5));
int vertexCount = 5;
int source = 0;
SPFAAlgorithm(edges, source);
System.out.println(this.negativeRing);
System.out.println(Arrays.toString(this.dist));
}
}
Floyd算法
算法概述
Floyd算法是一种用于寻找图中所有顶点对之间最短距离的动态规划路径算法。他通过逐步考虑每个顶点作为中间点,更新所有的可能的顶点对之间的距离,最终得到所有顶点对的最短路径矩阵。
算法步骤
- 初始化距离矩阵 :使用邻接矩阵初始化二维距离数组
dist[][]
,若无直接边链接,则距离置为INF无穷大 - 三重循环更新
- 外层循环遍历所有的中间节点
k
- 中间层和内层循环遍历所有的顶点对
(i, j)
- 若通过中间顶点
k
的路径更短,dist[i][k] + dist[k][j] < dist[i][j]
,则更新dist[i][j]
- 最终结果 :经过所有中间顶点的处理之后,
dist[][]
即为所有顶点对的最短距离
java
private final int INF = Integer.MAX_VALUE;
public int[][] floydAlgorithm(int[][] graph) {
// 顶点个数
int vertexCounts = graph.length;
// 初始化距离矩阵 dist,将所有的不可直接到大的置为INF
int[][] dist = Arrays.copyOf(graph, vertexCounts);
// 三重循环更新 dist 矩阵
for (int k = 0; k < vertexCounts; k++) {
for (int i = 0; i < vertexCounts; i++) {
for (int j = 0; j < vertexCounts; j++) {
// (i, k) 和 (k, j)可达,并且距离更小,则考虑更新 dist[i][j]
if (dist[i][k] != INF && dist[k][j] != INF
&& dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
}
计算复杂度分析
- 时间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N 3 ) O(N^3) </math>O(N3),这是由三层循环决定的。更新
dist
矩阵必须使用三重循环,因此并不好去修改优化时间复杂度 - 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( N 2 ) O(N^2) </math>O(N2),存储距离矩阵
A Star 算法
基本知识
A* 算法是一种路径查找和图遍历算法。它结合了Dijkstra算法的优点,可以找到最短路径,同时使用启发式函数来优化搜索过程,效率更高。
A* 算法的核心是启发函数 <math xmlns="http://www.w3.org/1998/Math/MathML"> f ( n ) = g ( n ) + h ( n ) f(n) = g(n) + h(n) </math>f(n)=g(n)+h(n),其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> g ( n ) g(n) </math>g(n)表示从起点到当前节点的实际代价, <math xmlns="http://www.w3.org/1998/Math/MathML"> h ( n ) h(n) </math>h(n)表示从当前节点到终点的估计代价。常用的启发式函数有曼哈顿距离、欧几里得距离等。
算法原理
A* 算法维护两个列表,分别为开放列表 和关闭列表。
- 开放列表:存储待评估的节点
- 关闭列表:存储已经评估过的节点
每次从开放列表中选择 <math xmlns="http://www.w3.org/1998/Math/MathML"> f ( n ) f(n) </math>f(n)值最小的节点进行扩展,直到找到终点或者开放列表为空。
每个节点由坐标、 <math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值、 <math xmlns="http://www.w3.org/1998/Math/MathML"> h h </math>h值、父节点等信息构成。然后使用优先队列作为开放列表,关闭列表可以使用集合或者哈希表来存储已经访问过的节点,避免重复处理。
算法步骤
- 初始化开放列表,将起点加入其中,其 <math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值为0, <math xmlns="http://www.w3.org/1998/Math/MathML"> h h </math>h值由启发函数计算得来, <math xmlns="http://www.w3.org/1998/Math/MathML"> f = g + h f = g + h </math>f=g+h
- 进入循环,直到开放列表为空或者找到终点:
- 选择开放列表中 <math xmlns="http://www.w3.org/1998/Math/MathML"> f f </math>f值较小的节点作为当前节点
- 如果当前节点是终点,则结束循环,回溯路径
- 否则,将当前节点加入到关闭列表
- 遍历当前节点的邻居节点,对于每个邻居节点
- 如果节点不可通过或者在关闭列表中,则跳过
- 计算新的 <math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值,如果该节点不再开放列表中或者新 <math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值更小,则更新 <math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值和父节点,并加入到开放列表
- 如果开放列表都空了,依旧没有找到终点,说明路径不存在
算法实现
java
public class T7AStarAlgorithm {
/**
* 方向数组
*/
private static final int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
/**
* A star 算法寻找 start - end 之间的最短路径
* @param grid
* @param start
* @param end
* @return
*/
public static List<Node> findPath(int[][] grid, Node start, Node end) {
// 优先队列存储开放列表
PriorityQueue<Node> openList = new PriorityQueue<>();
// 哈希表存储开放列表中的元素以及其 g 值,即当前元素到起点之间的实际距离
Map<Node, Integer> openMap = new HashMap<>();
// 关闭列表记录已经访问过的节点
Set<Node> closedSet = new HashSet<>();
// 初始化起点
start.g = 0;
start.h = calculateManhartonDistance(start, end);
// 将起点放入到开放列表
openList.add(start);
openMap.put(start, start.g);
// 循环处理开放列表中的每一个元素,以及其相邻元素
while (!openList.isEmpty()) {
Node curNode = openList.poll();
// 如果当前节点已经被处理过,直接跳过
if (closedSet.contains(curNode)) continue;
// 当前节点是终点,构造路径
if (curNode.equals(end)) {
return buildPath(curNode);
}
// 当前节点标记为已经访问
closedSet.add(curNode);
// 遍历邻居节点
for (int[] dir : DIRS) {
int x = curNode.x + dir[0];
int y = curNode.y + dir[1];
// 检查当前坐标是否合法
if (!isValid(x, y, grid)) continue;
// 以当前坐标创建新的节点
Node neighbor = new Node(x, y);
// 如果这个邻居已经访问过,直接跳过
if (closedSet.contains(neighbor)) continue;
// 计算这个邻居节点到起点的距离 g,移动成本为 1
int tentativeG = curNode.g + 1;
// 如果从当前节点到邻居节点这一条路不是最优的,则直接跳过
if (openMap.containsKey(neighbor) && tentativeG >= openMap.get(neighbor)) {
continue;
}
// 更新邻居信息
neighbor.g = tentativeG;
neighbor.h = calculateManhartonDistance(neighbor, end);
neighbor.parent = curNode;
// 将当前邻居节点加入到开放列表,并更新 openMap
openList.add(neighbor);
openMap.put(neighbor, tentativeG);
}
}
// 未找到最优的路径
return Collections.emptyList();
}
/**
* 从终点节点构建到起点的路径
* @param end
* @return
*/
private static List<Node> buildPath(Node end) {
Node curNode = end;
LinkedList<Node> path = new LinkedList<>();
while (curNode != null) {
// 将当前节点加入路径
path.addFirst(curNode);
curNode = curNode.parent;
}
return path;
}
/**
* 当前坐标是否是合法
* @param x
* @param y
* @param grid
* @return
*/
private static boolean isValid(int x, int y, int[][] grid) {
return x >= 0 && x < grid.length
&& y >= 0 && y < grid[0].length
&& grid[x][y] != 0;
}
/**
* 计算两个节点之间的曼哈顿距离
* @param start
* @param end
* @return
*/
private static int calculateManhartonDistance(Node start, Node end) {
return Math.abs(start.x - end.x) + Math.abs(start.y - end.y);
}
}
class Node implements Comparable<Node> {
int x, y; // 节点坐标
int g; // 从起点到当前节点的实际代价
int h; // 启发式估计代价
Node parent; // 父节点
public Node(int x, int y) {
this.x = x;
this.y = y;
}
// 计算总评估值
public int getFValue() {
return this.x + this.y;
}
/**
* compareTo 方法
* @param other
* @return
*/
@Override
public int compareTo(Node other) {
return Integer.compare(getFValue(), other.getFValue());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Node node = (Node) o;
return x == node.x && y == node.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
讨论
为什么优先队列选择使用 <math xmlns="http://www.w3.org/1998/Math/MathML"> f f </math>f值进行排序,而对邻居节点的处理选择使用 <math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值?
<math xmlns="http://www.w3.org/1998/Math/MathML"> f f </math>f值表示节点探索优先级,优先队列选择 <math xmlns="http://www.w3.org/1998/Math/MathML"> f f </math>f值,是为了能够正确决策出搜索的方向。
<math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值表示实际路径的代价,判断是否将邻居节点加入开放队列以 <math xmlns="http://www.w3.org/1998/Math/MathML"> g g </math>g值作为依据,这样避免了由于启发函数计算的不准确导致路径非全局最优的可能性。
计算复杂度分析
时间复杂度
- 最坏情况下,启发函数 <math xmlns="http://www.w3.org/1998/Math/MathML"> h ( n ) = 0 h(n) = 0 </math>h(n)=0,此时算法退化为Dijkstra算法,其计算复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( b d ) O(b^d) </math>O(bd)。其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b表示每个节点的邻居节点个数, <math xmlns="http://www.w3.org/1998/Math/MathML"> d d </math>d表示最优路径的深度。
- 最好情况下,启发函数完美,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> h ( n ) h(n) </math>h(n)与实际最短路径代价相等,算法直接沿着最优路径进行搜索,此时时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( d ) O(d) </math>O(d)
空间复杂度
- 优先队列存储的等待处理的点: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( b d ) O(b^d) </math>O(bd)
- 关闭列表记录已经处理过的点: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( b d ) O(b^d) </math>O(bd)
- 路径信息:每个节点需要存储父节点指针: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( d ) O(d) </math>O(d)
综上,空间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( b d ) O(b^d) </math>O(bd)