Cheapest Flights Within K Stops

Medium

๐Ÿ’ญ Introduction

Youโ€™re given n cities connected by flights. Each flight has a source city, destination city, and price. You need to find the cheapest price to fly from city src to city dst with at most K stops.

This is a constrained shortest path problem โ€” we need the cheapest path, but with a limit on the number of intermediate stops.

Unlike standard shortest path problems, we canโ€™t simply use Dijkstraโ€™s algorithm because we have an additional constraint: the number of stops. This makes the problem more challenging and interesting!

๐Ÿงฉ Problem Statement

LeetCode #787 โ€” Cheapest Flights Within K Stops

There are n cities connected by some number of flights. You are given an array flights where flights[i] = [fromi, toi, pricei] indicates that there is a flight from city fromi to city toi with cost pricei.

You are also given three integers src, dst, and k, return the cheapest price from src to dst with at most k stops. If there is no such route, return -1.

๐Ÿ” Example 1:

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
Output: 700

Explanation:
Graph:
    0 --100--> 1 --600--> 3
    |          |
   100        100
    |          |
    v          v
    2 --------200-----> 3

Optimal path: 0 โ†’ 1 โ†’ 3 (cost = 700, 1 stop)
Alternative: 0 โ†’ 1 โ†’ 2 โ†’ 3 (cost = 400, but 2 stops > k=1)

๐Ÿ” Example 2:

Input: n = 3, flights = [[0,1,100],[1,2,100],[0,2,500]], 
       src = 0, dst = 2, k = 1
Output: 200

Explanation:
Graph:
    0 --100--> 1 --100--> 2
    |                     ^
    +---------500---------+

Optimal path: 0 โ†’ 1 โ†’ 2 (cost = 200, 1 stop)
Direct path: 0 โ†’ 2 (cost = 500, 0 stops)

๐Ÿ” Example 3:

Input: n = 3, flights = [[0,1,100],[1,2,100],[0,2,500]], 
       src = 0, dst = 2, k = 0
Output: 500

Explanation:
With k=0 (no stops allowed), only direct flight: 0 โ†’ 2 (cost = 500)

๐Ÿง  Step 1: Understanding the Problem

Letโ€™s break down what makes this problem unique:

Key Observations:

  1. Weighted directed graph: Flights have costs and directions
  2. Constraint on path length: At most K stops (K+1 edges)
  3. Not just shortest path: Need cheapest within constraint
  4. Trade-off: Cheaper path might require more stops

Why standard Dijkstra doesnโ€™t work:

  • Dijkstra finds absolute shortest path
  • Doesnโ€™t consider the stop constraint
  • Might reject a path thatโ€™s longer but has fewer stops

The Challenge:

Cheaper path with more stops vs Expensive path with fewer stops

We need to track both cost AND number of stops!

๐Ÿค” Step 2: BFS Approach (Level-by-Level)

BFS naturally processes paths by number of stops:

from collections import deque, defaultdict

class Solution:
    def findCheapestPrice(self, n: int, flights: List[List[int]], 
                         src: int, dst: int, k: int) -> int:
        # Build adjacency list
        graph = defaultdict(list)
        for from_city, to_city, price in flights:
            graph[from_city].append((to_city, price))
        
        # BFS: (city, cost)
        queue = deque([(src, 0)])
        min_cost = float('inf')
        
        # Track minimum cost to reach each city at each level
        stops = 0
        
        while queue and stops <= k:
            size = len(queue)
            
            for _ in range(size):
                city, cost = queue.popleft()
                
                # If we reached destination
                if city == dst:
                    min_cost = min(min_cost, cost)
                    continue
                
                # Explore neighbors
                for next_city, price in graph[city]:
                    new_cost = cost + price
                    
                    # Only continue if cost is reasonable
                    if new_cost < min_cost:
                        queue.append((next_city, new_cost))
            
            stops += 1
        
        return min_cost if min_cost != float('inf') else -1

Problem: This explores too many paths! We need pruning.

โฑ๏ธ Time Complexity: $O(n^k)$ - Exponential in worst case

๐Ÿš€ Step 3: BFS with Pruning (Optimized)

Track the minimum cost to reach each city at each level:

from collections import deque, defaultdict

class Solution:
    def findCheapestPrice(self, n: int, flights: List[List[int]], 
                         src: int, dst: int, k: int) -> int:
        # Build graph
        graph = defaultdict(list)
        for from_city, to_city, price in flights:
            graph[from_city].append((to_city, price))
        
        # Track minimum cost to reach each city
        min_cost_to_city = [float('inf')] * n
        min_cost_to_city[src] = 0
        
        # BFS: (city, cost)
        queue = deque([(src, 0)])
        stops = 0
        
        while queue and stops <= k:
            size = len(queue)
            
            for _ in range(size):
                city, cost = queue.popleft()
                
                # Explore neighbors
                for next_city, price in graph[city]:
                    new_cost = cost + price
                    
                    # Only continue if we found a cheaper path
                    if new_cost < min_cost_to_city[next_city]:
                        min_cost_to_city[next_city] = new_cost
                        queue.append((next_city, new_cost))
            
            stops += 1
        
        return min_cost_to_city[dst] if min_cost_to_city[dst] != float('inf') else -1

โฑ๏ธ Time Complexity: $O(k \times E)$ where E = number of flights

๐Ÿ“ฆ Space Complexity: $O(n + E)$

โš™๏ธ Step 4: Modified Dijkstra with Stop Constraint

Use priority queue but track stops:

import heapq
from collections import defaultdict

class Solution:
    def findCheapestPrice(self, n: int, flights: List[List[int]], 
                         src: int, dst: int, k: int) -> int:
        # Build graph
        graph = defaultdict(list)
        for from_city, to_city, price in flights:
            graph[from_city].append((to_city, price))
        
        # Priority queue: (cost, city, stops)
        pq = [(0, src, 0)]
        
        # Track (city, stops) -> min_cost
        visited = {}
        
        while pq:
            cost, city, stops = heapq.heappop(pq)
            
            # If we reached destination
            if city == dst:
                return cost
            
            # If we've used all stops
            if stops > k:
                continue
            
            # Skip if we've visited this (city, stops) with lower cost
            if (city, stops) in visited and visited[(city, stops)] <= cost:
                continue
            
            visited[(city, stops)] = cost
            
            # Explore neighbors
            for next_city, price in graph[city]:
                new_cost = cost + price
                new_stops = stops + 1
                
                # Only add if we haven't found a better path
                if (next_city, new_stops) not in visited or \
                   visited[(next_city, new_stops)] > new_cost:
                    heapq.heappush(pq, (new_cost, next_city, new_stops))
        
        return -1

โฑ๏ธ Time Complexity: $O((n + E) \times k \log(n \times k))$

๐Ÿ“ฆ Space Complexity: $O(n \times k)$

๐Ÿงฎ Step 5: Dynamic Programming Approach

Use DP where dp[i][j] = minimum cost to reach city j using at most i stops:

class Solution:
    def findCheapestPrice(self, n: int, flights: List[List[int]], 
                         src: int, dst: int, k: int) -> int:
        # dp[stops][city] = min cost to reach city with 'stops' stops
        dp = [[float('inf')] * n for _ in range(k + 2)]
        
        # Base case: 0 cost to reach source
        for i in range(k + 2):
            dp[i][src] = 0
        
        # Fill DP table
        for stops in range(1, k + 2):
            for from_city, to_city, price in flights:
                # Update cost to reach to_city
                dp[stops][to_city] = min(
                    dp[stops][to_city],
                    dp[stops - 1][from_city] + price
                )
        
        # Find minimum cost with at most k stops
        result = min(dp[stops][dst] for stops in range(k + 2))
        return result if result != float('inf') else -1

โฑ๏ธ Time Complexity: $O(k \times E)$

๐Ÿ“ฆ Space Complexity: $O(k \times n)$

๐ŸŽฏ Step 6: Space-Optimized DP

Only need previous and current row:

class Solution:
    def findCheapestPrice(self, n: int, flights: List[List[int]], 
                         src: int, dst: int, k: int) -> int:
        # Only need two rows: previous and current
        prev = [float('inf')] * n
        curr = [float('inf')] * n
        prev[src] = 0
        
        # Iterate k+1 times (k stops = k+1 edges)
        for _ in range(k + 1):
            curr = prev.copy()  # Start with previous values
            
            for from_city, to_city, price in flights:
                curr[to_city] = min(curr[to_city], prev[from_city] + price)
            
            prev = curr
        
        return curr[dst] if curr[dst] != float('inf') else -1

โฑ๏ธ Time Complexity: $O(k \times E)$

๐Ÿ“ฆ Space Complexity: $O(n)$ โ€” Optimal!

๐Ÿงช Step 7: Complete Example Trace

Letโ€™s trace through Example 1: n=4, flights=[[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], src=0, dst=3, k=1

Using DP approach:

Stops City 0 City 1 City 2 City 3 Flights Processed
0 0 โˆž โˆž โˆž Initial
1 0 100 โˆž โˆž 0โ†’1 (100)
2 0 100 200 700 1โ†’2 (100), 1โ†’3 (600)

Result: 700 (path: 0 โ†’ 1 โ†’ 3)

Note: Path 0 โ†’ 1 โ†’ 2 โ†’ 3 (cost 400) requires 2 stops, which exceeds k=1.

๐ŸŽฏ Key Insights

  1. Constraint changes algorithm: Canโ€™t use standard Dijkstra due to stop limit

  2. Multiple approaches work: BFS, modified Dijkstra, and DP all viable

  3. Trade-offs: BFS is simpler, DP is more efficient, Dijkstra is flexible

  4. State definition: Need to track both cost and stops

  5. Pruning is crucial: Without it, search space explodes

๐Ÿ“Š Complexity Summary

Approach Time Space Notes
Naive BFS $O(n^k)$ $O(n)$ Too slow
BFS with Pruning $O(k \times E)$ $O(n)$ Good for small k
Modified Dijkstra $O((n+E) \times k \log(nk))$ $O(n \times k)$ Flexible
DP (2D) $O(k \times E)$ $O(k \times n)$ Clear logic
DP (Optimized) $O(k \times E)$ $O(n)$ Best space

Where n = number of cities, E = number of flights, k = max stops

๐ŸŒŸ Edge Cases to Consider

  1. No path exists: Return -1
  2. Direct flight: k=0, only direct flights allowed
  3. Source equals destination: Return 0
  4. k >= n-2: Effectively no constraint (can visit all cities)
  5. Multiple paths with same cost: Any is valid
  6. Cycles in graph: DP handles naturally

๐Ÿ’ก Common Mistakes

  1. Using standard Dijkstra: Doesnโ€™t respect stop constraint
  2. Not tracking stops: Loses the constraint information
  3. Wrong stop counting: k stops = k+1 edges
  4. Not pruning: Explores too many paths
  5. Off-by-one errors: Careful with k vs k+1

๐Ÿงช Test Cases

# Test Case 1: Standard case
n = 4
flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]]
src, dst, k = 0, 3, 1
# Expected: 700

# Test Case 2: Direct flight cheaper
n = 3
flights = [[0,1,100],[1,2,100],[0,2,500]]
src, dst, k = 0, 2, 0
# Expected: 500

# Test Case 3: No path
n = 3
flights = [[0,1,100]]
src, dst, k = 0, 2, 1
# Expected: -1

# Test Case 4: Multiple stops needed
n = 5
flights = [[0,1,5],[1,2,5],[2,3,5],[3,4,5]]
src, dst, k = 0, 4, 3
# Expected: 20

# Test Case 5: Source equals destination
n = 3
flights = [[0,1,100]]
src, dst, k = 0, 0, 0
# Expected: 0

๐Ÿ” Variations of This Problem

  1. Network Delay Time - No stop constraint, simpler
  2. Path with Maximum Probability - Maximize instead of minimize
  3. Minimum Cost to Reach City - Similar constraint problem
  4. Shortest Path with Alternating Colors - Additional constraints

๐Ÿ† Why This Problem Matters

  1. Real-world application: Actual flight booking systems
  2. Constrained optimization: Common in many domains
  3. Algorithm adaptation: Shows how to modify standard algorithms
  4. Interview favorite: Tests multiple concepts
  5. Trade-off analysis: Cost vs constraint balance

๐ŸŽ“ Key Takeaways

  • Constraints matter: Stop limit changes the algorithm choice
  • Multiple solutions: BFS, Dijkstra, and DP all work
  • State tracking: Need to track both cost and stops
  • Pruning essential: Reduces search space significantly
  • DP is elegant: Clean solution with good complexity

๐Ÿ“ Interview Tips

  1. Clarify constraints: Confirm k means stops, not edges
  2. Discuss approaches: Mention BFS, Dijkstra, and DP
  3. Start simple: Begin with BFS, optimize if needed
  4. Handle edge cases: No path, k=0, src=dst
  5. Explain trade-offs: Why each approach works
  6. Test thoroughly: Walk through a small example

๐Ÿ”ง Python Tips

# Building adjacency list
from collections import defaultdict
graph = defaultdict(list)
for u, v, w in flights:
    graph[u].append((v, w))

# BFS with level tracking
from collections import deque
queue = deque([(src, 0)])  # (city, cost)
stops = 0

# Priority queue for Dijkstra
import heapq
pq = [(0, src, 0)]  # (cost, city, stops)

# DP initialization
dp = [[float('inf')] * n for _ in range(k + 2)]
dp[0][src] = 0

# Space-optimized DP
prev = [float('inf')] * n
prev[src] = 0

๐ŸŽฏ Problem-Solving Pattern

This problem follows the Constrained Shortest Path pattern:

  1. Identify: Shortest path with additional constraint
  2. Choose approach: BFS for simplicity, DP for efficiency
  3. Track state: Both cost and constraint (stops)
  4. Prune aggressively: Avoid exploring invalid paths
  5. Handle edge cases: No path, boundary conditions

This pattern applies to many optimization problems with constraints!