【数据结构与算法】从广度优先搜索到Dijkstra算法解决单源最短路问题
前置知识
在学习最短路算法之前 , 要先掌握以下的图论基础概念来帮助理解后续内容 .
边和权
- 边 : 连接两个节点的连线 , 表示它们之间可达 .
- 边权 : 每条边的非负数值 , 表示代价或距离 .
- 无权图:所有边权相同 , 此时最短路径关注步数最少 .
- 带权图:边权不一 , 要比较累积代价 .
稠密图和稀疏图
- 稠密图 : 边数 E 接近最大值
V^2
, 适合使用邻接矩阵和O(V^2)
算法 . - 稀疏图 : 边数 E 远小于
V^2
, 适合使用邻接表结合堆优化的 Dijkstra 算法达到O((V+E)logV)
.
邻接矩阵和邻接表
- 邻接矩阵
用二维数组 graph[i][j]
存储节点 i 到 j 的边权 :
0 1 2 3
0 [0, 5, INF, 2]
1 [5, 0, 4, INF]
2 [INF, 4, 0, 1]
3 [2, INF, 1, 0]
// INF 表示当前位置不可达
- 优点 : 常数级查询复杂度 , 直观易理解 .
- 缺点 : 空间复杂度
O(V²)
, 当图为稀疏图时浪费严重 .
- 邻接表
用列表存储每个节点的出边集合 :
List<Edge>[] adj = new List[V];
adj[0] = [(1,5), (3,2)];
adj[1] = [(0,5), (2,4)];
// …
- 优点 : 空间
O(V+E)
, 适合稀疏图 . - 缺点 : 查边需遍历链表 .
将邻接表恢复成邻接矩阵
- 创建
n×n
矩阵,初始化为INF
,对角线设为0
. - 遍历每个节点
u
的邻接表 , 对边(v, w)
写入mat[u][v] = w
;
static final int INF = Integer.MAX_VALUE / 2;
static class Edge {int to, weight;Edge(int to, int weight) { this.to = to; this.weight = weight; }
}public static int[][] listToMatrix(List<Edge>[] adj) {int n = adj.length;int[][] mat = new int[n][n];for(int i = 0; i < n; ++i){Arrays.fill(mat[i], INF);mat[i][i] = 0;}for(int u = 0; u < n; ++u){for(Edge e : adj[u]){mat[u][e.to] = e.weight;}}return mat;
}
广度优先搜索 BFS
BFS 按加入队列的层数进行矩阵或图的遍历 , 先探索所有距离为 1 的节点 , 再扩散探索距离为 2 的 , 以此类推 . 天然能求出无权图的最短步数 .
1091. 二进制矩阵中的最短路径
题面要求得到从矩阵左上角到矩阵右下角的经过的最短路径 , 矩阵中有不可达地块 . 如果用例无法到达右下角则返回 -1 .
- 按照广度优先搜索策略 , 我们首先初始化队列 , 将初始坐标入队 .
- 设置队列非空循环 , 每次入队维护路径的最大值 , 在这里将标记已访问的位置表示成广搜经过该位置时经过的路径数量 .
- 向八向拓展 , 判断拓展坐标是否在矩阵内以及是否可达 , 如果是则入队 , 并将该拓展坐标设置为已访问 .
- 最后如果队列为空说明无法达到右下角 , 如果检测到出队坐标位置是右下角则立刻返回该位置元素 .
class Solution {public int shortestPathBinaryMatrix(int[][] grid) {int[][] direction = {{1, 0}, {1, 1}, {1, -1}, {0, 1}, {0, -1}, {-1, 0}, {-1, 1}, {-1, -1}};Deque<int[]> dq = new ArrayDeque<>();int row = grid.length, col = grid[0].length;if(grid[0][0] != 0 || grid[row - 1][col - 1] != 0) return -1;dq.offerLast(new int[]{0, 0});grid[0][0] = 1;while(!dq.isEmpty()){int[] arr = dq.pollFirst();int a = arr[0], b = arr[1];if(a == row - 1 && b == col - 1){return grid[a][b];}for(int[] temp : direction){int r = a + temp[0], c = b + temp[1];if(r >= 0 && r < row && c >= 0 && c < col && grid[r][c] == 0){grid[r][c] = grid[a][b] + 1;dq.offerLast(new int[]{r, c});}}}return -1;}
}
BFS 的局限性
- 仅适用于无权图 : 假设每条边代价相同 , 才能保证第一次到达即最短 .
- 处理带权图无效 : 无法比较不同路径的累积权重 .
Dijkstra 算法
Dijkstra 是贪心的单源最短路径算法 , 适用于边权非负的带权图 .
对于 1091 的最短路问题 , 如果可达的地块 0 改成权 , 权表示到达该地块所花费的时间 , 最后求得从左上角到右下角的最小花费 , 则要这样改良 :
class Solution {public int shortestPathBinaryMatrix(int[][] grid) {int[][] direction = {{1, 0},{1, 1},{1, -1},{0, 1},{0, -1},{-1, 0},{-1, 1},{-1, -1}};int row = grid.length, col = grid[0].length;final int INF = Integer.MAX_VALUE;int[][] dist = new int[row][col];for(int i = 0; i < row; ++i){Arrays.fill(dist[i], INF);}dist[0][0] = grid[0][0];PriorityQueue<int[]> pq = new PriorityQueue<>((a,b) -> a[0] - b[0]);pq.offer(new int[]{dist[0][0], 0, 0});while(!pq.isEmpty()){int[] cur = pq.poll();int cost = cur[0], x = cur[1], y = cur[2];if(cost > dist[x][y]) continue;if(x == row - 1 && y == col - 1){return cost;}for(int[] d : direction){int nx = x + d[0], ny = y + d[1];if(nx < 0 || nx >= row || ny < 0 || ny >= col) continue;int nc = cost + grid[nx][ny];if(nc < dist[nx][ny]){dist[nx][ny] = nc;pq.offer(new int[]{nc, nx, ny});}}}return -1;}
}
因为矩阵带权 , 更近的路径可能花费更多 , 所以 BFS 队列的先进后出策略是行不通的 , 在这里使用小顶堆的优先队列来让已探索路径中花费最小的位置出队 , 同时拓展出队的坐标 , 维护表示从起点到该位置的最小花费的 cost 数组 .
如果不标记已访问的位置 , 如何保证搜索不成环 ?
答案是 if(cost > dist[x][y]) continue;
, 如果这条路径花费更高 , 则放弃这条路径 . cost 数组是不关心具体路径的 , 我们拒绝拓展花费更高的路径 , 保证了每条路径最多探索一次 , 同时因为每一步都要花费 , 所以不会出现某一条路径重复走之前的地块形成环的情况 . 这也是为什么 Dijkstra 只能解决非负权图的原因 , 如果权有负 , 那么贪心的策略会使搜索 “回去” .
所以 Dijkstra 算法其实是一种最小花费优先的广度优先搜索策略 .
对比 BFS 与 Dijkstra
特性 | BFS | Dijkstra |
---|---|---|
适用图 | 无权图 | 边权非负的带权图 |
核心结构 | 队列 | 最小堆 |
距离定义 | 步数 | 累积权重 |
时间复杂度 | O(V+E) | 邻接矩阵 : O(V^2) ; 堆优化 : O((V+E)logV) |
Dijkstra 不是简单的先进先出关系 , 而是在已探索的地块中选择最小花费的来向四周拓展 , 这是使用小顶堆优先队列实现的 .
在拓展中维护表示到达当前地块的最小花费的 cost 数组 , 保证从优先队列出队时 , 出队坐标的 cost 一定是从起点发展的最小花费 .