Skip to content

Commit 8a1c540

Browse files
committed
Complete Chapter 6 (Graphs): BFS on implicit graphs, clone, topo sort, bipartite, Dijkstra+K-stops, Kruskal MST, Kosaraju SCC; fix CheapestFlightsWithKStops initial state; anchor ignore patterns
1 parent 8cf4a26 commit 8a1c540

12 files changed

Lines changed: 2094 additions & 3 deletions

‎.gitignore‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ build/
1818
.DS_Store
1919

2020
# Misc binaries
21-
main
22-
minikube-darwin-amd64
21+
/main
22+
/minikube-darwin-amd64
2323

2424
# Scratch / local-only files (keep out of the repo)
2525
/Coding Interview Fight Club.md

‎CodingInterviewFightClub/src/SUMMARY.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,13 @@
7474
- [5.4 Binary Tree Maximum Path Sum](ch05-trees/binary-tree-maximum-path-sum.md)
7575
- [5.5 Serialize And Deserialize Binary Tree](ch05-trees/serialize-and-deserialize-binary-tree.md)
7676
- [5.6 Binary Tree Inorder Traversal (Iterative)](ch05-trees/binary-tree-inorder-traversal-iterative.md)
77+
78+
- [6. Graphs](ch06-graphs/index.md)
79+
- [6.0 Pattern Primer: The Seven Engines](ch06-graphs/pattern-primer.md)
80+
- [6.1 Word Ladder](ch06-graphs/word-ladder.md)
81+
- [6.2 Clone Graph](ch06-graphs/clone-graph.md)
82+
- [6.3 Course Schedule II](ch06-graphs/course-schedule-ii.md)
83+
- [6.4 Is Graph Bipartite](ch06-graphs/is-graph-bipartite.md)
84+
- [6.5 Cheapest Flights With K Stops](ch06-graphs/cheapest-flights-with-k-stops.md)
85+
- [6.6 Min Cost To Connect All Points](ch06-graphs/min-cost-to-connect-all-points.md)
86+
- [6.7 Strongly Connected Components](ch06-graphs/strongly-connected-components.md)
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
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

Comments
 (0)