Variations of Shortest Path: From LeetCode 787 - Wed, Sep 18, 2024
I encountered this interesting question when I was refreshing my algorithms skills.
The original question is:
There are n cities connected by some roads. You are given an array
flights
whereflights[i] = [from_i, to_i, price_i]
indicates that there is a flight from cityfrom_i
to cityto_i
with a price ofprice_i
. You are also given three integerssrc
,dst
, andk
, representing the starting city, the destination city, and the maximum number of stops allowed. Your task is to find the cheapest price fromsrc
todst
with at mostk
stops. If there is no such route, return -1.
Sample Input:
n = 4, flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], src = 0, dst = 3, k = 1
Problem Analysis
At first glance this is a classic shortest path problem, but with an additional constraint on the number of stops. However, due to the constraint on the number of stops, this requires careful consideration of how to traverse the graph when we do Dijsktra or Bellman-Ford.
A Recap on Shortest Path Algorithms
Dijkstra’s Algorithm
Dijkstra’s algorithm is a dynamic programming algorithm that exploits the fact that in a graph with non-negative weights, the shortest path from a source to a destination has an optimal substructure. The algorithm maintains a priority queue to explore the nearest unvisited node and updates the distances to its neighbors.
More specifically, the algorithm splits all vertices into two sets: the set of vertices whose shortest distance from the source is known \(S\) and the set of vertices whose shortest distance is unknown (\(T\)). It repeatedly selects the vertex with the smallest known distance, adds it to the known set, and updates the distances to its neighbors.
The first step is to initialize the shortest distance \(dis[v]\) such that \(dis[src] = 0\) and \(dis[v] = \infty\) for all other vertices \(v\).
At each iteration, we do the following until we have processed all vertices (\(|T| = \{\}\)):
- Find the vertex \(u\) in \(T\) with the smallest distance \(dis[u]\).
- Add \(u\) to \(S\).
- Perform relaxation for all neighbors \(v\) of \(u\): if \(dis[v] > dis[u] + w(u, v)\), then update \(dis[v] = dis[u] + w(u, v)\).
Bellman-Ford Algorithm
Compared to Dijkstra’s algorithm, we are not doing dynamic programming here (note the absence of the priority queue). Instead, every edge is relaxed in one iteration. This will be repeated for a total of \(|V| - 1\) iterations, where \(|V|\) is the number of vertices in the graph.
Solving the Problem
Approach 1: Modified Bellman-Ford
It is straightforward to think that we can directly use the Bellman-Ford algorithm to solve this problem. However, the challenge is to incorporate the constraint on the number of stops.
Recall that the Bellman-Ford will relax all edges for \(|V| - 1\) iterations. To incorporate the constraint on the number of stops, we can modify the algorithm to only relax edges for \(k + 1\) iterations (since we can have at most \(k\) stops, we need to consider \(k + 1\) edges).
Here is the modified Bellman-Ford algorithm:
- Initialize a distance array
dis
of sizen
withfloat('inf')
, and setdis[src] = 0
. - For
i
from 0 tok
:- Create a copy of the current distance array
temp_dis
. - For each edge
(u, v, price)
inflights
:- If
dis[u] != float('inf')
anddis[u] + price < temp_dis[v]
, then updatetemp_dis[v] = dis[u] + price
.
- If
- Update
dis
withtemp_dis
.
- Create a copy of the current distance array
- Return
dis[dst]
if it is notfloat('inf')
, otherwise return -1.
The caveat here is the copy. Why do we need to create a copy of the distance array? This is to ensure that we are only considering the distances from the previous iteration when relaxing edges. While this does not matter in the standard Bellman-Ford algorithm, it is crucial here because we are limiting the path length. Updating the distance array in place would violate the invariant that a shortest path is only relaxed at most once per iteration.
Approach 2: BFS with Priority Queue
On the LeetCode official solution, this is called a modified Dijkstra’s algorithm. However it is more akin to a BFS with a priority queue. The idea is to use a priority queue to explore the graph while keeping track of the number of stops.
The invariant here is that instead of a visited
set, we maintain the number of steps when the node is last visited. A node is only explored if the current number of stops is less than the previous recorded number of stops, or if it has not been visited before.
This is really just BFS with a PQ, where we explore the cheapest path first. And the existence of this PQ is totally unnecessary, since regular BFS works just fine. PQ insertion added complexity without any real benefit.
Note: Why Dijkstra’s algorithm does not apply here? It’s actually the same problem as in the original Bellman-Ford algorithm, where it is possible to relax on a shortest path multiple times. In Bellman-Ford this can be circumvented by only allowing one relaxation per iteration, but in Dijkstra’s algorithm, the priority queue does not allow for this. Thus, the algorithm would not work correctly with the stop constraint.
Conclusion
In conclusion, the problem of finding the cheapest flight with at most k
stops can be effectively solved using a modified Bellman-Ford algorithm or just BFS. The key is to carefully manage the number of stops and ensure that we are not violating the constraints of the problem.
I really suspect that the LeetCode solution is generated with ChatGPT :shrug:. The BFS with priority queue is unnecessarily complicated and does not provide any real benefit over a simple BFS. The modified Bellman-Ford is a more straightforward and efficient approach to solving this problem.
References
- LeetCode Problem 787: Cheapest Flights Within K Stops
- CP-Algorithms: https://cp-algorithms.com/graph/dijkstra.html