← Applied algorithms
Routes and connections From walking times to the quickest route

Dijkstra’s shortest path

Settle the nearest. Then look around.

Routers that speak OSPF, the routing protocol defined in RFC 2328, each work out a shortest-path tree with themselves at the root and build their routing tables from it. The RFC names the method: “Using the Dijkstra algorithm, a tree is formed from this subset of the link state database.”

We’ll use the same idea on something you can walk: a campus map with times in minutes, and the quickest way from the main gate to the lecture hall.

TypeScriptGoOne campus map in each language

01 / The idea

The nearest step isn’t always on the way.

From the main gate, the Café is 2 minutes away, the Library 4, and the Gym 5. Heading for the Café first feels right. But the quickest walk to the lecture hall goes through the Library and the Quad, 9 minutes in all, and never passes the Café.

Dijkstra’s algorithm finds the quickest route from one place to every place it can reach by settling places in order of how far away they are, as long as no path takes negative time.

It doesn’t guess which way to go. It keeps every place it has reached but not yet settled, the frontier, and always settles the one with the fewest minutes next. Watch it cross the campus, including the moment two routes tie.

Dijkstra

Settle the nearest. Look around. Repeat.

CAMPUS · WALKING MINUTES Main gate → Lecture hall
2′4′5′6′2′5′3′3′1′7′4′0Main gate2Café4Library5Gym∞Quad∞Science lab∞Lecture hall∞Dorms∞Boathouse

Settled Main gate at 0 min.

settled waiting, tentative minutes not reached (∞)

01/ 03
Settle the nearest place

Settle the nearest place first.

Main gate starts at 0 minutes, and its paths give Café 2, Library 4, and Gym 5. Café is the nearest, so it settles next, even though the quickest way to the lecture hall doesn’t pass through it. Library settles at 4.

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

Read this scene

Main gate starts at 0 minutes, and its paths give Café 2, Library 4, and Gym 5. Café is the nearest, so it settles next, even though the quickest way to the lecture hall doesn’t pass through it. Library settles at 4.

Settled: Main gate 0 min. Waiting: Café 2 min, Library 4 min, Gym 5 min.

Watch and Step through replay the search from the main gate to the lecture hall. Try it runs the same TypeScript on paths and times you choose, one settled place at a time, and starts fresh each time you open it.

Café settled before Library, and that was fine: settling a place only fixes its time. The route to the hall is decided by where the hall was reached from, and that came later.

02 / Name the rule

Settle the fewest minutes. Then offer your neighbors a time.

Every place starts at ∞ except the start, at 0. Then one step repeats until nothing is waiting:

Settle

Fewest minutes waiting

Take the waiting place with the smallest time. That time is now final.

Offer

Its time plus the walk

Each unsettled neighbor gets an offer: this place’s minutes plus the path’s.

Keep

Only a strictly better offer

A better offer replaces the neighbor’s time and remembers where it came from.

One rule holds after every step: every settled place has its quickest time, and every waiting place has the quickest time through settled places only. The next place to settle is the waiting one with the fewest minutes. Any other way to reach it would have to leave the settled places through some waiting place, which already costs at least as much, and then walk further. Walking only adds minutes, so nothing can arrive cheaper.

A place no path reaches never gets an offer. It stays at ∞ and never settles, like the Boathouse on the island.

What if a path could give time back?Why the rule needs nonnegative times

Picture three one-way links: A to B takes 2 minutes, A to C takes 3, and C to B gives 2 back (−2). Settling from A, B has the fewest minutes and settles at 2. C settles at 3 and offers B 3 − 2 = 1, but B is already settled. The true quickest time to B is 1; the algorithm said 2.

That is why this lesson’s code refuses negative minutes. Princeton’s Algorithms, 4th edition states Dijkstra’s algorithm for “edge-weighted digraphs with nonnegative weights,” and covers Bellman–Ford for graphs that have negative weights but no negative cycles.

03 / Read the shape

A heap picks the next place. Stale entries get skipped.

Basic form is the whole search: settle builds two-way neighbor lists (paths in TypeScript, Neighbours in Go), then pops the waiting place with the fewest minutes and offers its neighbors a time. In the wild answers a map app’s question with an early stop. At the call site asks for three trips from the main gate. Both languages print the same four lines.

The heap is the priority queue from the Binary heap lesson, ordered by minutes and then by place id, so equal times settle in the same order in both languages. When a place gets a better offer, the code doesn’t find and change its entry; it pushes a new one and skips the old one when it comes out. That is the same problem as the heap lesson’s changed priority, solved by adding instead of editing. The heap lesson needs version numbers to spot a stale copy; here the set of settled places does that job.

Settle builds the two-way paths, then repeatedly settles the waiting place with the fewest minutes and offers its neighbors a time. A min-heap orders the waiting places by minutes, then id; a stale entry is skipped.

TypeScriptReading
campus.ts
// Dijkstra's shortest paths over walking times in whole minutes. Every path is two-way.
export const MAX_PLACES = 64;
export const MAX_PATHS = 256;
export const MAX_MINUTES = 1440; // one day

export type Path = { a: string; b: string; minutes: number };
export type Campus = { places: string[]; paths: Path[] };
// A settled place: its final walking time, and the settled place it was reached from.
export type Settled = { place: string; minutes: number; via: string | null };

export type RouteErrorCode = 'too-big' | 'bad-place' | 'unknown-place' | 'bad-minutes';

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

type Entry = { minutes: number; place: string };

// Fewer minutes first; equal minutes by place id, so ties settle the same way in every language.
const ahead = (x: Entry, y: Entry) =>
	x.minutes < y.minutes || (x.minutes === y.minutes && x.place < y.place);

// A binary min-heap: the frontier of places reached but not yet settled.
class Frontier {
	#items: Entry[] = [];
	get size() {
		return this.#items.length;
	}
	push(entry: Entry) {
		const items = this.#items;
		items.push(entry);
		for (let i = items.length - 1; i > 0;) {
			const parent = (i - 1) >> 1;
			if (!ahead(items[i], items[parent])) break;
			[items[i], items[parent]] = [items[parent], items[i]];
			i = parent;
		}
	}
	pop(): Entry {
		const items = this.#items;
		const top = items[0];
		const last = items.pop()!;
		if (items.length > 0) {
			items[0] = last;
			for (let i = 0; ;) {
				let best = i;
				for (const child of [2 * i + 1, 2 * i + 2])
					if (child < items.length && ahead(items[child], items[best])) best = child;
				if (best === i) break;
				[items[i], items[best]] = [items[best], items[i]];
				i = best;
			}
		}
		return top;
	}
}

export function paths(campus: Campus): Map<string, { to: string; minutes: number }[]> {
	if (campus.places.length > MAX_PLACES || campus.paths.length > MAX_PATHS)
		throw new RouteError('too-big', `up to ${MAX_PLACES} places and ${MAX_PATHS} paths`);
	const next = new Map<string, { to: string; minutes: number }[]>();
	for (const place of campus.places) {
		if (!/^[a-z][a-z0-9-]{0,23}$/.test(place) || next.has(place))
			throw new RouteError('bad-place', `place ids are unique lowercase slugs: ${place}`);
		next.set(place, []);
	}
	for (const { a, b, minutes } of campus.paths) {
		if (!next.has(a) || !next.has(b))
			throw new RouteError('unknown-place', `a path joins places that don't exist: ${a}, ${b}`);
		if (!Number.isInteger(minutes) || minutes < 0 || minutes > MAX_MINUTES)
			throw new RouteError('bad-minutes', `minutes are whole numbers from 0 to ${MAX_MINUTES}`);
		next.get(a)!.push({ to: b, minutes });
		next.get(b)!.push({ to: a, minutes });
	}
	return next;
}

// Settle places in order of walking time from start. Stops as soon as `target` is settled.
export function settle(campus: Campus, start: string, target?: string): Settled[] {
	const next = paths(campus);
	for (const place of target === undefined ? [start] : [start, target])
		if (!next.has(place)) throw new RouteError('unknown-place', `no such place: ${place}`);
	const best = new Map<string, number>([[start, 0]]);
	const via = new Map<string, string | null>([[start, null]]);
	const done = new Set<string>();
	const frontier = new Frontier();
	frontier.push({ minutes: 0, place: start });
	const settled: Settled[] = [];
	while (frontier.size > 0) {
		const { minutes, place } = frontier.pop();
		if (done.has(place)) continue; // a stale entry: this place already settled with fewer minutes
		done.add(place);
		settled.push({ place, minutes, via: via.get(place)! });
		if (place === target) break;
		for (const { to, minutes: walk } of next.get(place)!) {
			const offer = minutes + walk;
			// Only a strictly shorter offer wins, so a tie keeps the route found first.
			if (!done.has(to) && offer < (best.get(to) ?? Infinity)) {
				best.set(to, offer);
				via.set(to, place);
				frontier.push({ minutes: offer, place: to }); // the old, longer entry stays and is skipped
			}
		}
	}
	return settled;
}
GoAlongside
campus.go
// Dijkstra's shortest paths over walking times in whole minutes. Every path is two-way.
const (
	MaxPlaces  = 64
	MaxPaths   = 256
	MaxMinutes = 1440 // one day
)

type Path struct {
	A, B    string
	Minutes int
}

type Campus struct {
	Places []string
	Paths  []Path
}

// Settled is a place's final walking time and the settled place it was reached from.
// Via is "" for the start.
type Settled struct {
	Place   string
	Minutes int
	Via     string
}

type RouteError struct{ Code, Message string }

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

type entry struct {
	minutes int
	place   string
}

// frontier is a min-heap for container/heap: fewer minutes first, then place id, so ties
// settle the same way in every language.
type frontier []entry

func (f frontier) Len() int { return len(f) }
func (f frontier) Less(i, j int) bool {
	if f[i].minutes != f[j].minutes {
		return f[i].minutes < f[j].minutes
	}
	return f[i].place < f[j].place
}
func (f frontier) Swap(i, j int) { f[i], f[j] = f[j], f[i] }
func (f *frontier) Push(x any)   { *f = append(*f, x.(entry)) }
func (f *frontier) Pop() any {
	old := *f
	last := old[len(old)-1]
	*f = old[:len(old)-1]
	return last
}

type walk struct {
	to      string
	minutes int
}

var placeID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,23}$`)

func Neighbours(c Campus) (map[string][]walk, error) {
	if len(c.Places) > MaxPlaces || len(c.Paths) > MaxPaths {
		return nil, &RouteError{"too-big", fmt.Sprintf("up to %d places and %d paths", MaxPlaces, MaxPaths)}
	}
	next := make(map[string][]walk, len(c.Places))
	for _, place := range c.Places {
		if _, seen := next[place]; seen || !placeID.MatchString(place) {
			return nil, &RouteError{"bad-place", "place ids are unique lowercase slugs: " + place}
		}
		next[place] = nil
	}
	for _, p := range c.Paths {
		_, okA := next[p.A]
		_, okB := next[p.B]
		if !okA || !okB {
			return nil, &RouteError{"unknown-place", "a path joins places that don't exist: " + p.A + ", " + p.B}
		}
		if p.Minutes < 0 || p.Minutes > MaxMinutes {
			return nil, &RouteError{"bad-minutes", fmt.Sprintf("minutes are whole numbers from 0 to %d", MaxMinutes)}
		}
		next[p.A] = append(next[p.A], walk{p.B, p.Minutes})
		next[p.B] = append(next[p.B], walk{p.A, p.Minutes})
	}
	return next, nil
}

// Settle settles places in order of walking time from start. It stops as soon as target is
// settled; an empty target settles every reachable place.
func Settle(c Campus, start, target string) ([]Settled, error) {
	next, err := Neighbours(c)
	if err != nil {
		return nil, err
	}
	for _, place := range []string{start, target} {
		if _, ok := next[place]; !ok && (place == start || target != "") {
			return nil, &RouteError{"unknown-place", "no such place: " + place}
		}
	}
	best := map[string]int{start: 0}
	via := map[string]string{start: ""}
	done := map[string]bool{}
	f := &frontier{{0, start}}
	var settled []Settled
	for f.Len() > 0 {
		e := heap.Pop(f).(entry)
		if done[e.place] {
			continue // a stale entry: this place already settled with fewer minutes
		}
		done[e.place] = true
		settled = append(settled, Settled{e.place, e.minutes, via[e.place]})
		if e.place == target {
			break
		}
		for _, w := range next[e.place] {
			offer := e.minutes + w.minutes
			// Only a strictly shorter offer wins, so a tie keeps the route found first.
			if current, reached := best[w.to]; !done[w.to] && (!reached || offer < current) {
				best[w.to] = offer
				via[w.to] = e.place
				heap.Push(f, entry{offer, w.to}) // the old, longer entry stays and is skipped
			}
		}
	}
	return settled, nil
}
Reading the TypeScriptA small heap, Maps, and null

Frontier is a binary min-heap written in the file, because JavaScript has no built-in priority queue. ahead compares minutes, then ids. best, via, and done are Maps and a Set. A settled place’s via is null for the start.

Errors are a RouteError whose code matches the Go version.

Reading the Gocontainer/heap and an empty via

frontier implements heap.Interface, and heap.Push and heap.Pop keep the order. Go’s container/heap documentation puts it plainly: “A heap is a common way to implement a priority queue.”

Via is "" for the start, and an empty target means “settle every place.” Both work because a place id is never empty.

What is refusedIds, sizes, and minutes

More than 64 places or 256 paths is too-big. A place id must be a unique lowercase slug (bad-place). A path to a place that doesn’t exist, or a start or target that doesn’t exist, is unknown-place. Minutes must be whole numbers from 0 to 1,440 (bad-minutes).

Both languages run the same cases, and both check every settled time against an independent Bellman–Ford-style reference on 300 generated maps, along with every route’s walked minutes.

04 / Try a decision

Could Library still get closer?

This is the question that makes the whole algorithm safe. Answer it before you trust a settled time.

From the main gate, Library has just settled at 4 minutes. Gym is waiting at 5, Quad at 6, and Science lab at 9. Could a route discovered later still reach Library in fewer than 4 minutes?

05 / Follow the cost

Each path is offered at most once.

Dijkstra: time and extra space per operation
OperationTimeExtra spaceWhat it assumes
Settle the next placeO(log P)O(1)One pop from the heap. A stale entry is popped and skipped, which is one more pop.
Offer a neighbor a timeO(log P)O(1)A better offer pushes a new entry. The old, longer one stays in the heap until it is popped and skipped.
Settle every reachable placeO((V + P) log P)O(V + P)V places, P two-way paths. A place offers only to neighbors that haven’t settled, so each path is offered at most once: at most P + 1 pushes and as many pops.
Stop at a targetO((V + P) log P)O(V + P)The same bound at worst. It often does far less, because the search ends when the target settles.
Scan instead of a heapO(V² + P)O(V)Find the next place by looking at every waiting one. Better when almost every pair of places has a path.
Walk the route backO(V)O(V)Follow each place to the place it was reached from.

The bound comes from counting offers. A place makes its offers once, when it settles, and only to neighbors that haven’t settled yet, so each path is offered once, from whichever end settles first. That is at most P offers, at most P + 1 entries ever pushed, and as many popped. Each push or pop costs O(log P). When no two paths join the same pair of places, P is below V², so log P is at most 2 log V and the bound is O((V + P) log V).

Princeton’s Algorithms, 4th edition gives the textbook version with an indexed heap that changes an entry in place: “time proportional to E log V (in the worst case).” Skipping stale entries instead trades a slightly bigger heap for simpler code.

On a campus where almost every building has a path to almost every other, P is close to V², and scanning an array of V times for the smallest is O(V²) overall, without a heap at all.

06 / Give it a real job

Stop when the target settles.

A campus map app is asked one trip at a time. quickestRoute in In the wild stops the search as soon as the target settles, because its time can’t change after that. From the main gate to the lecture hall, 7 of 9 places settle before it stops; the Dorms never need to.

Then it walks back: the hall was reached from the Quad, the Quad from the Library, the Library from the main gate. Reversed, that is the route. When two routes tie, the first one found keeps it, which is why the lab’s Campus preset always goes through the Quad. Try Slower library path and the route moves to the Science lab.

If the frontier runs out before the target settles, no path leads there, and quickestRoute returns no route. The Boathouse is that case: the search settles all 8 reachable places and stops.

In OSPF this runs on each router, and in a map app on the server that answers route requests; nothing in a component computes it.

07 / Make the call

Nonnegative times, one start.

Reach for Dijkstra when every path has a time of zero or more, and you want the quickest way from one place: to one target, or to all of them at once.

Something simpler fits some maps. If every path takes the same time, a breadth-first walk with a queue finds the same quickest times without a heap. If some links can give time back, use the Bellman–Ford lesson, as long as no loop gives back more time than it takes. And if you know roughly where the target is, like a straight-line distance on a real map, A* search uses that estimate to settle fewer places.

SourcesThe paper, the RFC, a textbook, and Go’s heap

All checked 13 September 2026.

08 / Take the idea with you

Explain a route without saying “Dijkstra.”

“Start at 0 and everything else at infinity. Keep taking the closest place you haven’t finished, and tell its neighbors how long it would take to reach them through it. A place you’ve finished can’t get any closer, so when the destination is finished, walk back the way each place was reached.”

Before moving on, open a map you know well and pick two places. Name the nearest first step, and check whether the quickest route actually takes it.

Connections to follow nextRelated lessons
  • Binary heap is the priority queue that picks the next place to settle.
  • Queue and deque is all you need when every path takes the same time.
  • A* search adds an estimate of the distance left, so it settles fewer places on the way.

Copy the complete example, add a shortcut from the Café to the Science lab, and predict the new route to the lecture hall before you run it.

Back to applied algorithms →