← Applied algorithms
Routes and connections When an edge can cost less than nothing

Bellman–Ford shortest paths

Relax every edge. Trust no cost too soon.

Currency desks watch for arbitrage: a loop of conversions that comes back with more money than it started with. Sedgewick and Wayne’s Algorithms turns that into a shortest-path question. Take each exchange rate, “replace each weight by its logarithm, negated,” and an arbitrage loop becomes a cycle whose total is below zero.

We’ll price conversions out of US dollars through a small market of quotes, where one desk quotes better than the mid-market rate, so one edge costs less than nothing. Let’s watch the costs improve, pass by pass.

TypeScriptGoOne market of quotes in each language

01 / The idea

The better offer can arrive after the cost looked finished.

From USD, the bank’s direct quote into EUR costs 30 basis points, and its quote into GBP costs 12. A basis point is a hundredth of a percent, measured here against the mid-market rate. If the search fixed EUR at 30 now, it would miss a desk that sells euros for pounds 8 basis points better than mid: USD → GBP → EUR costs 12 − 8 = 4.

Bellman–Ford finds the cheapest path from one start through a directed, weighted graph, including edges that cost less than zero, as long as no loop the start can reach keeps lowering the total.

Instead of fixing one cost at a time, it repeats one plain step: ask every quote whether its currency can be reached more cheaply through the currency it converts from.

Bellman–Ford

Relax every quote. Let better offers travel.

CURRENCY QUOTES · BASIS POINTS AGAINST MID USD → JPY
USD→JPY 60 bp
USD 0 unchanged
GBP 12 updated this pass
EUR 30 updated this pass
CHF 45 updated this pass
SGD 65 updated this pass
JPY 60 updated this pass
BRL ∞ unchanged

5 accepted updates in pass 1.

accepted now carried forward unreachable

01/ 03
Scan every quote

Scan every quote, in the order they are listed.

Every currency starts at ∞ except USD at 0. The first pass sets EUR at 30 from the direct quote. GBP → EUR comes earlier in the list than USD → GBP, so it is skipped: GBP has no cost yet. GBP ends the pass at 12, and JPY at 60.

Reduced motion: choose a scene to see its completed state.

Read this scene

Every currency starts at ∞ except USD at 0. The first pass sets EUR at 30 from the direct quote. GBP → EUR comes earlier in the list than USD → GBP, so it is skipped: GBP has no cost yet. GBP ends the pass at 12, and JPY at 60.

Passes shown: 1. Accepted updates: JPY 60, EUR 30, GBP 12, CHF 45, SGD 65.

Watch and Step through replay the quotes pass by pass. Try it runs the same TypeScript implementation on those quotes or on the market with one stale quote.

The animation uses the real TypeScript trace. EUR drops from 30 to 4 on the second pass, and the improvement carries on to CHF, SGD, and JPY in the same pass. No quote leads to BRL, so it stays ∞.

02 / Name the rule

Relax a quote: carry the best offer forward.

Start USD at 0 and every other currency at ∞. For a quote u → v with cost w, compare cost[u] + w with cost[v]. Replace the currency’s cost only when the offer is strictly smaller.

Offer

Source cost plus quote

A reached currency offers its current best cost, plus the quote, to the next one.

Keep

Only the smaller number

Every accepted offer replaces the predecessor too, so a chain can be rebuilt later.

Check

One more time

After V − 1 passes, another improvement proves a reachable negative cycle.

The rule that holds is weaker than Dijkstra’s: after k passes, every chain of at most k quotes has been accounted for. A cheapest chain that doesn’t visit a currency twice uses at most V − 1 quotes. If a reachable loop can still lower a cost after that, no cheapest chain exists.

Why scan every quote again?The quote order is not a shortcut

In the example, GBP → EUR is listed before USD → GBP. On the first pass GBP has no cost yet when its quote is scanned, so EUR stays at 30. The second pass sees GBP at 12 and carries 4 onward. A different order might carry several improvements in one pass, but Bellman–Ford is correct in any order because it allows up to V − 1 full scans.

Why do basis points add up?The costs are logarithms

Each quote’s cost is 10,000 × ln(mid ÷ quoted rate), rounded to a whole basis point. Converting along a chain multiplies the rates, and logarithms turn multiplying into adding, so the cost of a chain is the sum of its quotes. At a few dozen basis points the logarithm and the plain percentage agree to the nearest point: a quote 0.3% worse than mid costs 30.

The mid-market rates come from one reference price per currency, so going round any loop at mid costs exactly 0. A loop costs less than nothing only when its quotes beat mid by more than they lose, and that is an arbitrage.

03 / Read the shape

Costs are a table, and each pass is a sweep.

Basic form validates the currencies and quotes, initializes the cost table, and relaxes every quote. In the wild follows predecessor links to one currency, and findArbitrage names the loop when the table is refused. At the call site prices USD to JPY, prints every cost, then adds one stale quote. Both languages print the same four lines.

Validate a market of one-way quotes, then relax every quote up to V − 1 times. A strictly better offer replaces the old cost; no currency is ever treated as final.

TypeScriptReading
rates.ts
export const MAX_CURRENCIES = 64;
export const MAX_QUOTES = 256;
export const MIN_COST = -500;
export const MAX_COST = 5000;

// cost is the basis points a quote loses against the mid-market rate:
// 10,000 × ln(mid ÷ quoted rate), rounded. A quote better than mid costs less than 0.
// Logarithms add, so the cost of a chain of conversions is the sum of its quotes.
export type Quote = { from: string; to: string; cost: number };
export type Market = { currencies: string[]; quotes: Quote[] };
export type Relaxation = {
	from: string;
	to: string;
	oldCost: number | null;
	newCost: number;
};
export type Pass = {
	pass: number;
	updates: Relaxation[];
	costs: Record<string, number | null>;
	changed: boolean;
};
export type Paths = {
	start: string;
	currencies: string[];
	costs: Record<string, number | null>;
	via: Record<string, string | null>;
	passes: number;
};

export type ExchangeErrorCode =
	'too-big' | 'bad-currency' | 'unknown-currency' | 'bad-cost' | 'negative-cycle';

export class ExchangeError extends Error {
	readonly code: ExchangeErrorCode;
	constructor(code: ExchangeErrorCode, message: string) {
		super(message);
		this.name = 'ExchangeError';
		this.code = code;
	}
}

function validate(market: Market, start: string): void {
	if (market.currencies.length > MAX_CURRENCIES || market.quotes.length > MAX_QUOTES)
		throw new ExchangeError(
			'too-big',
			`up to ${MAX_CURRENCIES} currencies and ${MAX_QUOTES} quotes`
		);
	const known = new Set<string>();
	for (const currency of market.currencies) {
		if (!/^[a-z]{3}$/.test(currency) || known.has(currency))
			throw new ExchangeError(
				'bad-currency',
				`currencies are unique three-letter lowercase codes: ${currency}`
			);
		known.add(currency);
	}
	for (const quote of market.quotes) {
		if (!known.has(quote.from) || !known.has(quote.to))
			throw new ExchangeError(
				'unknown-currency',
				`a quote names a currency that is not in the market: ${quote.from}, ${quote.to}`
			);
		if (!Number.isInteger(quote.cost) || quote.cost < MIN_COST || quote.cost > MAX_COST)
			throw new ExchangeError(
				'bad-cost',
				`costs are whole basis points from ${MIN_COST} to ${MAX_COST}`
			);
	}
	if (!known.has(start)) throw new ExchangeError('unknown-currency', `no such currency: ${start}`);
}

function snapshot(currencies: string[], costs: Map<string, number>): Record<string, number | null> {
	return Object.fromEntries(currencies.map((currency) => [currency, costs.get(currency) ?? null]));
}

// Relax every quote up to V - 1 times. In-place updates let an improvement travel on within
// the same pass, but no currency's cost is ever treated as final.
export function traceRelaxation(market: Market, start: string): Pass[] {
	validate(market, start);
	const costs = new Map<string, number>([[start, 0]]);
	const passes: Pass[] = [];
	for (let pass = 1; pass < market.currencies.length; pass++) {
		const updates: Relaxation[] = [];
		for (const quote of market.quotes) {
			const from = costs.get(quote.from);
			if (from === undefined) continue;
			const newCost = from + quote.cost;
			const oldCost = costs.get(quote.to) ?? null;
			if (oldCost === null || newCost < oldCost) {
				costs.set(quote.to, newCost);
				updates.push({ from: quote.from, to: quote.to, oldCost, newCost });
			}
		}
		passes.push({
			pass,
			updates,
			costs: snapshot(market.currencies, costs),
			changed: updates.length > 0
		});
		if (updates.length === 0) break;
	}
	for (const quote of market.quotes) {
		const from = costs.get(quote.from);
		if (from !== undefined && from + quote.cost < (costs.get(quote.to) ?? Infinity))
			throw new ExchangeError('negative-cycle', 'a reachable negative cycle has no cheapest chain');
	}
	return passes;
}

export function shortestPaths(market: Market, start: string): Paths {
	const passes = traceRelaxation(market, start);
	const last = passes.at(-1);
	const costs = last?.costs ?? snapshot(market.currencies, new Map([[start, 0]]));
	const via: Record<string, string | null> = Object.fromEntries(
		market.currencies.map((currency) => [currency, null])
	);
	for (const pass of passes)
		for (const update of pass.updates) {
			via[update.to] = update.from;
		}
	return {
		start,
		currencies: market.currencies.slice(),
		costs,
		via,
		passes: passes.length
	};
}
GoAlongside
rates.go
const (
	MaxCurrencies = 64
	MaxQuotes     = 256
	MinCost       = -500
	MaxCost       = 5000
)

// Cost is the basis points a quote loses against the mid-market rate:
// 10,000 × ln(mid ÷ quoted rate), rounded. A quote better than mid costs less than 0.
// Logarithms add, so the cost of a chain of conversions is the sum of its quotes.
type Quote struct {
	From string
	To   string
	Cost int
}

type Market struct {
	Currencies []string
	Quotes     []Quote
}

type Relaxation struct {
	From    string
	To      string
	OldCost *int
	NewCost int
}

type Pass struct {
	Pass    int
	Updates []Relaxation
	Costs   map[string]*int
	Changed bool
}

type Paths struct {
	Start      string
	Currencies []string
	Costs      map[string]*int
	Via        map[string]string
	Passes     int
}

type ExchangeError struct {
	Code    string
	Message string
}

func (e *ExchangeError) Error() string { return e.Message }

var currencyPattern = regexp.MustCompile(`^[a-z]{3}$`)

func validate(market Market, start string) error {
	if len(market.Currencies) > MaxCurrencies || len(market.Quotes) > MaxQuotes {
		return &ExchangeError{"too-big", fmt.Sprintf("up to %d currencies and %d quotes", MaxCurrencies, MaxQuotes)}
	}
	known := map[string]bool{}
	for _, currency := range market.Currencies {
		if !currencyPattern.MatchString(currency) || known[currency] {
			return &ExchangeError{"bad-currency", "currencies are unique three-letter lowercase codes: " + currency}
		}
		known[currency] = true
	}
	for _, quote := range market.Quotes {
		if !known[quote.From] || !known[quote.To] {
			return &ExchangeError{"unknown-currency", fmt.Sprintf("a quote names a currency that is not in the market: %s, %s", quote.From, quote.To)}
		}
		if quote.Cost < MinCost || quote.Cost > MaxCost {
			return &ExchangeError{"bad-cost", fmt.Sprintf("costs are whole basis points from %d to %d", MinCost, MaxCost)}
		}
	}
	if !known[start] {
		return &ExchangeError{"unknown-currency", "no such currency: " + start}
	}
	return nil
}

func snapshot(currencies []string, costs map[string]int) map[string]*int {
	result := map[string]*int{}
	for _, currency := range currencies {
		if value, ok := costs[currency]; ok {
			copy := value
			result[currency] = &copy
		} else {
			result[currency] = nil
		}
	}
	return result
}

// Relax every quote up to V - 1 times. In-place updates let an improvement travel on within
// the same pass, but no currency's cost is ever treated as final.
func TraceRelaxation(market Market, start string) ([]Pass, error) {
	if err := validate(market, start); err != nil {
		return nil, err
	}
	costs := map[string]int{start: 0}
	passes := []Pass{}
	for pass := 1; pass < len(market.Currencies); pass++ {
		updates := []Relaxation{}
		for _, quote := range market.Quotes {
			from, ok := costs[quote.From]
			if !ok {
				continue
			}
			newCost := from + quote.Cost
			oldCost, reached := costs[quote.To]
			if !reached || newCost < oldCost {
				var old *int
				if reached {
					copy := oldCost
					old = &copy
				}
				costs[quote.To] = newCost
				updates = append(updates, Relaxation{quote.From, quote.To, old, newCost})
			}
		}
		passes = append(passes, Pass{pass, updates, snapshot(market.Currencies, costs), len(updates) > 0})
		if len(updates) == 0 {
			break
		}
	}
	for _, quote := range market.Quotes {
		from, fromReached := costs[quote.From]
		to, toReached := costs[quote.To]
		if fromReached && (!toReached || from+quote.Cost < to) {
			return nil, &ExchangeError{"negative-cycle", "a reachable negative cycle has no cheapest chain"}
		}
	}
	return passes, nil
}

func ShortestPaths(market Market, start string) (Paths, error) {
	passes, err := TraceRelaxation(market, start)
	if err != nil {
		return Paths{}, err
	}
	costs := map[string]*int{}
	if len(passes) > 0 {
		costs = passes[len(passes)-1].Costs
	} else {
		costs = snapshot(market.Currencies, map[string]int{start: 0})
	}
	via := map[string]string{}
	for _, currency := range market.Currencies {
		via[currency] = ""
	}
	for _, pass := range passes {
		for _, update := range pass.Updates {
			via[update.To] = update.From
		}
	}
	return Paths{start, append([]string(nil), market.Currencies...), costs, via, len(passes)}, nil
}
Reading the TypeScriptMaps, snapshots, and a strict improvement

costs is a Map whose missing entries mean ∞. Each accepted relaxation writes via so the chain can be rebuilt. traceRelaxation records a snapshot after every pass for the animation and lab, while shortestPaths returns the final table. findArbitrage keeps the quote that last improved each currency, so it can walk back round the loop.

Reading the GoMaps and pointers for unreached currencies

Go stores reached costs as integers in a map and uses a nil pointer in snapshots for ∞. CheapestChain and FindArbitrage return a nil pointer and no error when there is nothing to return. The quote loop is the same: compare, replace, remember the predecessor, then run the extra negative-cycle check.

What is refusedBounds and graph safety

Both versions bound the market at 64 currencies and 256 quotes, require unique three-letter lowercase codes (bad-currency), check quote endpoints (unknown-currency) before costs, and accept whole basis points from −500 to 5,000 (bad-cost). A reachable negative cycle is negative-cycle; a loop the start can’t reach doesn’t change its costs.

04 / Try a decision

What does one more improvement mean?

The extra pass isn’t an optimization detail. It is the line between a cost that is high but real and a loop that pays you every time you go round it.

After V − 1 passes over the quotes, one quote can still lower a currency’s cost. What should the quoting service do?

05 / Follow the cost

Certainty costs a full sweep per possible quote.

Bellman–Ford: time and extra space per operation
OperationTimeExtra spaceWhat it assumes
Relax one quoteO(1)O(1)Read one quote and replace its currency’s cost only when the new offer is smaller.
Run one full passO(E)O(V + E)Scan all E one-way quotes once. The shown trace also keeps a V-entry snapshot and the accepted updates for the pass; without the trace the pass needs O(1) extra.
Find every cheapest chainO(VE)O(V² + VE)At most V − 1 passes are needed when no reachable negative cycle exists. The shown trace keeps every pass; keeping only the current table needs O(V).
Stop after no changesO(kE)O(k(V + E))k is the number of useful passes; it can be much smaller than V − 1. Without the trace, O(V).
Rebuild one chainO(V)O(V)Follow predecessor links from the target back to the start, then reverse them.
Name the loopO(VE)O(V)findArbitrage runs V passes with no trace, then takes at most 2V steps back along predecessor links.

With V currencies and E quotes, the worst case is O(VE): one pass for each quote a cheapest chain might need. Sedgewick and Wayne put it as “time proportional to E V.” The algorithm itself needs O(V) for costs and predecessors, plus the input. The shown traceRelaxation also keeps a snapshot and an update log for every pass, O(V² + VE) in the worst case, because the animation and lab replay it; a production version keeps only the current table.

That cost buys something Dijkstra doesn’t have: edges below zero. If every cost is zero or more, Dijkstra’s priority queue usually does less work by making a safe greedy choice. Bellman–Ford is the slower, more general method when that choice isn’t safe.

06 / Give it a real job

Refuse to quote a loop, and name it.

A payments service that pays suppliers abroad asks this every time its quotes refresh: what is the cheapest chain from the currency we hold to the one we owe? cheapestChain answers from the finished table. USD to JPY is 49 bp through GBP, EUR, CHF, and SGD, cheaper than the bank’s direct 60.

Now one desk’s EUR → USD price goes stale, 20 bp better than mid after the euro has moved. The loop USD → GBP → EUR → USD costs 12 − 8 − 20 = −16 bp: a million dollars sent round it would come back as about $1,001,600, and every lap would add more. The table has no cheapest chain, so the service refuses to quote, and findArbitrage names the three quotes for someone to check. On a live market a loop like that disappears as soon as anyone trades on it, so when your own service finds one, it usually means bad data.

The same relaxation, run by many machines at once, is how older routers learn their routes. RFC 2453 says it plainly: “RIP is a routing protocol based on the Bellman-Ford (or distance vector) algorithm.” Each router offers its neighbors its current distances, and a neighbor keeps an offer only when it beats what it has.

This runs on the server that holds the quote table; nothing in a component computes it.

07 / Make the call

Choose based on edge weights and the question.

Use Bellman–Ford for one start when edges below zero are meaningful and a reachable negative cycle should be reported. Use Dijkstra when every weight is zero or more and you want the faster priority-queue search. If every edge has the same cost, BFS is simpler; if you need the cost between every pair of currencies, not only from one, Floyd–Warshall fills the whole table at once.

Bellman–Ford answers cheapest paths, not “best” paths through a profitable loop. A negative cycle is a signal to fix the data, cap the number of conversions, or ask a different question.

SourcesThe paper, a textbook, and an RFC

All checked 23 September 2026.

08 / Take the idea with you

Explain a cheapest chain without saying “Bellman–Ford.”

“Start at zero and everything else at infinity. Go through every conversion and keep any cheaper offer it makes. Do that once for each currency but one. If a conversion can still make something cheaper after that, there’s a loop that pays you to go round it, so there is no cheapest answer.”

Before moving on, think of a graph you know where an edge could honestly be negative, and ask whether a loop in it could be too.

Connections to follow nextRelated lessons

Copy the complete example, change the GBP → EUR quote from −8 to 20, and predict which costs move on the second pass before you run it.

Back to applied algorithms →