Question: Shipping route cost paths
Companies: Stripe
Difficulty: Medium
Problem statement
A parcel carrier publishes its shipping routes as a list of strings. Each string has the form source:target:method:cost:
-
sourceandtargetare country codes. A route ships one way only. -
methodis the carrier or mode (for exampleAir,Sea,Rail). The same pair of countries can have several routes with different methods. -
costis a non-negative integer.
Implement a class ShippingNetwork(routes: list[str]) that answers these questions in parts:
-
direct_cost(src, dst) -> int | None. The cheapest single route fromsrctodst, orNoneif there is none. -
all_paths(src, dst, max_legs=2) -> list[Path]. Every way to ship fromsrctodstusing at mostmax_legsroutes (so by default, direct or through one intermediate country). A path must not visit a country twice. Each path lists its legs (source, target, method, cost) and its total cost. Return the paths sorted by total cost, cheapest first. -
cheapest_path(src, dst) -> Path | None. The cheapest path with any number of stops, orNoneifdstcannot be reached.
Reject malformed route strings with an error.
Example
routes = ["JP:DE:Air:9", "JP:SG:Sea:2", "SG:DE:Air:4", "JP:DE:Sea:7"]
-
direct_cost("JP", "DE")returns7. -
all_paths("JP", "DE")returns three paths:JP -Sea-> SG -Air-> DE(6),JP -Sea-> DE(7),JP -Air-> DE(9). -
direct_cost("DE", "JP")returnsNone, because routes are one way.
Example with more stops
routes = ["A:B:x:10", "A:C:y:1", "C:D:z:1", "D:B:w:1"]
-
all_paths("A", "B")returns only the direct path (10). The cheaper path needs two stops. -
cheapest_path("A", "B")returnsA -> C -> D -> Bwith cost 3.
Talk through how your design would change if the interviewer adds a limit on the number of stops, or bans some methods or countries.
Approach
Clarify first. Each route is a string source:target:method:cost. Settle these before coding:
-
Direction. A route ships one way.
US:UKdoes not give youUK:US. Ask; most interviewers say directed. -
Parallel routes. Two carriers can serve the same pair (
JP:DE:Air:9andJP:DE:Sea:7). Keep both. They are different paths, and the method is part of the answer. - Costs. Non-negative integers. Reject malformed strings rather than guessing.
- What a path is. A list of legs. Report each leg's method and cost plus the total. No country appears twice in one path.
The question comes in parts. Build the parser and the graph once, then answer each part on top of it.
Part 1: direct cost. Group legs by source in a dict of lists (an adjacency list). The direct cost is the cheapest leg from src whose target is dst, or None. A dict keyed by (src, dst) makes this O(1), but the adjacency list serves every later part, so start there.
Part 2: all paths with at most one stop. The brute force is to loop: every direct leg, then every leg src -> mid followed by every leg mid -> dst. That is fine for exactly one stop, but it does not extend. Write a depth-first search with a leg limit instead. Carry the current legs and the set of countries on the path. When you reach dst, record a copy and stop going deeper on that branch. When you have used max_legs, stop. Sort the result by total cost so the cheapest comes first.
The invariant: seen holds exactly the countries on the current path. Adding on the way down and removing on the way back keeps that true, so no path revisits a country and every simple path within the limit is found once per combination of methods.
Part 3: cheapest path, any number of stops. Enumerating all paths is exponential. Costs are non-negative, so use Dijkstra. Keep the best known cost to each country and the leg that reached it. Pop the cheapest frontier country; skip stale heap entries; relax its outgoing legs. Stop when dst is popped, because its cost can no longer improve. Walk the via links back from dst to rebuild the legs. Parallel routes need no special case: relaxation keeps the cheapest leg automatically.
Code
from __future__ import annotations
import heapq
from collections import defaultdict
from dataclasses import dataclass
@dataclass(frozen=True)
class Leg:
source: str
target: str
method: str
cost: int
@dataclass(frozen=True)
class Path:
legs: tuple[Leg, ...]
@property
def cost(self) -> int:
return sum(leg.cost for leg in self.legs)
def describe(self) -> str:
if not self.legs:
return "(empty)"
hops = [f"{l.source} -[{l.method} {l.cost}]-> " for l in self.legs]
return "".join(hops) + self.legs[-1].target + f" = {self.cost}"
class ShippingNetwork:
def __init__(self, routes: list[str]) -> None:
self.out: dict[str, list[Leg]] = defaultdict(list)
for raw in routes:
parts = raw.strip().split(":")
if len(parts) != 4:
raise ValueError(f"bad route: {raw!r}")
src, dst, method, cost = parts
amount = int(cost)
if amount < 0:
raise ValueError(f"negative cost: {raw!r}")
self.out[src].append(Leg(src, dst, method, amount))
# Part 1: cheapest single leg, or None.
def direct_cost(self, src: str, dst: str) -> int | None:
costs = [l.cost for l in self.out.get(src, []) if l.target == dst]
return min(costs) if costs else None
# Part 2: every path with at most `max_legs` legs, no country repeated.
def all_paths(self, src: str, dst: str, max_legs: int = 2) -> list[Path]:
found: list[Path] = []
legs: list[Leg] = []
seen = {src}
def dfs(node: str) -> None:
if node == dst and legs:
found.append(Path(tuple(legs)))
return
if len(legs) == max_legs:
return
for leg in self.out.get(node, []):
if leg.target in seen:
continue
seen.add(leg.target)
legs.append(leg)
dfs(leg.target)
legs.pop()
seen.discard(leg.target)
if src != dst:
dfs(src)
found.sort(key=lambda p: (p.cost, len(p.legs), p.describe()))
return found
# Part 3: cheapest path with any number of legs (Dijkstra).
def cheapest_path(self, src: str, dst: str) -> Path | None:
if src == dst:
return Path(())
best: dict[str, int] = {src: 0}
via: dict[str, Leg] = {}
heap: list[tuple[int, str]] = [(0, src)]
while heap:
dist, node = heapq.heappop(heap)
if dist > best[node]:
continue
if node == dst:
break
for leg in self.out.get(node, []):
nd = dist + leg.cost
if nd < best.get(leg.target, nd + 1):
best[leg.target] = nd
via[leg.target] = leg
heapq.heappush(heap, (nd, leg.target))
if dst not in best:
return None
legs: list[Leg] = []
node = dst
while node != src:
leg = via[node]
legs.append(leg)
node = leg.source
return Path(tuple(reversed(legs)))
Example:
net = ShippingNetwork(["JP:DE:Air:9", "JP:SG:Sea:2", "SG:DE:Air:4", "JP:DE:Sea:7"])
net.direct_cost("JP", "DE") # 7
[p.describe() for p in net.all_paths("JP", "DE")]
# ['JP -[Sea 2]-> SG -[Air 4]-> DE = 6',
# 'JP -[Sea 7]-> DE = 7',
# 'JP -[Air 9]-> DE = 9']
net.cheapest_path("JP", "DE").cost # 6
Complexity
Let R be the number of routes, V the number of countries, and d the most legs leaving one country.
-
Build:
O(R)time and space. -
direct_cost:O(d)time,O(1)extra space.O(1)with a(src, dst)index. -
all_pathswithmax_legs = k:O(d^k * k)time in the worst case, since each of up tod^kpaths is copied when found. For one stop that isO(d^2). Space isO(k)for the recursion plus the output. -
cheapest_path:O((V + R) log R)time with a binary heap,O(V + R)space.
Test cases
Network A: ["JP:DE:Air:9", "JP:SG:Sea:2", "SG:DE:Air:4", "JP:DE:Sea:7", "SG:BR:Sea:6", "BR:DE:Rail:1", "DE:JP:Air:9"]
-
direct_cost("JP", "DE")returns7(the cheaper of two parallel routes). -
direct_cost("DE", "SG")returnsNone. -
all_paths("JP", "DE")returns totals[6, 7, 9]: via SG by Sea and Air, then direct Sea, then direct Air. -
all_paths("JP", "DE", max_legs=3)returns totals[6, 7, 9, 9], addingJP -> SG -> BR -> DE. -
cheapest_path("JP", "DE").costis6.
Network B: ["A:B:x:10", "A:C:y:1", "C:D:z:1", "D:B:w:1"]
-
cheapest_path("A", "B")isA -> C -> D -> Bwith cost3, cheaper than the direct10. -
all_paths("A", "B")(one stop max) returns only the direct path, cost10. -
cheapest_path("B", "A")returnsNone, because routes are one way.
Edge cases:
-
src == dst:all_pathsreturns[];cheapest_pathreturns an empty path with cost0. - Empty route list: every query returns
Noneor[]. -
"bad"or"A:B:x:-1": the constructor raisesValueError. - A cycle (
DE:JPabove) does not cause an infinite loop:seenblocks it in the search, and Dijkstra never improves a settled country.
Follow-ups
-
Fewest stops, or at most K stops at the lowest cost. Dijkstra ignores stop count. For "cheapest with at most K stops", run Bellman-Ford for K + 1 rounds, copying the distance array each round, or run Dijkstra on states
(country, legs used). Both areO(K * R). -
Preferred or banned methods and countries. Filter legs as you relax them (
if leg.method in allowed). Banned countries are just never pushed. Nothing else changes. - Undirected routes. Add the reverse leg in the constructor. The search code stays the same.
-
Price per parcel weight or currency. Make cost a function of the leg and the parcel, for example
base + per_kg * weight, or convert each leg into one currency before summing. Dijkstra still works as long as every leg cost stays non-negative. -
Many queries on a static network. Cache Dijkstra results per source, or precompute all pairs with Floyd-Warshall (
O(V^3)) whenVis small, so each query is a lookup.
Interviewer expectations
Mid-level (L4)
- Parses the strings cleanly and validates them; asks about direction and duplicates.
- Gets part 1 and part 2 working with readable code and sensible names.
- Returns paths with legs, methods and totals, not only a number.
Senior (L5)
- Builds one adjacency list that serves every part, and writes part 2 as a bounded DFS instead of nested loops.
- Reaches Dijkstra for part 3 unprompted, explains why non-negative costs make it correct, and rebuilds the path.
- States complexity per method and tests parallel routes, cycles and unreachable targets.
Staff (L6+)
- Separates parsing, the graph and the queries so new constraints (stop limits, method filters, weight pricing) slot in without rewrites.
- Explains when Dijkstra breaks (stop limits, negative adjustments such as discounts) and what replaces it.
- Talks about scale: caching per source, precomputing for a static network, and how to update when a carrier adds or drops a route.