Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Shortest Paths at Scale  ›  Lesson

Bellman-Ford & Negative Weights

Bellman-Ford & Negative Weights 30 minRelax every leg, round after round, until nothing moves
You're building a piece ofMeridian — the router
This piece — tariff_route(): Prices routes when some legs pay a rebate, and refuses when a loop of rebates would pay forever.
Scenario Some of Meridian's legs pay the van back: partner depots rebate a van that carries their returns. The tariff router finds the cheapest route when costs can go negative, and refuses to answer, rather than answer wrongly, when a loop of rebates would pay out forever.
Your task
Build tariff_route(legs, start, goal). `legs` is a directed edge list of [from, to, cost]; a rebate leg has a negative cost and the network may contain loops. Return {"cost": total, "path": stops} for the cheapest route (every test has exactly one), {"cost": None, "path": []} if `goal` can't be reached, or {"cost": "loop", "path": []} if a money-making loop reachable from `start` can also reach `goal`, so no cheapest cost exists. Example: tariff_route([["depot", "mill", 2], ["depot", "quay", 6], ["quay", "mill", -5], ["mill", "harbour", 3]], "depot", "harbour") → {"cost": 4, "path": ["depot", "quay", "mill", "harbour"]}.

Bellman-Ford & Negative Weights

In short: Bellman-Ford finds cheapest costs on a graph that may have both cycles and negative edges, by relaxing every leg, V − 1 times, in any order. After round k, every stop's cost is at most the cheapest route to it using at most k legs, so V − 1 rounds cover every simple route. One more round that still improves something proves a reachable loop makes money — there is no cheapest route to report, and the algorithm must say so.

A small table: 5 stops across the top, 3 rounds down the side, with round 3 unchanged and a highlighted insight that round 3 being unchanged means every cost is final

The night timetable had credits but no loops, and one sweep handled it. Daytime Meridian is messier. Roads run both ways, so the network is full of cycles, and partner depots rebate a van that carries their returns: on the books, those legs cost less than nothing. The tariff router has to find the cheapest route through that, and there is a new danger. If a few rebate legs form a loop whose total is negative, a van could drive round it forever, getting paid each lap. Then "the cheapest route" doesn't exist, and the router must say so rather than print a number.

Neither tool you have works here. Dijkstra settles stops too early when a rebate can undercut them later, and the DAG sweep needs an order that a cyclic network doesn't have. Bellman-Ford needs neither. It settles nothing and assumes no order; it just relaxes every leg, again and again, until nothing moves.

Rounds, and what each one guarantees

One round relaxes every leg in the network once, in any order. The whole algorithm is V − 1 rounds, where V is the number of stops. The reason is one invariant:

After round k, every stop's cost is at most the cheapest route to it that uses at most k legs.

Round 1 prices every one-leg route out of the start. Round 2 extends the best of those by one more leg, and so on. And if no loop makes money, a cheapest route never needs to visit a stop twice, because cutting out the loop between the two visits can't make it dearer. A route with no repeated stop has at most V − 1 legs, so after V − 1 rounds every cheapest route has been priced.

Watch it on a small piece of the daytime network. The legs are relaxed in a deliberately unhelpful order (listed in the note), so it takes more than one round. Each note records the costs at the end of a round:

watch it move · graph

Each round relaxes every leg once. Costs only ever fall, and a round that changes nothing means every cost is final.

Only the depot has a cost. Every round will try all six legs in the order listed. 5 nodes, 6 edges, directed. At depot. depot: 0.

leg order: quay>yard, yard>harbour, depot>quay, depot>mill, mill>quay, mill>harbour

1 / 6

That last step is the detection rule. After V − 1 rounds, costs can only still fall if some loop keeps making them fall. So one extra round is a proof: if anything improves in round V, there is a money-making loop the start can reach.

Bellman-Ford in code

The loop is short. Keep a changed flag so a quiet round ends things early, then spend one more pass over the legs checking for a loop:

python

Unreachable stops stay at infinity and cause no trouble: inf + cost is still inf, so they never "improve" anything.

The order of legs inside a round never affects the final answer, but it does affect how many rounds you need:

What does this print?
legs = [["d", "e", 1], ["c", "d", 1], ["b", "c", 1], ["a", "b", 1]]   # listed back to front
best = {"a": 0}
rounds = 0
while True:
    rounds += 1
    changed = False
    for x, y, cost in legs:
        if x in best and best[x] + cost < best.get(y, float("inf")):
            best[y] = best[x] + cost
            changed = True
    if not changed:
        break
print(rounds)

That is also the difference between relaxing in place and relaxing from a snapshot of the previous round. In place, a lucky order can chain several new legs within one round. From a snapshot, round k means exactly "at most k legs", which is what you want when the number of legs is capped (the step-limited mix in the graphs section was this). Step through a snapshot round:

step through it
1legs = [["a", "b", 2], ["b", "c", 3]]
2best = {"a": 0}
3new = dict(best)
4for x, y, cost in legs:
5 if x in best:
6 new[y] = min(new.get(y, 99), best[x] + cost)
7print(new)

c stays unpriced: b only got its cost in new, and the round reads from best.

Trusting the answer

It's easy to write the rounds and forget the check. Then a money-making loop doesn't produce an error; it produces a confident, wrong number:

broken — fix it

This network has a loop that pays the van every lap, so the harbour has no cheapest cost, but the function prints a number anyway. Make it return "loop" when round V would still improve something.

A loop only matters if it is net negative. One that exactly breaks even is harmless, and the strict < is what makes the check agree:

What does this print?
legs = [["mill", "quay", 3], ["quay", "mill", -3]]
best = {"mill": 0, "quay": float("inf")}
for a, b, cost in legs:                           # V - 1 = 1 round
    if best[a] + cost < best[b]:
        best[b] = best[a] + cost
print(any(best[a] + cost < best[b] for a, b, cost in legs))

The main task goes one step further than "is there a loop?". A loop only ruins the answer for stops it can reach. Any leg that still improves after V − 1 rounds marks its target as unbounded, and so is everything downstream of it. A loop in the far corner of the city shouldn't stop the router pricing a trip across the middle.

Your Bellman-Ford, drawn

Here is Bellman-Ford drawing each improvement as it happens, with a final check that flags any leg still improving. Remove the ["yard", "mill", -5] leg to see a clean run, or change it to -1 and work out by hand whether the loop still pays before you press run.

your code, drawn · graph

Your Bellman-Ford. Badges only fall. If any leg can still lower one after V − 1 rounds, the costs have no floor.

How slow is "every leg, every round"?

V − 1 rounds of E legs is O(V × E). Dijkstra is O((V + E) log V). On a city-sized network the gap is brutal, which is why real routers use Bellman-Ford only when they must, and often just once, to check a tariff table or to compute the potentials you'll meet in the next lesson. Memory is cheap by comparison: just the best cost per stop, O(V) space — no heap, no per-round copy needed.

Say Meridian has n junctions and about 3n roads. Bellman-Ford does about 3n × n relaxations and Dijkstra about 4n × log₂ n heap steps, so Bellman-Ford does about 3n / (4 log₂ n) times the work:

junctions n Bellman-Ford's work over Dijkstra's
1,000 75×
10,000 564×
19,000 1,003×
1,000,000 37,629×

A modest town of 19,000 junctions already costs a thousand times more, and the ratio grows almost linearly with the size of the network. Use Bellman-Ford when costs can be negative, and nowhere else.

A common speed-up keeps a queue of stops whose cost just fell and only relaxes legs out of those, since a leg out of an unchanged stop can't improve anything. On typical road networks it is much faster, though its worst case is no better.


Interview-style: exchange-board arbitrage

Meridian pays partner fleets in their own credit schemes: fuel vouchers, parking tokens, charge points. An exchange board lists rates such as "1 fuel voucher buys 2 parking tokens". Is there a cycle of exchanges that ends with more than you started with?

It doesn't look like a shortest-path problem: it multiplies instead of adding, and bigger is better. Two changes fix both. A logarithm turns a product into a sum, and a minus sign turns "bigger" into "smaller":

python

A profitable cycle is exactly a negative loop under weights −log r, and Bellman-Ford finds negative loops. The boss exercise builds it, including the floating-point tolerance that stops a break-even cycle like 2 × 0.5 from looking like profit.

What it costs

Time Handles
DAG sweep O(V + E) negative costs, no cycles
Dijkstra O((V + E) log V) cycles, no negative costs
Bellman-Ford O(V × E) cycles and negative costs; detects money-making loops

❓ Cross-question — "Why not add a big constant to every cost so none is negative, then use Dijkstra?" Because routes with more legs get charged the constant more times, so the ranking of routes changes. The reweighting that does work, a different adjustment per stop, is in the next lesson.


Quick practice

What does this print?
legs = [["d", "e", 1], ["c", "d", 1], ["b", "c", 1], ["a", "b", 1]]
best = {"a": 0}
for _ in range(2):
    for x, y, cost in legs:
        if x in best and best[x] + cost < best.get(y, float("inf")):
            best[y] = best[x] + cost
print(best.get("d"))
broken — fix it

The chain a → b → c → d → e costs 4, but this prints inf. Fix the round count.

🎯 Your turn

Write tariff_route(legs, start, goal). legs is a directed edge list of [from, to, cost], rebates are negative, and loops are allowed. Return {"cost": total, "path": stops} for the cheapest route, {"cost": None, "path": []} if the goal is unreachable, or {"cost": "loop", "path": []} if a money-making loop reachable from the start can also reach the goal:

  • tariff_route([["depot", "mill", 2], ["depot", "quay", 6], ["quay", "mill", -5], ["mill", "harbour", 3]], "depot", "harbour") → {"cost": 4, "path": ["depot", "quay", "mill", "harbour"]}

Hint: V − 1 rounds with parent, one checking pass to find doomed stops, a DFS to spread "doomed" downstream, then walk parent back from the goal.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of Meridian's router is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upOne round, read from the last

Write one_round(legs, best), which performs a single Bellman-Ford round. legs is a directed edge list of [from, to, cost], and best maps each stop priced so far to its cost (unpriced stops are absent). Return a new dict: a copy of best in which every leg has been relaxed, reading costs only from the old best, so one round can never chain two new legs together. Example: one_round([["a", "b", 2], ["b", "c", 3]], {"a": 0}) → {"a": 0, "b": 2} (c needs a second round).

relaxationweighted-graph
DrillEvery cheapest cost, rebates and all

Write rebate_costs(legs, start) returning the cheapest cost from start to every stop it can reach (start at 0). legs is a directed edge list of [from, to, cost]; costs may be negative and the network may have loops, but no loop in these tests makes money. Stop early once a round changes nothing. Example: [["depot", "mill", 2], ["depot", "quay", 6], ["quay", "mill", -5]] from "depot" → {"depot": 0, "mill": 1, "quay": 6}.

shortest-pathsrelaxation
BuildIs there a money-making loop anywhere?

Before a tariff table goes live, Meridian checks it can't be gamed. Write has_money_loop(legs) returning True if the directed edge list of [from, to, cost] contains any loop whose costs add up to less than zero, anywhere in the network, reachable from any stop or none. A loop that adds up to exactly 0 is harmless.

shortest-pathsrelaxation
BossVoucher arbitrage

An interview-style routing problem. Meridian settles with partner fleets in their own credit schemes: fuel vouchers, parking tokens, charge points. The exchange board lists rates, a list of [a, b, r] meaning one unit of scheme a buys r units of scheme b. Write has_arbitrage(rates) returning True if some cycle of exchanges ends with more than you started with (the product of its rates is greater than 1), and False otherwise.

shortest-pathslogarithmrelaxation
CapstoneWhich stops have no cheapest cost?

When a money-making loop is reachable, some stops have no cheapest cost at all: you can always go round once more. Write loop_victims(legs, start) returning, sorted alphabetically, every stop whose cost from start is unbounded below. legs is a directed edge list of [from, to, cost]. A loop that start can't reach harms nothing.

shortest-pathsgraph-traversalrelaxation
ExplainWhy V − 1 rounds, and what the next one proves

Bellman-Ford runs V − 1 rounds, where V is the number of stops. Explain why that many is always enough when there is no money-making loop, and why an improvement in one more round proves such a loop exists.

tariff_route.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
tariff_route(legs, start, goal) → dictThe cheapest route under rebates as {cost, path}; cost None if unreachable, "loop" if no cheapest cost exists.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.