题目地址:
https://leetcode.com/problems/cheapest-flights-within-k-stops/
给定一个非负权有向图,再给定一个源点 s s s,一个目标点 d d d,和一个整数 K K K,问从 s s s到 d d d中转站个数(其实就是路径去掉起点和终点后剩余的点数)不超过 K K K的最短路径长度。若不存在则返回 − 1 −1 −1。
法1:DFS + 剪枝。直接从起点开始DFS,当递归深度到达 K + 1 K+1 K+1(也就是路径边数)时就直接返回。也就是在暴搜所有能到达目标点且路径边数不超过 K + 1 K+1 K+1的路径的长度。同时用一个全局变量更新答案即可。代码如下:
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Solution {
private int res = Integer.MAX_VALUE;
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int K) {
if (src == dst) {
return 0;
}
Map<Integer, List<int[]>> graph = buildGraph(flights);
boolean[] visited = new boolean[n];
dfs(src, dst, 0, 0, K + 1, graph, visited);
return res == Integer.MAX_VALUE ? -1 : res;
}
private void dfs(int cur, int dst, int curCost, int pathLen, int maxLen, Map<Integer, List<int[]>> graph, boolean[] visited) {
if (cur == dst) {
res = Math.min(res, curCost);
return;
}
if (pathLen == maxLen) {
return;
}
visited[cur] = true;
if (graph.containsKey(cur)) {
for (int[] next : graph.get(cur)) {
int nextPoint = next[0], costFromCur = next[1];
if (curCost + costFromCur >= res) {
continue;
}
dfs(nextPoint, dst, curCost + costFromCur, pathLen + 1, maxLen, graph, visited);
}
}
visited[cur] = false;
}
private Map<Integer, List<int[]>> buildGraph(int[][] flights) {
Map<Integer, List<int[]>> graph = new HashMap<>();
for (int[] flight : flights) {
graph.putIfAbsent(flight[0], new ArrayList<>());
graph.get(flight[0]).add(new int[]{flight[1], flight[2]});
}
return graph;
}
}
时空复杂度 O ( V + E ) O(V+E) O(V+E)。
法2:Bellman-Ford算法。这个算法本质上是动态规划。设 f [ i ] [ v ] f[i][v] f[i][v]是从源点 s s s到顶点 v v v不超过 i i i条边的路径中最短那条路的长度。那么显然 f [ i ] [ v ] = min ( u , v ) ∈ E { f [ i − 1 ] [ u ] + w ( u , v ) } f[i][v]=\min_{(u,v)\in E}\{f[i-1][u]+w(u,v)\} f[i][v]=(u,v)∈Emin{f[i−1][u]+w(u,v)}如果目标点是 x x x的话,最终答案就是 f [ k ] [ x ] f[k][x] f[k][x]。为了节省空间,我们可以把 f f f简化为一维数组,但需要把 i − 1 i-1 i−1的情形备份一份用来更新 i i i的情形。代码如下:
import java.util.Arrays;
public class Solution {
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int K) {
if (src == dst) {
return 0;
}
// 用邻接矩阵建图
int[][] graph = buildGraph(flights, n);
// 就是描述中的f数组,记录从源点到该顶点的距离
int[] dp = new int[n];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[src] = 0;
int[] backup = null;
// 更新K + 1次
for (int pass = 1; pass <= K + 1; pass++) {
// 备份一份
backup = Arrays.copyOf(dp, n);
// 遍历所有边,通过旧的dp[i](也就是backup[i])更新dp[j]
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 无边,略过
if (graph[i][j] == Integer.MAX_VALUE) {
continue;
}
// 这里为了防止溢出,需要判断一下正无穷的情况
if (backup[i] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j], backup[i] + graph[i][j]);
}
}
}
}
return dp[dst] == Integer.MAX_VALUE ? -1 : dp[dst];
}
private int[][] buildGraph(int[][] flights, int n) {
int[][] graph = new int[n][n];
for (int[] row : graph) {
Arrays.fill(row, Integer.MAX_VALUE);
}
for (int[] flight : flights) {
graph[flight[0]][flight[1]] = flight[2];
}
return graph;
}
}
时间复杂度 O ( K V 2 ) O(KV^2) O(KV2)。
法3:BFS + 优先队列。其实就是模仿Dijkstra算法去做。但需要注意的是,这里BFS的时候,除非出队的顶点恰好是目标点(此时就直接返回答案了),否则是不能标记某个顶点已经算过的。也就是说,即使一个顶点算出过最短路,如果存在一个更长的路但是边数少,也需要加进优先队列里面,否则可能会漏解。代码如下:
import java.util.*;
public class Solution {
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int K) {
if (src == dst) {
return 0;
}
// 用邻接矩阵建图
int[][] graph = buildGraph(flights, n);
// 这里int[]是三元组,第一个位置是花费,第二个是到达点,第三个是路径边数
// 以从源点到当前点的花费做权重,开最小堆
PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
// 把源点加入堆
minHeap.offer(new int[]{0, src, 0});
while (!minHeap.isEmpty()) {
int[] cur = minHeap.poll();
int cost = cur[0], place = cur[1], pathLen = cur[2];
// 如果取出来的点恰好是目标点,直接返回花费
if (place == dst) {
return cost;
}
// 如果当前点与源点的这条路径边数还不足K + 1,则对其邻接点拓展
if (pathLen < K + 1) {
// 遍历其邻边
for (int next = 0; next < n; next++) {
// 如果连通,就更新,并且加入堆
if (graph[place][next] != Integer.MAX_VALUE) {
minHeap.offer(new int[]{cost + graph[place][next], next, pathLen + 1});
}
}
}
}
return -1;
}
private int[][] buildGraph(int[][] flights, int n) {
int[][] graph = new int[n][n];
for (int[] row : graph) {
Arrays.fill(row, Integer.MAX_VALUE);
}
for (int[] flight : flights) {
graph[flight[0]][flight[1]] = flight[2];
}
return graph;
}
}
时间复杂度 O ( E log E ) O(E\log E) O(ElogE),空间 O ( V 2 ) O(V^2) O(V2)。
注解:法3与通常的Dijkstra算法有若干不同之处:
第一,不需要开dist
数组存储每个点与源点的最短路长度,也不需要开visited
数组标记某个点最短路是否被算过。回顾一下传统的Dijkstra算法,visited
数组的作用在于,当顶点出堆的时候,标记其最短路已经算出过,以后更新到该顶点的时候就要忽略这个顶点。但是这道题中不能这样判断,原因是,即使一个顶点
v
v
v出堆,我们也不能断定要求的不超过
k
k
k条边的最短路路径一定包含
v
v
v对应的那个最短路
P
P
P,有可能选的是路程更长但是边数更短的路,而不是
P
P
P。所以不能对
v
v
v做标记。类似的道理,在传统的Dijkstra算法中,dist
数组存的是当前到某个点的最短路,但是这些信息事实上没什么用,即使发现了最短路也不能对其更新,否则就会产生上面说的“路程更长但是边数更短的路”的问题,从而漏解。
第二,一个顶点第一次出堆的时候,只有它恰好是目标点的时候,对应的最短路是正确的,别的点出堆的时候附带的最短路不一定是目标点最短路的子路径(也就是说,没有最优子结构的性质),原因就在于边数的限制。所以法 3 3 3本质上还是BFS,只是形似Dijkstra而已。
但是,我们可以证明:当一个顶点出堆的时候,它对应的花费就是从源点到该点的不超 k k k条边的最短路的花费,并且负责更新这条最短路的每个顶点的三元组都已经出堆。第二条是显然的,最短路路径上的点当然要出堆才能更新到当前出堆的点。注意到这个算法其实是保持路径长度不超过 k k k的并且 h ( v ) = 0 h(v)=0 h(v)=0的A*算法,而A*算法是能保证第一次出堆就得到最短路的,所以结论正确。