title: Bus Routes
slug: bus-routes
difficulty: hard
leetcode_id: 815
leetcode_url: https://leetcode.com/problems/bus-routes/
categories:
  - graphs
  - hash-tables
  - arrays
patterns:
  - bfs

function_signature: "def num_buses_to_destination(routes: list[list[int]], source: int, target: int) -> int:"

test_cases:
  visible:
    - input: { routes: [[1, 2, 7], [3, 6, 7]], source: 1, target: 6 }
      expected: 2
    - input: { routes: [[7, 12], [4, 5, 15], [6], [15, 19], [9, 12, 13]], source: 15, target: 12 }
      expected: -1
  hidden:
    - input: { routes: [[1, 2, 3]], source: 1, target: 1 }
      expected: 0
    - input: { routes: [[1, 2, 3]], source: 1, target: 3 }
      expected: 1
    - input: { routes: [[1, 2], [2, 3], [3, 4]], source: 1, target: 4 }
      expected: 3
    - input: { routes: [[1, 5], [5, 10], [10, 20]], source: 1, target: 20 }
      expected: 3
    - input: { routes: [[1, 2, 3], [4, 5, 6]], source: 1, target: 6 }
      expected: -1
    - input: { routes: [[1, 2, 3, 4, 5]], source: 1, target: 5 }
      expected: 1

description: |
  You are given an array `routes` representing bus routes where `routes[i]` is a bus route that the i<sup>th</sup> bus repeats forever.

  For example, if `routes[0] = [1, 5, 7]`, this means that the 0<sup>th</sup> bus travels in the sequence `1 -> 5 -> 7 -> 1 -> 5 -> 7 -> 1 -> ...` forever.

  You will start at the bus stop `source` (you are not on any bus initially), and you want to go to the bus stop `target`. You can travel between bus stops by buses only.

  Return *the least number of buses you must take to travel from* `source` *to* `target`. Return `-1` if it is not possible.

constraints: |
  - `1 <= routes.length <= 500`
  - `1 <= routes[i].length <= 10^5`
  - All the values of `routes[i]` are **unique**
  - `sum(routes[i].length) <= 10^5`
  - `0 <= routes[i][j] < 10^6`
  - `0 <= source, target < 10^6`

examples:
  - input: "routes = [[1,2,7],[3,6,7]], source = 1, target = 6"
    output: "2"
    explanation: "The best strategy is take the first bus to the bus stop 7, then take the second bus to the bus stop 6."
  - input: "routes = [[7,12],[4,5,15],[6],[15,19],[9,12,13]], source = 15, target = 12"
    output: "-1"
    explanation: "There is no way to reach stop 12 from stop 15 using the available bus routes."

explanation:
  intuition: |
    Imagine you're at a bus station looking at a transit map. Each bus route is a loop connecting certain stops, and you want to find the **minimum number of buses** to reach your destination.

    The key insight is to think about this problem at the **bus level**, not the stop level. Why? Because once you board a bus, you can reach *any* stop on that route without taking another bus. The cost comes from **switching buses**, not from travelling between stops.

    Think of it like this: each bus route is a "super-node" that connects all its stops. Your goal is to find the shortest path from any route containing the `source` stop to any route containing the `target` stop.

    This naturally suggests **BFS (Breadth-First Search)**: start from all buses that pass through the source, explore all buses you can transfer to at one level, then two levels, and so on. BFS guarantees that when you first reach a bus containing the target, you've found the minimum number of buses.

  approach: |
    We solve this using **BFS on buses** with a stop-to-buses mapping:

    **Step 1: Handle the edge case**

    - If `source == target`, return `0` immediately (no buses needed)

    &nbsp;

    **Step 2: Build a stop-to-buses mapping**

    - Create a dictionary where each stop maps to the list of bus indices that visit it
    - This allows O(1) lookup to find which buses you can catch at any stop

    &nbsp;

    **Step 3: Initialise BFS**

    - Start with all buses that contain the `source` stop
    - Add these to a queue with distance `1` (taking one bus)
    - Mark these buses as visited to avoid reprocessing

    &nbsp;

    **Step 4: BFS traversal**

    - For each bus in the queue:
      - Check all stops on this bus route
      - If any stop equals `target`, return the current bus count
      - For each stop, find all other buses that visit it (potential transfers)
      - Add unvisited buses to the queue with distance + 1

    &nbsp;

    **Step 5: Return the result**

    - If BFS completes without finding `target`, return `-1`

    &nbsp;

    This approach is efficient because we explore buses level by level. The first time we find a bus containing the target, we've found the shortest path.

  common_pitfalls:
    - title: BFS on Stops Instead of Buses
      description: |
        A natural first instinct is to run BFS on stops: from the source stop, explore all reachable stops, then their neighbours, and so on.

        The problem with this approach is that it doesn't correctly count "number of buses". Moving between stops on the *same* bus shouldn't increment the counter, but standard BFS on stops would treat each edge equally.

        While this can be fixed with careful bookkeeping, it's much cleaner to run BFS on buses directly, where each level naturally represents one bus taken.
      wrong_approach: "BFS where each stop is a node"
      correct_approach: "BFS where each bus route is a node"

    - title: Not Handling source == target
      description: |
        If `source` and `target` are the same stop, the answer is `0` — you don't need any bus. This edge case should be checked before building any data structures.

        Missing this check would cause the algorithm to incorrectly return `1` (if source is on some bus route) or even error out.
      wrong_approach: "Starting BFS without checking if already at target"
      correct_approach: "Return 0 immediately if source == target"

    - title: Revisiting Buses
      description: |
        Without marking buses as visited, you might process the same bus multiple times from different stops. This leads to:
        - Incorrect distance counting (processing a bus at level 3 when it was already found at level 1)
        - TLE due to exponential exploration

        Always mark a bus as visited when first adding it to the queue, not when processing it.
      wrong_approach: "No visited set for buses"
      correct_approach: "Mark buses visited when enqueuing"

    - title: Building Stop Adjacency Graph
      description: |
        Some solutions try to build a graph where stops are nodes connected if they're on the same route. With up to `10^6` stops and routes containing `10^5` stops, this could create O(n^2) edges per route — far too slow.

        The stop-to-buses mapping avoids this by keeping the mapping sparse.
      wrong_approach: "Creating edges between all stops on each route"
      correct_approach: "Map stops to bus indices instead"

  key_takeaways:
    - "**Graph abstraction**: The key insight is treating buses (not stops) as nodes — this aligns the BFS level with the metric we're minimising"
    - "**BFS for shortest path**: When finding the minimum number of steps/transfers, BFS is the go-to algorithm because it explores level by level"
    - "**Mapping for efficiency**: The stop-to-buses mapping enables O(1) transfer lookups without building a dense graph"
    - "**Related problems**: This pattern applies to word ladders, gene mutations, and other problems where you traverse between sets of connected items"

  time_complexity: "O(N * M) where N is the number of routes and M is the total number of stops across all routes. We build the mapping in O(M) and BFS visits each bus at most once, processing all its stops."
  space_complexity: "O(N + M). The stop-to-buses mapping stores each (stop, bus) pair once, and we need O(N) for the visited set and queue."

solutions:
  - approach_name: BFS on Buses
    is_optimal: true
    code: |
      from collections import deque, defaultdict

      def num_buses_to_destination(routes: list[list[int]], source: int, target: int) -> int:
          # Edge case: already at destination
          if source == target:
              return 0

          # Build mapping: stop -> list of bus indices
          stop_to_buses = defaultdict(list)
          for bus_idx, route in enumerate(routes):
              for stop in route:
                  stop_to_buses[stop].append(bus_idx)

          # BFS initialisation: start with all buses containing source
          visited_buses = set()
          queue = deque()

          for bus_idx in stop_to_buses[source]:
              visited_buses.add(bus_idx)
              queue.append((bus_idx, 1))  # (bus index, number of buses taken)

          # BFS traversal
          while queue:
              bus_idx, num_buses = queue.popleft()

              # Check all stops on this bus route
              for stop in routes[bus_idx]:
                  # Found the target!
                  if stop == target:
                      return num_buses

                  # Find all buses we can transfer to at this stop
                  for next_bus in stop_to_buses[stop]:
                      if next_bus not in visited_buses:
                          visited_buses.add(next_bus)
                          queue.append((next_bus, num_buses + 1))

          # Target not reachable
          return -1
    explanation: |
      **Time Complexity:** O(N * M) — We iterate through all stops to build the mapping, and BFS processes each bus once, checking all its stops.

      **Space Complexity:** O(N + M) — The mapping stores each stop-bus pair, plus O(N) for visited set and queue.

      We treat each bus route as a node in our BFS graph. Starting from buses containing the source, we explore transfers level by level. The first bus containing the target gives us the minimum count.

  - approach_name: BFS with Stop-Level Tracking
    is_optimal: false
    code: |
      from collections import deque, defaultdict

      def num_buses_to_destination(routes: list[list[int]], source: int, target: int) -> int:
          if source == target:
              return 0

          # Build stop to buses mapping
          stop_to_buses = defaultdict(set)
          for bus_idx, route in enumerate(routes):
              for stop in route:
                  stop_to_buses[stop].add(bus_idx)

          # BFS on stops, but track which bus we're on
          visited_stops = {source}
          visited_buses = set()
          queue = deque([(source, 0)])  # (stop, buses_taken)

          while queue:
              stop, buses = queue.popleft()

              # Try each bus at this stop
              for bus_idx in stop_to_buses[stop]:
                  if bus_idx in visited_buses:
                      continue
                  visited_buses.add(bus_idx)

                  # Visit all stops on this bus
                  for next_stop in routes[bus_idx]:
                      if next_stop == target:
                          return buses + 1
                      if next_stop not in visited_stops:
                          visited_stops.add(next_stop)
                          queue.append((next_stop, buses + 1))

          return -1
    explanation: |
      **Time Complexity:** O(N * M) — Similar to the optimal solution.

      **Space Complexity:** O(N + S) where S is the number of unique stops — We track both visited stops and visited buses.

      This alternative approach runs BFS on stops but correctly counts buses by marking entire bus routes as visited. It's slightly less elegant but demonstrates that stop-level BFS can work with proper bookkeeping.
