Question: Fastest single-mode commute on a city grid
Companies: Databricks
Difficulty: Medium
Problem statement
You are planning a commute across a city drawn as a 2D grid of characters:
-
'S': your starting point (exactly one). -
'D': your destination (exactly one). -
'1'to'k': a block that only transport modeican use. -
'X': a blocked cell.
You are also given two arrays of length k. time[i-1] and cost[i-1] are the minutes and dollars that mode i spends per block.
You pick one mode for the whole trip and cannot switch. Moves go up, down, left or right. A trip in mode i may step only on S, D and cells labelled i. Its total time is (blocks travelled) × time[i-1], and its total cost is (blocks travelled) × cost[i-1]. S and D do not count as blocks.
Implement optimal_commute(grid: list[str], time: list[int], cost: list[int]) -> int. It returns the mode number with the smallest total time. Break ties by smaller total cost, then by smaller mode number. Return -1 if no single mode can reach D.
Constraints: 1 <= rows, cols <= 500, 1 <= k <= 9, 1 <= time[i], cost[i] <= 1000.
Example 1
grid = ["S2D",
"1X1",
"111"]
time = [1, 9], cost = [9, 1]
Mode 1 goes around the wall through 5 blocks, for time 5. Mode 2 uses 1 block, for time 9. Answer: 1.
Example 2
Same grid, time = [9, 2], cost = [1, 1]. Mode 1 takes 45 and mode 2 takes 2. Answer: 2.
Example 3
grid = ["S12D"]. Reaching D needs both modes, and switching is not allowed. Answer: -1.
Approach
Each digit cell belongs to exactly one mode, and you may not change modes mid-trip. So a trip in mode m is a path from S to D that only steps on S, D and cells labelled m. Every block in mode m costs the same time[m] and cost[m]. That means the trip's time and cost are both blocks * time[m] and blocks * cost[m], where blocks is the number of mode cells on the path. Fewer blocks is better on both counts. The question for each mode becomes: what is the shortest unweighted path from S to D through that mode's cells?
Brute force. DFS over all simple paths for each mode and keep the shortest. This is exponential. Mention it only to dismiss it.
BFS per mode. For each mode m, run BFS from S. A neighbour is passable if it is D or a cell labelled m. X, other digits and out-of-bounds cells are walls. The first time BFS reaches D, the distance d counts the steps. The blocks travelled are the d - 1 cells strictly between S and D. Score the mode as (blocks * time[m], blocks * cost[m], m) and take the minimum tuple. That ranks by fastest first, then cheapest, then lowest mode number. Modes that never reach D are skipped. Return -1 if no mode works.
Why it is cheaper than it looks. A naive bound is O(k * R * C). But BFS for mode m only enters cells labelled m (plus D). Each cell has a single label, so across all modes every cell is dequeued at most once, apart from S and D. The total work is O(R * C + k). You can also skip modes with no cell adjacent to S before running BFS.
Invariants.
- BFS dequeues cells in non-decreasing distance order, so the first arrival at
Dis the shortest path. - A cell is marked visited when it is enqueued, so it is never enqueued twice in one mode's search.
-
SandDare passable for every mode.Dis a goal and is never expanded.
Code
from collections import deque
from typing import List, Optional, Sequence, Tuple
DIRS = ((1, 0), (-1, 0), (0, 1), (0, -1))
def _find(grid: Sequence[str], ch: str) -> Tuple[int, int]:
for r, row in enumerate(grid):
c = row.find(ch)
if c != -1:
return r, c
raise ValueError(f"grid has no {ch!r}")
def _blocks_for_mode(grid: Sequence[str], mode: str,
start: Tuple[int, int]) -> Optional[int]:
"""Fewest mode cells between S and D using only `mode`, or None."""
rows, cols = len(grid), len(grid[0])
seen = {start}
queue = deque([(start[0], start[1], 0)])
while queue:
r, c, dist = queue.popleft()
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if not (0 <= nr < rows and 0 <= nc < cols) or (nr, nc) in seen:
continue
cell = grid[nr][nc]
if cell == "D":
return dist # dist mode cells were stepped on before D
if cell == mode:
seen.add((nr, nc))
queue.append((nr, nc, dist + 1))
return None
def optimal_commute(grid: Sequence[str], time: List[int],
cost: List[int]) -> int:
"""Return the 1-based mode with least total time, ties by cost then
lower mode number. Return -1 if no single mode reaches D."""
if len(time) != len(cost):
raise ValueError("time and cost must have the same length")
if not grid or not grid[0]:
return -1
start = _find(grid, "S")
best: Optional[Tuple[int, int, int]] = None
for m in range(1, len(time) + 1):
blocks = _blocks_for_mode(grid, str(m), start)
if blocks is None:
continue
key = (blocks * time[m - 1], blocks * cost[m - 1], m)
if best is None or key < best:
best = key
return best[2] if best else -1
if __name__ == "__main__":
city = [
"3333D",
"3XX1X",
"S1112",
"2XX2X",
"2222X",
]
# Modes 1 and 2 dead-end; mode 3 reaches D in 5 blocks.
assert optimal_commute(city, [1, 1, 9], [1, 1, 9]) == 3
assert optimal_commute(["S1D"], [5, 1], [1, 1]) == 1
assert optimal_commute(["S2D", "1X1", "111"], [1, 9], [9, 1]) == 1
assert optimal_commute(["S2D", "1X1", "111"], [9, 2], [1, 1]) == 2
assert optimal_commute(["S11", "22D"], [2, 2], [3, 1]) == 2
assert optimal_commute(["SD"], [3, 1], [1, 1]) == 1
assert optimal_commute(["SX", "XD"], [1], [1]) == -1
assert optimal_commute(["S12D"], [1, 1], [1, 1]) == -1
print("ok")
Complexity
- Time:
O(R * C + k). Each mode's BFS only enters cells with that mode's label, so every cell is processed by at most one search (Sis the start of allksearches). - Space:
O(R * C)for the visited set and queue of the largest single mode.
Test cases
-
["S1D"],time=[5,1],cost=[1,1]→1. Mode 2 has no cells, so only mode 1 reachesD. -
["S2D","1X1","111"],time=[1,9],cost=[9,1]→1. Mode 1 uses 5 blocks for time 5. Mode 2 uses 1 block for time 9. - Same grid,
time=[9,2],cost=[1,1]→2. Mode 2 takes 2, mode 1 takes 45. -
["S11","22D"],time=[2,2],cost=[3,1]→2. Mode 1 dead-ends, so mode 2 wins outright. -
["SD"],time=[3,1],cost=[1,1]→1. Zero blocks, so every mode ties at(0, 0)and the lowest mode number wins. -
["SX","XD"],time=[1],cost=[1]→-1. Fully blocked. -
["S12D"],time=[1,1],cost=[1,1]→-1. ReachingDneeds a mode switch, which is not allowed. - Time tie: two modes with equal
blocks * time→ the lowerblocks * costwins.
Follow-ups
-
Return the route too. Store a parent pointer per cell in the winning mode's BFS and walk back from
D. This addsO(R * C)space. -
Mode switches allowed, with a transfer penalty. Run Dijkstra on states
(r, c, mode). Moving within a mode coststime[mode]. Switching costs a fixed penalty. Use a lexicographic(time, cost)priority. That isO(R * C * log(R * C)), because each cell has one label and so only a constant number of states. - Per-cell weights (traffic, hills). Plain BFS is no longer correct. Use Dijkstra per mode, or 0-1 BFS if weights are 0/1.
-
Many queries on the same map (different
S/Dpairs or price lists). Precompute connected components per mode with union-find to answer "can mode m connect them?" inO(1). Cache BFS distances from popular sources. -
Optimise a weighted blend or a budget ("fastest under $X"). Each mode's path is fixed-length, so filter modes by
blocks * cost <= Xand then take the fastest. With mode switching this becomes a constrained shortest path. Discuss Pareto labels per state.
Interviewer expectations
Mid-level (L4)
- Parses the grid and finds
Scorrectly. Writes a correct BFS with bounds and visited checks. - Sees that a fixed mode makes time and cost proportional to path length.
- Handles the unreachable case and the tie-break, with a few hand-traced tests.
Senior (L5)
- Explains why BFS (not DFS) gives the shortest path, and counts blocks off by none, including the
Snext toDcase. - Encodes the ranking as a tuple comparison instead of nested ifs.
- Notes the tighter
O(R * C + k)bound, and moves cleanly to Dijkstra when weights or mode switches are added.
Staff (L6+)
- Asks the ambiguous questions early: are
S/Dcounted as blocks, may modes switch, what to return on a tie or no path. - Designs the switching follow-up as a state-space graph and reasons about its size.
- Discusses reuse across queries (component precompute, caching) and multi-objective trade-offs (Pareto fronts) without over-building the base answer.