|
| 1 | +# 6.5 Cheapest Flights With K Stops |
| 2 | + |
| 3 | +> **Source:** [`src/main/kotlin/graph/greedy/CheapestFlightsWithKStops.kt`](https://github.com/arpanpathak/AdvancedAlgorithmPatterns/blob/main/src/main/kotlin/graph/greedy/CheapestFlightsWithKStops.kt) |
| 4 | +> **Pattern:** Dijkstra + stop budget · **Core page** |
| 5 | +
|
| 6 | +## The Problem |
| 7 | + |
| 8 | +There are `n` cities connected by `flights[i] = [from, to, price]`. Find the cheapest price from `src` to `dst` with **at most `k` stops** (i.e., at most `k + 1` flight legs). Return `-1` if no such route exists. |
| 9 | + |
| 10 | +- Constraints: $1 \le n \le 100$; $0 \le k \le n - 1$; prices up to $10^4$. |
| 11 | + |
| 12 | +## Examples |
| 13 | + |
| 14 | +``` |
| 15 | +Input: n = 4, flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], |
| 16 | + src = 0, dst = 3, k = 1 |
| 17 | +Output: 700 (0 -> 1 -> 3 costs 100+600=700; the cheaper 0->1->2->3 at 400 needs 2 stops) |
| 18 | +
|
| 19 | +Input: same flights, k = 0 |
| 20 | +Output: -1 (0 -> 1 -> 3 would be one stop; only a direct 0 -> 3 flight is allowed — none exists) |
| 21 | +``` |
| 22 | + |
| 23 | +## Intuition — Dijkstra, but the budget changes the rules |
| 24 | + |
| 25 | +Without the stop limit, this is plain Dijkstra: explore cheapest-first, prune anything that reaches a city more expensively than a known path. With the `k` limit, **a more expensive path can be the only valid one** — a cheap route might blow the stop budget, while a pricier direct route fits within it. So the pure "cheapest wins" prune would throw away the answer. |
| 26 | + |
| 27 | +The fix has two parts: |
| 28 | + |
| 29 | +1. **State = `(city, cost, stops)`** — the stops counter rides along in the queue (the [state-tuple upgrade](pattern-primer.md) from the primer). Every leg increments it. |
| 30 | +2. **Prune on the budget first** — a state with `stops > k + 1` is dead on arrival: drop it, regardless of cost. Only then apply the cost-based prune for efficiency. |
| 31 | + |
| 32 | +Because the priority queue still orders by cost, the **first time `dst` is popped** is the cheapest among all states that survived the budget filter — that's the answer. (Every candidate pushed for `dst` has `stops <= k + 1` by the prune, and the queue yields them in cost order.) |
| 33 | + |
| 34 | +**Why `k + 1` and not `k`?** The problem counts *stops* (intermediate cities); each stop requires a flight leg *after* it. A direct flight is 0 stops but 1 leg. The state's counter counts legs, so the budget is `k + 1` legs. Off-by-one here is the most common bug in this problem — say it out loud before coding. |
| 35 | + |
| 36 | +## Approach 1 — Bellman-Ford, k+1 layered relaxations |
| 37 | + |
| 38 | +Relax all edges `k + 1` times, tracking the best cost per stop-count: $O(k \cdot E)$, guaranteed correct (each round adds one leg). The classic alternative — same spirit, no heap. The repo's version below is the heap flavor. |
| 39 | + |
| 40 | +## Approach 2 — Budget-aware Dijkstra (the repo's version, optimal) |
| 41 | + |
| 42 | +```kotlin |
| 43 | +import java.util.* |
| 44 | + |
| 45 | +class CheapestFlightsWithKStops { |
| 46 | + data class Node(val dest: Int, val cost: Int) |
| 47 | + data class State(val node: Int, val cost: Int, val stops: Int) // legs taken so far |
| 48 | + |
| 49 | + /** |
| 50 | + * @param n number of cities (0..n-1) |
| 51 | + * @param flights flights[i] = [from, to, price] |
| 52 | + * @param src departure city |
| 53 | + * @param dst arrival city |
| 54 | + * @param k max intermediate stops allowed |
| 55 | + * @return cheapest price with at most k stops, or -1 |
| 56 | + */ |
| 57 | + fun findCheapestPrice(n: Int, flights: Array<IntArray>, src: Int, dst: Int, k: Int): Int { |
| 58 | + // Build the graph from the input flights |
| 59 | + val graph = mutableMapOf<Int, MutableList<Node>>() |
| 60 | + flights.forEach { flight -> |
| 61 | + graph.getOrPut(flight[0]) { mutableListOf() }.add(Node(flight[1], flight[2])) |
| 62 | + } |
| 63 | + |
| 64 | + // Initialize the priority queue and cost tracking |
| 65 | + val minCost = Array(n) { Int.MAX_VALUE } |
| 66 | + val pq = PriorityQueue<State>(compareBy { it.cost }) |
| 67 | + pq.offer(State(src, 0, 0)) |
| 68 | + minCost[src] = 0 |
| 69 | + |
| 70 | + while (pq.isNotEmpty()) { |
| 71 | + val (node, currentCost, stops) = pq.poll() |
| 72 | + |
| 73 | + // Drop states over the budget, or ones dominated by a cheaper arrival |
| 74 | + if (stops > k + 1 || currentCost > minCost[node]) continue |
| 75 | + minCost[node] = currentCost |
| 76 | + |
| 77 | + // First pop of dst = cheapest state that survived the budget filter |
| 78 | + if (node == dst) return currentCost |
| 79 | + |
| 80 | + // Explore neighbors |
| 81 | + graph[node]?.forEach { neighbor -> |
| 82 | + pq.offer(State(neighbor.dest, currentCost + neighbor.cost, stops + 1)) |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + return -1 |
| 87 | + } |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +```java |
| 92 | +import java.util.*; |
| 93 | + |
| 94 | +public class CheapestFlightsWithKStops { |
| 95 | + // state: (city, accumulated cost, legs taken so far) |
| 96 | + private record State(int node, int cost, int stops) {} |
| 97 | + |
| 98 | + /** |
| 99 | + * @param n number of cities (0..n-1) |
| 100 | + * @param flights flights[i] = [from, to, price] |
| 101 | + * @param src departure city |
| 102 | + * @param dst arrival city |
| 103 | + * @param k max intermediate stops allowed |
| 104 | + * @return cheapest price with at most k stops, or -1 |
| 105 | + */ |
| 106 | + public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) { |
| 107 | + Map<Integer, List<int[]>> graph = new HashMap<>(); |
| 108 | + for (int[] f : flights) { |
| 109 | + graph.computeIfAbsent(f[0], x -> new ArrayList<>()).add(new int[]{f[1], f[2]}); |
| 110 | + } |
| 111 | + |
| 112 | + int[] minCost = new int[n]; |
| 113 | + Arrays.fill(minCost, Integer.MAX_VALUE); |
| 114 | + PriorityQueue<State> pq = new PriorityQueue<>(Comparator.comparingInt(s -> s.cost)); |
| 115 | + pq.offer(new State(src, 0, 0)); |
| 116 | + minCost[src] = 0; |
| 117 | + |
| 118 | + while (!pq.isEmpty()) { |
| 119 | + State s = pq.poll(); |
| 120 | + if (s.stops() > k + 1 || s.cost() > minCost[s.node()]) continue; |
| 121 | + minCost[s.node()] = s.cost(); |
| 122 | + |
| 123 | + if (s.node() == dst) return s.cost(); |
| 124 | + |
| 125 | + for (int[] edge : graph.getOrDefault(s.node(), List.of())) { |
| 126 | + pq.offer(new State(edge[0], s.cost() + edge[1], s.stops() + 1)); |
| 127 | + } |
| 128 | + } |
| 129 | + return -1; |
| 130 | + } |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +```cpp |
| 135 | +#include <queue> |
| 136 | +#include <unordered_map> |
| 137 | +#include <vector> |
| 138 | + |
| 139 | +class CheapestFlightsWithKStops { |
| 140 | +public: |
| 141 | + /** |
| 142 | + * @param n number of cities (0..n-1) |
| 143 | + * @param flights flights[i] = [from, to, price] |
| 144 | + * @param src departure city |
| 145 | + * @param dst arrival city |
| 146 | + * @param k max intermediate stops allowed |
| 147 | + * @return cheapest price with at most k stops, or -1 |
| 148 | + */ |
| 149 | + int findCheapestPrice(int n, std::vector<std::vector<int>>& flights, int src, int dst, int k) { |
| 150 | + std::unordered_map<int, std::vector<std::pair<int, int>>> graph; |
| 151 | + for (auto& f : flights) graph[f[0]].push_back({f[1], f[2]}); |
| 152 | + |
| 153 | + std::vector<int> minCost(n, INT_MAX); |
| 154 | + // min-heap ordered by (cost, node, stops) |
| 155 | + auto cmp = [](const std::array<int,3>& a, const std::array<int,3>& b) { return a[0] > b[0]; }; |
| 156 | + std::priority_queue<std::array<int,3>, std::vector<std::array<int,3>>, decltype(cmp)> pq(cmp); |
| 157 | + pq.push({0, src, 0}); |
| 158 | + minCost[src] = 0; |
| 159 | + |
| 160 | + while (!pq.empty()) { |
| 161 | + auto [cost, node, stops] = pq.top(); |
| 162 | + pq.pop(); |
| 163 | + |
| 164 | + if (stops > k + 1 || cost > minCost[node]) continue; |
| 165 | + minCost[node] = cost; |
| 166 | + |
| 167 | + if (node == dst) return cost; |
| 168 | + |
| 169 | + for (auto& [next, price] : graph[node]) { |
| 170 | + pq.push({cost + price, next, stops + 1}); |
| 171 | + } |
| 172 | + } |
| 173 | + return -1; |
| 174 | + } |
| 175 | +}; |
| 176 | +``` |
| 177 | + |
| 178 | +```python |
| 179 | +import heapq |
| 180 | + |
| 181 | +def find_cheapest_price(n: int, flights: list[list[int]], src: int, dst: int, k: int) -> int: |
| 182 | + """ |
| 183 | + @param n: number of cities (0..n-1) |
| 184 | + @param flights: flights[i] = [from, to, price] |
| 185 | + @param src: departure city |
| 186 | + @param dst: arrival city |
| 187 | + @param k: max intermediate stops allowed |
| 188 | + @return: cheapest price with at most k stops, or -1 |
| 189 | + """ |
| 190 | + graph: dict[int, list[tuple[int, int]]] = {} |
| 191 | + for frm, to, price in flights: |
| 192 | + graph.setdefault(frm, []).append((to, price)) |
| 193 | + |
| 194 | + min_cost = [float("inf")] * n |
| 195 | + pq = [(0, src, 0)] # (cost, city, legs taken) |
| 196 | + min_cost[src] = 0 |
| 197 | + |
| 198 | + while pq: |
| 199 | + cost, node, stops = heapq.heappop(pq) |
| 200 | + |
| 201 | + if stops > k + 1 or cost > min_cost[node]: |
| 202 | + continue |
| 203 | + min_cost[node] = cost |
| 204 | + |
| 205 | + if node == dst: |
| 206 | + return cost |
| 207 | + |
| 208 | + for nxt, price in graph.get(node, []): |
| 209 | + heapq.heappush(pq, (cost + price, nxt, stops + 1)) |
| 210 | + |
| 211 | + return -1 |
| 212 | +``` |
| 213 | + |
| 214 | +```rust |
| 215 | +use std::cmp::Reverse; |
| 216 | +use std::collections::{BinaryHeap, HashMap}; |
| 217 | + |
| 218 | +impl Solution { |
| 219 | + /// @param n number of cities (0..n-1) |
| 220 | + /// @param flights flights[i] = [from, to, price] |
| 221 | + /// @param src departure city |
| 222 | + /// @param dst arrival city |
| 223 | + /// @param k max intermediate stops allowed |
| 224 | + /// @return cheapest price with at most k stops, or -1 |
| 225 | + pub fn find_cheapest_price(n: i32, flights: Vec<Vec<i32>>, src: i32, dst: i32, k: i32) -> i32 { |
| 226 | + let mut graph: HashMap<i32, Vec<(i32, i32)>> = HashMap::new(); |
| 227 | + for f in &flights { |
| 228 | + graph.entry(f[0]).or_default().push((f[1], f[2])); |
| 229 | + } |
| 230 | + |
| 231 | + let mut min_cost = vec![i32::MAX; n as usize]; |
| 232 | + // BinaryHeap is a max-heap; Reverse makes it a min-heap on (cost, node, stops) |
| 233 | + let mut pq = BinaryHeap::new(); |
| 234 | + pq.push(Reverse((0, src, 0))); |
| 235 | + min_cost[src as usize] = 0; |
| 236 | + |
| 237 | + while let Some(Reverse((cost, node, stops))) = pq.pop() { |
| 238 | + if stops > k + 1 || cost > min_cost[node as usize] { |
| 239 | + continue; |
| 240 | + } |
| 241 | + min_cost[node as usize] = cost; |
| 242 | + |
| 243 | + if node == dst { |
| 244 | + return cost; |
| 245 | + } |
| 246 | + |
| 247 | + if let Some(neighbors) = graph.get(&node) { |
| 248 | + for &(nxt, price) in neighbors { |
| 249 | + pq.push(Reverse((cost + price, nxt, stops + 1))); |
| 250 | + } |
| 251 | + } |
| 252 | + } |
| 253 | + -1 |
| 254 | + } |
| 255 | +} |
| 256 | +``` |
| 257 | + |
| 258 | +## Dry run |
| 259 | + |
| 260 | +**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`. |
| 261 | + |
| 262 | +``` |
| 263 | +pq: [(0,0,0)] (cost, city, stops) |
| 264 | +pop (0,0,0): explore 0 -> 1 (+100). pq: [(100,1,1)] |
| 265 | +pop (100,1,1): 1 <= k+1=2 ok. explore 1 -> 2 (+100 -> (200,2,2)), 1 -> 3 (+600 -> (700,3,2)) |
| 266 | + pq: [(200,2,2), (700,3,2)] |
| 267 | +pop (200,2,2): stops=2 > k+1=2? no (not greater). explore 2 -> 0 (already min), 2 -> 3 (+200 -> (400,3,3)) |
| 268 | + pq: [(400,3,3), (700,3,2)] |
| 269 | +pop (400,3,3): stops=3 > k+1=2 -> continue (over budget — the cheap 0->1->2->3 route dies here) |
| 270 | +pop (700,3,2): stops=2 <= 2, node==dst -> return 700 ✓ |
| 271 | +``` |
| 272 | + |
| 273 | +The dry run shows the entire point in one line: `(400,3,3)` — *cheaper* but *over budget* — is dropped, while `(700,3,2)` — *pricier* but *within budget* — is the answer. A plain Dijkstra would have returned 400. |
| 274 | + |
| 275 | +## Complexity |
| 276 | + |
| 277 | +**Time.** Each pushed state costs $O(\log)$ heap time; a city can be re-pushed with different stop counts (the cost prune is not strict here), worst case: |
| 278 | + |
| 279 | +$$ |
| 280 | +T(V, E, k) = O(E \cdot k \cdot \log(E \cdot k)) |
| 281 | +$$ |
| 282 | + |
| 283 | +**Space.** The heap plus the graph: |
| 284 | + |
| 285 | +$$ |
| 286 | +S(V, E) = O(V + E) |
| 287 | +$$ |
| 288 | + |
| 289 | +In practice (small `k`) this behaves like $O(E \log E)$. |
| 290 | + |
| 291 | +## Variants & follow-ups |
| 292 | + |
| 293 | +- **Theoretical honesty corner:** the cost prune (`cost > minCost[node]`) can, in adversarial cases, discard a *pricier-but-fewer-stops* prefix that was the only way to reach `dst` within budget. The bulletproof alternatives: (a) prune on `minStops` (fewest legs seen) instead of cost; (b) the $k+1$-layered Bellman-Ford, which never prunes on cost at all. Interviewers rarely push this deep — but naming it shows you know why the constraint breaks vanilla Dijkstra. |
| 294 | +- **Single-Threaded CPU** (`src/main/kotlin/heap/SingleThreadedCPU.kt`) — the same "state carries extra budget" idea applied to task scheduling. |
| 295 | +- **Network Delay Time / Dijkstra plain** — this page with `k = n-1` (budget never binds) degenerates to textbook Dijkstra. |
| 296 | +- **Interview follow-up:** "Why can't we just use `minCost` as a hard visited set?" Because the same city can be the right *intermediate* at different stop counts — a city reached once with 1 stop and once with 3 stops are different states with different futures. The stop counter is part of the identity, which is why it lives in the state tuple. |
0 commit comments