Skip to content
wannacode

City navigation

In short: An algorithms project: a program reads the city map as a graph, finds the route with the fewest streets with BFS and the fastest with Dijkstra, and shows where they differ.

Problem

The courier asks for a route

The courier company from the "Graphs" module has a depot in the city. The map is a text file with one street per line: from where, to where and how many minutes it takes to drive. - is a two-way street, -> is one-way:

map.txt
Depot - Station 4
Depot - Market 3
Station - Square 3
Market - Square 2
Market -> Park 4
Square - Cinema 5
Cinema - Hospital 3
Park - Stadium 2
Stadium - Hospital 2
Depot - Stadium 16

The dispatcher needs a route from the Depot to the Hospital. The courier on a bicycle asks for the route with the fewest crossroads, because they stop at every one. The courier in the van asks for the fastest. The answers are not the same.

This project is the program that reads the map and answers both questions. You build it in steps, each with output to compare yours against.

The idea

The plan

  1. The map as a graph. Crossroads are vertices, streets are weighted edges: the minutes. As in "Graph: adjacency list and matrix", you keep an adjacency list: a map from a crossroad to a list of streets.
  2. Fewest streets. BFS from the Depot in layers: first the crossroads one street away, then two.
  3. The minutes of a route. The sum of the minutes of its streets, to compare the two answers.
  4. Fastest. Dijkstra with a priority queue: it always takes out the crossroad with the fewest minutes so far.
  5. The way back. The same program from the Hospital to the Depot. The one-way street makes the way back different.

The program reads the map from standard input, so it works with any map. You run it with the file: node main.mjs < map.txt.

Exercise

Step 1: the map

Read the map into a map from a crossroad to a list of streets (to which crossroad and how many minutes). A two-way street goes into the lists of both ends, a one-way street only into the list of its start. A crossroad with no outgoing streets must be in the map too, with an empty list.

Print the number of crossroads and the neighbours of the Depot, the Market and the Park, in the order of the lines in the file:

Output
crossroads: 8
Depot: Station 4, Market 3, Stadium 16
Market: Depot 3, Square 2, Park 4
Park: Stadium 2
Solution
function addStreet(city, a, b, minutes, twoWay) {
if (!city.has(a)) city.set(a, []);
if (!city.has(b)) city.set(b, []);
city.get(a).push({ to: b, minutes });
if (twoWay) city.get(b).push({ to: a, minutes });
}
function readMap(text) {
const city = new Map();
for (const line of text.trim().split("\n")) {
const [a, kind, b, minutes] = line.trim().split(/\s+/);
addStreet(city, a, b, Number(minutes), kind === "-");
}
return city;
}
function neighbors(city, place) {
return `${place}: ${city.get(place).map((s) => `${s.to} ${s.minutes}`).join(", ")}`;
}
const city = readMap(readFileSync(0, "utf8"));
console.log(`crossroads: ${city.size}`);
console.log(neighbors(city, "Depot"));
console.log(neighbors(city, "Market"));
console.log(neighbors(city, "Park"));
At the top is import { readFileSync } from "node:fs";. readFileSync(0, "utf8") reads standard input: file descriptor 0. The file is .mjs so that import works.

The Park has one neighbour, the Stadium: Market -> Park is one-way and the Market is not in the Park's list.

Exercise

Step 2: fewest streets

Write fewestStreets(city, start, end): a BFS from start that stops as soon as it takes out end and returns the route as a list of crossroads. To build the route, keep for every crossroad where you came from (prev), then walk back from end and reverse the list.

Output
fewest streets: Depot → Stadium → Hospital
Solution
function route(prev, end) {
const places = [];
for (let at = end; at !== undefined; at = prev.get(at)) places.push(at);
return places.reverse();
}
function fewestStreets(city, start, end) {
const prev = new Map();
const seen = new Set([start]);
const queue = [start];
for (let head = 0; head < queue.length; head++) {
const at = queue[head];
if (at === end) return route(prev, end);
for (const { to } of city.get(at)) {
if (!seen.has(to)) {
seen.add(to);
prev.set(to, at);
queue.push(to);
}
}
}
return [];
}
const city = readMap(readFileSync(0, "utf8"));
console.log(`fewest streets: ${fewestStreets(city, "Depot", "Hospital").join(" → ")}`);

A crossroad is marked as seen when it goes into the queue, not when it comes out: otherwise one crossroad goes in several times.

Exercise

Step 3: how many minutes

Add minutesOf(city, route), which adds up the minutes of the streets along the route, and describe(city, route), which returns the route, the number of streets and the minutes. Print the route from step 2 with describe:

Output
fewest streets: Depot → Stadium → Hospital (2 streets, 18 min)
Solution
function minutesOf(city, route) {
let total = 0;
for (let i = 1; i < route.length; i++) {
total += city.get(route[i - 1]).find((s) => s.to === route[i]).minutes;
}
return total;
}
function describe(city, route) {
return `${route.join(" → ")} (${route.length - 1} streets, ${minutesOf(city, route)} min)`;
}
const city = readMap(readFileSync(0, "utf8"));
console.log(`fewest streets: ${describe(city, fewestStreets(city, "Depot", "Hospital"))}`);

Two streets, but 18 minutes: sixteen of them on the ring road.

See it

Two routes on one map

function fastest(city, start, end) {
const dist = new Map([[start, 0]]);
const prev = new Map();
const queue = new MinHeap();
queue.push(0, start);
while (queue.size > 0) {
const [minutes, at] = queue.pop();
if (minutes > dist.get(at)) continue;
if (at === end) return route(prev, end);
for (const { to, minutes: length } of city.get(at)) {
const t = minutes + length;
if (!dist.has(to) || t < dist.get(to)) {
dist.set(to, t);
prev.set(to, at);
queue.push(t, to);
}
}
}
return [];
}
Step by step, in words
  1. The map: 8 crossroads and 10 streets with their minutes. Market → Park is one-way; the ring road from Depot to Stadium is 16 min.
  2. BFS, one street from Depot: Station, Market, Stadium. Minutes do not matter to it.
  3. Two streets away: Square, Park, Hospital. Hospital is already in the queue, from Stadium.
  4. Fewest streets: Depot → Stadium → Hospital, 2 streets, but 18 min: 16 of them are the ring road.
  5. Dijkstra: the queue is ordered by minutes. Depot 0; its neighbours go into the queue: Station 4, Market 3, Stadium 16.
  6. The fastest in the queue is Market, 3 min. New: Square 5, Park 7.
  7. Station, 4 min: through it nothing gets faster.
  8. The fastest in the queue is Square, 5 min. New: Cinema 10.
  9. Park at 7 min. Through it Stadium drops from 16 to 9.
  10. The fastest in the queue is Stadium, 9 min. New: Hospital 11.
  11. Cinema, 10 min: through it nothing gets faster.
  12. Hospital comes out at 11 min: nothing left in the queue is faster, so this is the answer.
  13. Fastest: Depot → Market → Park → Stadium → Hospital, 4 streets, 11 min.
  14. Two answers to two questions: 2 streets and 18 min, or 4 streets and 11 min. For a courier, minutes are what count.

BFS sees only the number of streets and picks the ring road. Dijkstra takes the crossroads out by minutes and reaches the Hospital in 11 min through the Market, the one-way street and the Stadium. The Stadium first has 16 min along the ring road, then drops to 9 through the Park.

Exercise

Step 4: fastest

Write fastest(city, start, end) with Dijkstra: dist keeps the fewest minutes so far, the queue is ordered by minutes and prev keeps the route. Print both routes:

Output
fewest streets: Depot → Stadium → Hospital (2 streets, 18 min)
fastest: Depot → Market → Park → Stadium → Hospital (4 streets, 11 min)
Solution
function fastest(city, start, end) {
const dist = new Map([[start, 0]]);
const prev = new Map();
const queue = new MinHeap();
queue.push(0, start);
while (queue.size > 0) {
const [minutes, at] = queue.pop();
if (minutes > dist.get(at)) continue;
if (at === end) return route(prev, end);
for (const { to, minutes: length } of city.get(at)) {
const t = minutes + length;
if (!dist.has(to) || t < dist.get(to)) {
dist.set(to, t);
prev.set(to, at);
queue.push(t, to);
}
}
}
return [];
}
const city = readMap(readFileSync(0, "utf8"));
console.log(`fewest streets: ${describe(city, fewestStreets(city, "Depot", "Hospital"))}`);
console.log(`fastest: ${describe(city, fastest(city, "Depot", "Hospital"))}`);
JavaScript has no priority queue, so you need your own: a small min-heap, as in the lesson on the priority queue.
class MinHeap {
constructor() {
this.items = [];
}
get size() {
return this.items.length;
}
push(priority, value) {
const a = this.items;
a.push([priority, value]);
let i = a.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (a[p][0] <= a[i][0]) break;
[a[p], a[i]] = [a[i], a[p]];
i = p;
}
}
pop() {
const a = this.items;
const top = a[0];
const last = a.pop();
if (a.length > 0) {
a[0] = last;
let i = 0;
for (;;) {
const l = 2 * i + 1;
const r = l + 1;
let m = i;
if (l < a.length && a[l][0] < a[m][0]) m = l;
if (r < a.length && a[r][0] < a[m][0]) m = r;
if (m === i) break;
[a[m], a[i]] = [a[i], a[m]];
i = m;
}
}
return top;
}
}

Try it

Fill in

A crossroad can go into the queue several times, when the minutes to it drop. The old entry stays. What does the program do when it takes it out?

if (minutes > dist.get(at)) ;

Try it

Predict the way back

The courier returns from the Hospital to the Depot. The program asks fastest(city, "Hospital", "Depot"). What does it print?

const city = readMap(readFileSync(0, "utf8"));
console.log(`back: ${describe(city, fastest(city, "Hospital", "Depot"))}`);
Answer

Exercise

Step 5: the whole program

Add the way back. The whole program prints:

Output
fewest streets: Depot → Stadium → Hospital (2 streets, 18 min)
fastest: Depot → Market → Park → Stadium → Hospital (4 streets, 11 min)
back: Hospital → Cinema → Square → Market → Depot (4 streets, 13 min)
The whole program
import { readFileSync } from "node:fs";
class MinHeap {
constructor() {
this.items = [];
}
get size() {
return this.items.length;
}
push(priority, value) {
const a = this.items;
a.push([priority, value]);
let i = a.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (a[p][0] <= a[i][0]) break;
[a[p], a[i]] = [a[i], a[p]];
i = p;
}
}
pop() {
const a = this.items;
const top = a[0];
const last = a.pop();
if (a.length > 0) {
a[0] = last;
let i = 0;
for (;;) {
const l = 2 * i + 1;
const r = l + 1;
let m = i;
if (l < a.length && a[l][0] < a[m][0]) m = l;
if (r < a.length && a[r][0] < a[m][0]) m = r;
if (m === i) break;
[a[m], a[i]] = [a[i], a[m]];
i = m;
}
}
return top;
}
}
function addStreet(city, a, b, minutes, twoWay) {
if (!city.has(a)) city.set(a, []);
if (!city.has(b)) city.set(b, []);
city.get(a).push({ to: b, minutes });
if (twoWay) city.get(b).push({ to: a, minutes });
}
function readMap(text) {
const city = new Map();
for (const line of text.trim().split("\n")) {
const [a, kind, b, minutes] = line.trim().split(/\s+/);
addStreet(city, a, b, Number(minutes), kind === "-");
}
return city;
}
function route(prev, end) {
const places = [];
for (let at = end; at !== undefined; at = prev.get(at)) places.push(at);
return places.reverse();
}
function fewestStreets(city, start, end) {
const prev = new Map();
const seen = new Set([start]);
const queue = [start];
for (let head = 0; head < queue.length; head++) {
const at = queue[head];
if (at === end) return route(prev, end);
for (const { to } of city.get(at)) {
if (!seen.has(to)) {
seen.add(to);
prev.set(to, at);
queue.push(to);
}
}
}
return [];
}
function minutesOf(city, route) {
let total = 0;
for (let i = 1; i < route.length; i++) {
total += city.get(route[i - 1]).find((s) => s.to === route[i]).minutes;
}
return total;
}
function describe(city, route) {
return `${route.join(" → ")} (${route.length - 1} streets, ${minutesOf(city, route)} min)`;
}
function fastest(city, start, end) {
const dist = new Map([[start, 0]]);
const prev = new Map();
const queue = new MinHeap();
queue.push(0, start);
while (queue.size > 0) {
const [minutes, at] = queue.pop();
if (minutes > dist.get(at)) continue;
if (at === end) return route(prev, end);
for (const { to, minutes: length } of city.get(at)) {
const t = minutes + length;
if (!dist.has(to) || t < dist.get(to)) {
dist.set(to, t);
prev.set(to, at);
queue.push(t, to);
}
}
}
return [];
}
const city = readMap(readFileSync(0, "utf8"));
console.log(`fewest streets: ${describe(city, fewestStreets(city, "Depot", "Hospital"))}`);
console.log(`fastest: ${describe(city, fastest(city, "Depot", "Hospital"))}`);
console.log(`back: ${describe(city, fastest(city, "Hospital", "Depot"))}`);

Try it

Find the bug

A colleague has shortened addStreet. Now the way back comes out at 11 min:

Output
back: Hospital → Stadium → Park → Market → Depot (4 streets, 11 min)

Pick the line with the bug.

See it

Measure: the city grows

A real city has thousands of crossroads. The grid is a speed test: n crossroads in a square, streets between neighbours, minutes by a formula. The courier starts at the top left corner and goes to the bottom right:

function gridCity(size) {
const city = new Map();
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
const at = `${r}:${c}`;
if (c + 1 < size) addStreet(city, at, `${r}:${c + 1}`, 1 + ((r * 7 + c * 13) % 9), true);
if (r + 1 < size) addStreet(city, at, `${r + 1}:${c}`, 1 + ((r * 11 + c * 5) % 9), true);
}
}
return city;
}
const city = gridCity(100);
console.log(`crossroads: ${city.size}`);
const few = fewestStreets(city, "0:0", "99:99");
console.log(`fewest streets: ${few.length - 1} streets, ${minutesOf(city, few)} min`);
const fast = fastest(city, "0:0", "99:99");
console.log(`fastest: ${minutesOf(city, fast)} min`);
Output
crossroads: 10000
fewest streets: 198 streets, 990 min
fastest: 693 min

Run both algorithms in the browser. BFS looks at every street once: O(n). So does Dijkstra, but every heap entry costs O(log n): O(n log n). At n = 1000 that is 3,966 against 15,778 operations, and at n = 100,000 it is 398,158 against 2,348,779:

  • BFS: fewest streets O(n)
  • Dijkstra: fastest O(n log n)
Algorithmn = 10n = 1,000n = 100,000
BFS: fewest streets223,966398,158
Dijkstra: fastest3415,7782,348,779
The code runs in your browser, in JavaScript. The number of operations is the same in every language, because the algorithm is the same. The time depends on the machine, so it is only for comparison. “≈” is an estimate from the curve: we do not run an n that big, so the page does not freeze.
function fastest(city, start, end) {
const dist = new Map([[start, 0]]);
const prev = new Map();
const queue = new MinHeap();
queue.push(0, start);
while (queue.size > 0) {
const [minutes, at] = queue.pop();
if (minutes > dist.get(at)) continue;
if (at === end) return route(prev, end);
for (const { to, minutes: length } of city.get(at)) {
const t = minutes + length;
if (!dist.has(to) || t < dist.get(to)) {
dist.set(to, t);
prev.set(to, at);
queue.push(t, to);
}
}
}
return [];
}
More precisely, if you are curious

Dijkstra is slower, but it answers the question about minutes, and BFS cannot. Both stop as soon as they take out the target: for a nearby target they look at a small part of the city.

Navigation for whole countries does not run plain Dijkstra from every place. It preprocesses the map (contraction hierarchies, for example) and searches with A*, which adds an estimate of how far the target still is. The base is still Dijkstra.

Exercise

Road works

Solve this one in your editor.

A closed street. The Park – Stadium street is closed for road works. Give readMap a set of closed streets ("Park Stadium") that skips those lines in both directions. Print both routes from the Depot to the Hospital:

Output
fewest streets: Depot → Stadium → Hospital (2 streets, 18 min)
fastest: Depot → Market → Square → Cinema → Hospital (4 streets, 13 min)

More tasks

  • Only the minutes. Write a version of fastest that returns the minutes to every crossroad without stopping at the target. Which crossroad is the farthest from the Depot?
  • Without a crossroad. The Square is closed for an event. How do you leave it out without touching the map?
  • An empty ring road. At night the ring road is empty and takes 6 min. Change the minutes in the map: which route is the fastest now, and is it the same as the BFS one?

Test

Check yourself

1. BFS and Dijkstra give different routes from the Depot to the Hospital. Which one is wrong?
2. Why does BFS not find the fastest route in this city?
3. In Dijkstra, the entry that comes out of the queue sometimes has more minutes than dist. What does the program do?
4. Market -> Park is one-way. What belongs in the adjacency list?
5. The city is a grid with n crossroads. How does the number of operations of the two algorithms grow?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·