The Shortest Path
Scout is a survey robot. It gets dropped somewhere on a map and has to reach a charging station before its battery runs flat. Wandering isn't an option. Over six lessons you'll teach Scout how to find a route, and then how to prove that route is the best one possible.
The algorithms you'll build are the same ones inside GPS navigation, internet routers and video game characters. By the end you'll be able to explain why each one works, when it fails, and which one to choose.
Reading the map
The maps use real orienteering colours. Orienteers race between checkpoints on maps like these, choosing their own routes, which is exactly Scout's problem.
How the search is drawn
Every visualiser in this course uses the same four marks. They'll make sense properly in lesson 2, but it helps to know them now.
How this course works
Each lesson is a control on the course line down the side of the page. Work through them in order, because each algorithm fixes a problem the previous one couldn't handle.
- Grids are graphs. Turn a map into something a computer can search.
- Blind search. Depth-first and breadth-first search, and why one of them guarantees the fewest steps.
- Weighted terrain. When steps cost different amounts, Dijkstra's algorithm finds the cheapest route.
- Heuristics and A*. Give the search a sense of direction without losing the guarantee.
- Measuring efficiency. Race every algorithm and back your conclusions with data.
- Challenge arena. Apply everything to harder problems of your own choosing.
You'll write Python in your usual editor (Thonny, IDLE or VS Code). Each lesson has code to build, checkpoint questions that mark themselves, and a short quiz. Finishing a quiz with every answer correct ticks off that control. Your progress is saved in this browser.
Grids are graphs
Before a computer can search a map, the map has to become something it can reason about. That something is a graph.
By the end of this lesson you can
- describe a grid map as a graph made of nodes and edges
- locate any cell using (row, col) coordinates in Python
- write a
neighbours()function that never walks through rock or off the edge of the map
Find the route yourself
Start with the problem, not the theory. Plot Scout's route across this small map by tapping cells one at a time, starting next to the triangle. Scout can only move up, down, left or right, one cell per move.
Think about it
Compare your route with the people around you. If two of you found routes of the same length, are they both "the shortest"? And if you found a short route, how could you prove nobody could do better without trying every possible route?
What we're aiming for
There can be several shortest routes with the same length, so "the shortest path" really means "a path that nothing beats". Proving that by trying every route is hopeless on a real map, because the number of routes explodes. The algorithms in this course prove it cleverly instead, by searching in a careful order.
Nodes and edges
A graph is a collection of nodes (also called vertices) joined by edges. Graphs don't care about shapes or distances on a page; they only record what connects to what. That makes them perfect for describing any problem that involves moving between places: train stations, web pages linking to each other, friends on social media, or cells on a map.
A grid map becomes a graph with two simple rules:
- every cell Scout can stand on becomes a node
- every pair of cells Scout can move directly between gets an edge
Rock cells become nothing at all. They simply disappear from the graph, which is how the graph "knows" Scout can't go there.
Some vocabulary you'll use for the rest of the course:
- Two nodes are neighbours if an edge joins them.
- A path is a sequence of nodes where each one is a neighbour of the next.
- The length of a path is the number of edges (moves) in it. A route through 13 cells has length 12.
- In lesson 3, edges get a weight: a cost for crossing them. Then the shortest path is the one with the smallest total weight.
Grids in Python
The simplest way to store a map in Python is a list of strings. Each string is a row, and each character is a cell. We'll use # for rock, . for open ground, S for the start and G for the goal.
Row first, always
We write positions as (row, col), so grid[row][col]. That's the opposite of (x, y) in maths, where the horizontal number comes first. Rows count downward from 0 at the top. Mixing these up is the single most common bug in grid code.
Finding neighbours
Every search algorithm needs to ask one question over and over: from this cell, where can Scout go next? That's the job of neighbours(). For a cell at (row, col), the four candidate moves are:
- up:
(row - 1, col) - down:
(row + 1, col) - left:
(row, col - 1) - right:
(row, col + 1)
A candidate only counts if it's on the map and not rock. Try it below: tap any open cell and the explorer shows what neighbours() should return, and why each rejected candidate was rejected.
The sneaky Python bug
Python allows negative indexes: grid[-1] is the last row, not an error. If you forget to check r >= 0, a cell on the top row will happily report a "neighbour" on the bottom row, and Scout will teleport across the map. Try tapping a cell on the top edge above to see how the explorer handles it.
Complete the function below. Replace each TODO with working code.
Keep the order
Check the directions in the order up, down, left, right. The order doesn't change the length of the shortest path, but it does change which of several equally short routes gets chosen. Keeping the same order as this page means your results will match the visualisers.
Checkpoint: test your function
Run your code and answer these. The page works out the correct answers from the same map, so if yours differ, your function has a bug.
Watch
Blind search
Scout has no idea where the charging station is. All it can do is look at the cells next to it. How do you search a map when you can't see where you're going?
By the end of this lesson you can
- explain the frontier and how every search algorithm uses it
- compare depth-first search (a stack) with breadth-first search (a queue)
- explain why breadth-first search always finds the fewest steps
- rebuild the route using a
came_fromdictionary
Three kinds of cell
At any moment during a search, every cell on the map is in one of three states:
- Explored: Scout has visited it and already looked at all its neighbours. (Yellow in the visualisers.)
- Frontier: Scout knows it exists, because it was seen next to an explored cell, but hasn't visited it yet. (Blue dots.)
- Unknown: not seen at all yet.
The frontier is the boundary between what Scout has explored and what it hasn't. Every search algorithm in this course runs the same loop:
- Put the start cell in the frontier.
- Take one cell out of the frontier. This is the current cell (magenta ring).
- If it's the goal, stop.
- Otherwise, add each of its neighbours that hasn't been seen before to the frontier.
- Go back to step 2.
That's the whole idea. The only thing that changes between algorithms is which cell step 2 takes out. And that depends entirely on what kind of container the frontier is.
Stacks and queues
A stack works like a pile of plates: you add to the top and take from the top, so the last thing in is the first thing out (LIFO). A queue works like the line at the canteen: you join at the back and get served from the front, so the first thing in is the first thing out (FIFO).
Depth-first search: the stack
If the frontier is a stack, Scout always continues from the cell it discovered most recently. It charges down one corridor as far as it can go, and only backs up when it hits a dead end. That's depth-first search (DFS).
Press Step a few times and watch the frontier panel on the right. Then press Play.
Predict, then test
DFS does find a route through Maze A. Is it a good route? Count roughly how long it is, then switch the map to Open field and run DFS again. What does its route look like when there are no walls to guide it?
What you should notice
On Maze A, DFS finds a route of 82 steps when one of 30 exists. On the open field it wanders in a zigzag because it always follows the newest cell, even when that leads away from the goal. DFS is guaranteed to find a route if one exists, but nothing about how it works makes that route short.
Breadth-first search: the queue
Now change one thing: make the frontier a queue. Scout always takes the oldest cell in the frontier. The start's neighbours were added first, so they're all explored before any of their neighbours. The search spreads out evenly in every direction, like ripples from a stone dropped in a pond. This is breadth-first search (BFS).
Turn on Show step counts to see how many moves each cell is from the start.
Why a diamond?
On the open field, the explored area of BFS grows in a very particular shape. What shape is it, and why isn't it a circle?
Explanation
It's a diamond. Every cell labelled 5 is exactly five moves from the start, and because Scout can't move diagonally, the cells five moves away form a diamond, not a circle. Each ring of the diamond is a set of cells at the same distance.
Why BFS always finds the fewest steps
This is the most important idea of the lesson, so it's worth following carefully.
- BFS explores every cell 1 step away before any cell 2 steps away, every cell 2 steps away before any cell 3 steps away, and so on. The queue forces this, because cells are added in the order they were discovered.
- Suppose the goal is really d steps away. BFS can't reach it earlier than ring d, because no route that short exists.
- And it can't reach it later, because it finishes ring d before starting ring d + 1.
- So the first time BFS reaches the goal, it has used the fewest possible steps. No cleverness needed; the order does the proving.
This proof only works if every step costs the same. Hold that thought for lesson 3.
Remembering the way back
When BFS reaches the goal, it knows the goal is reachable, but it hasn't stored the route. The fix is simple: whenever a cell is discovered, write down which cell it was discovered from. We store this in a dictionary called came_from.
In the visualisers, Show came_from arrows draws these links as small arrows pointing back toward the cell each one came from. Once the goal is found, follow the arrows backward from the goal to the start, then reverse the list. That's the route.
Each cell remembers one thing
Every arrow points back one step toward the start. Because BFS discovers each cell by the shortest possible route, following the arrows from any explored cell gives the shortest route to that cell, not just to the goal.
Build it
Here's breadth-first search in Python. Read it line by line and match each part to the loop from the top of the lesson. deque is Python's fast queue: append() joins the back and popleft() serves the front.
To turn this into depth-first search, you change exactly one line: replace frontier.popleft() with frontier.pop(). Now it takes from the back, which makes the deque behave like a stack. Try it and compare the results.
Checkpoint: run your search
Watch
Weighted terrain
Real ground isn't all the same. Scrub slows Scout down and marsh drains its battery. The route with the fewest steps might not be the cheapest one.
By the end of this lesson you can
- explain why BFS fails when moves have different costs
- trace Dijkstra's algorithm by hand on a weighted graph
- use a priority queue to always expand the cheapest cell next
- explain why Dijkstra's answer is guaranteed to be the cheapest
When steps stop being equal
From now on, entering a cell costs energy: 1 for open ground, 3 for scrub and 5 for marsh. The cost of a route is the total energy for every cell it enters (the start is free, since Scout is already there).
Run BFS on Arena B below and look at the route cost when it finishes. Then switch to Dijkstra and run it again.
What went wrong for BFS?
BFS found a route with fewer steps. Why is it still the worse route for Scout?
Explanation
BFS counts steps, and on Arena B its 20-step route wades straight through the marsh for a cost of 32. Dijkstra's route takes 24 steps but stays on open ground, for a cost of 24. BFS's proof depended on every step costing the same. Once that stops being true, "fewest steps" and "cheapest" are different questions.
The fix: always expand the cheapest
Edsger Dijkstra's idea was a small change to BFS with a big effect. Instead of taking the oldest cell from the frontier, take the one with the lowest total cost so far. We call that cost g.
BFS explored in rings of equal steps. Dijkstra explores in rings of equal cost. Cheap ground gets explored quickly, and expensive ground gets put off until everything cheaper has been tried.
There's one more change. With BFS, the first time we saw a cell was always by its shortest route. With costs, a cell might first be seen along an expensive route and later reached more cheaply. So Dijkstra checks every time: if the new route to a neighbour is cheaper than the best one recorded, it updates the cost and came_from. This update is called relaxing an edge.
A bit of history
Dijkstra designed the algorithm in 1956 while sitting at a café in Amsterdam with his fiancée. He later said it took him about twenty minutes, and that he did it without pencil and paper, which forced him to keep it simple.
Trace it by hand
Before coding, step through Dijkstra on a small graph. Numbers on the edges are costs. The yellow badges show each node's best known cost so far, and the magenta lines show the came_from links as they form. Step through slowly and read each explanation.
Why the answer can be trusted
When Dijkstra takes a node out of the frontier, its cost is final. It will never find a cheaper route to that node later. Here's why:
- The node taken out has the lowest cost of anything in the frontier. Call that cost g.
- Any other route to it would have to leave the explored area through some frontier node, and every frontier node already costs at least g.
- Costs can only go up as a route gets longer, because every cell costs at least something.
- So no other route can come in under g. When the goal comes out of the frontier, its cost is the cheapest possible.
Step 3 is the catch: the proof needs every cost to be zero or more. If some cell gave back energy (a negative cost), Dijkstra could lock in an answer too early and be wrong.
Your turn: trace this one
Use the same method on this graph, starting from S. Work it out on paper with a table like the one above, then enter the final cheapest cost to each node.
The priority queue
Dijkstra needs a frontier that can always hand over the cheapest item quickly. That's a priority queue. Think of a hospital emergency department: patients aren't seen in the order they arrive, but in order of urgency.
Python's heapq module keeps a list arranged so the smallest item is always at the front. We store (cost, cell) pairs, so the smallest cost comes out first.
See the cost contours
Turn on Show costs and run Dijkstra. Each number is the cheapest cost from the start to that cell. Watch the explored area stretch quickly across open ground and crawl through scrub and marsh. Try painting your own scrub and marsh with the tools, then run it again.
Build it
Dijkstra is BFS with a priority queue and a cost check. Add the terrain costs and Arena B to your file, then complete the TODO lines.
Why skip cells in done?
When a cheaper route to a cell is found, we push a new entry rather than hunting for the old one in the heap. The old, more expensive entry is still in there. When it eventually comes out, the cell is already done, so we ignore it.
Checkpoint: Arena B
Watch
Heuristics and A*
Dijkstra always finds the cheapest route, but it searches in every direction equally, including straight away from the goal. What if Scout had a rough idea of which way to head?
By the end of this lesson you can
- define a heuristic and calculate Manhattan distance
- explain why greedy best-first search is fast but unreliable
- explain how A* combines g and h into f = g + h
- decide whether a heuristic is admissible, and why that matters
The wasted effort
Run Dijkstra on the open field and count how much of the map it explores before reaching a goal that's in plain sight. Dijkstra is careful, but it isn't smart: it has no idea where the goal is until it trips over it.
A heuristic: an educated guess
A heuristic is a quick estimate of how far a cell is from the goal. It doesn't need to be exact; it just needs to be cheap to calculate and point roughly the right way. We call it h.
Because Scout moves in four directions, the natural estimate is Manhattan distance: count the rows and the columns between the cell and the goal, and add them. It's named after the grid of streets in Manhattan, where you can't cut diagonally through the buildings.
h = |row − goal_row| + |col − goal_col|
Greedy best-first: all instinct
What if we swap Dijkstra's rule for the heuristic, and always expand the cell with the smallest h? This is greedy best-first search. It charges toward the goal and, on an open map, barely explores anything.
But instinct can be wrong. Try it on The long wall and on Forest run, and compare the route cost with Dijkstra's.
Fast, but...
Greedy explored far fewer cells than Dijkstra. What did it cost?
Explanation
On The long wall, greedy heads straight at the wall, then slides along it and ends up with a 26-step route when 22 is possible. On Forest run it ploughs through expensive ground because h knows nothing about terrain, paying 43 when 31 is possible. Greedy only looks at where it's going, never at what it has already spent.
A*: the best of both
Dijkstra only looks backward at what has been spent (g). Greedy only looks forward at what's left (h). A* (say "A-star") adds them together:
f = g + h
f is an estimate of the total cost of the best route that goes through this cell. A* always expands the cell with the lowest f. Cells that are cheap to reach and look close to the goal get explored first, and cells heading the wrong way get put off.
Use the Inspect tool below, then tap cells during or after a run to see their g, h and f values.
The guarantee: admissible heuristics
A* is only guaranteed to find the cheapest route if the heuristic never overestimates the true remaining cost. A heuristic like that is called admissible.
Manhattan distance is admissible on our maps. Every move brings Scout at most one cell closer to the goal, and every move costs at least 1. So the real remaining cost can never be less than the Manhattan distance. The estimate might be too low (marsh makes the real cost higher), but it's never too high.
Why does overestimating break things? If h is too big for a cell that's actually on the best route, that cell's f looks worse than it really is. A* might finish along another route before it ever gets around to checking the good one.
Break the guarantee on purpose
The slider multiplies the heuristic: f = g + w × h. Run A* on Forest run at w = 0, w = 1, w = 2 and w = 5. For each, write down the cells explored and the route cost. What happens as w grows, and at which point does the route stop being the cheapest?
What you should find
At w = 0 the heuristic disappears and A* is exactly Dijkstra. At w = 1 it's normal A*: same cost as Dijkstra, far fewer cells explored. As w grows past 1, the heuristic can overestimate, so A* gets faster but starts accepting more expensive routes. Large values behave almost like greedy. Games often use a weight a little above 1 on purpose, because "nearly the best route, found quickly" beats "the perfect route, found late".
Build it
A* is Dijkstra with one extra term in the priority. Copy your dijkstra() function, rename it, and change the priority you push onto the heap.
Watch out
Notice that new_cost is now built from cost_so_far[current], not from the number popped off the heap. The heap holds f = g + h, and adding h into g would count the estimate as real cost.
Checkpoint: A* on Arena B
Watch
Measuring efficiency
You now have five algorithms. Some guarantee the best route and some don't. Some explore the whole map and some go almost straight there. Time to stop guessing and collect evidence.
By the end of this lesson you can
- measure search effort by counting nodes expanded
- compare algorithms fairly on the same maps
- explain the worst case, and why every algorithm here can explore the whole grid
- justify which algorithm suits which situation, using data
What should we measure?
Timing code in seconds is tempting, but it depends on the computer, what else is running and even the room temperature. A fairer measure is nodes expanded: the number of cells taken out of the frontier and processed. It's the same on every computer, and it tracks how much work the algorithm really did.
We also care about the quality of the answer: route cost, and whether it matched the cheapest possible.
The race
All five algorithms run side by side on the same map, one step each per tick. Choose a map and start the race. When every algorithm has finished, the results table and chart appear below.
Record and explain
Run the race on at least three maps. For each, note which algorithm explored the fewest cells while still finding the cheapest route. Is it always the same one? Is there a map where the gap between them almost disappears?
Things to look for
A* is usually the winner on efficiency among the algorithms that guarantee the cheapest route. Greedy often explores less still, but check its cost column. In Maze A, walls force every algorithm down the same corridors, so the heuristic helps much less. A good heuristic shines in open space and matters least in tight mazes.
The worst case
Computer scientists usually describe an algorithm by its worst case: the most work it could ever have to do. For all five algorithms, the worst case is exploring every cell on the map. That happens when the goal is in the last place the search looks, or when there's no route at all (the search can't give up until the frontier is empty).
If a map has n open cells, BFS and DFS do work proportional to n, written O(n). Dijkstra and A* also have to keep their priority queue in order, which adds a little on top: roughly O(n log n). A good heuristic doesn't change A*'s worst case, but it makes the typical case far better, and that's what you see in the race.
Force the worst case
In the Challenge arena you can edit maps freely. Build a map where the goal is completely walled in, then run A*. How many cells does it explore before it gives up?
Pull it all together
Complete the table from memory, then check it. This is the whole course on one page.
Your own data
Add a counter to each of your Python functions: set expanded = 0 before the loop, add 1 each time a cell comes out of the frontier and isn't skipped, and return it alongside the result. Then run all of them on both maps and chart the results.
Why your counts might differ slightly
When two cells tie in the frontier, different programs break the tie differently, so explored counts can differ by a few cells from the race above. Route costs for BFS, Dijkstra and A* must match exactly, though, because those are guaranteed.
Watch
Challenge arena
You know how the algorithms work, why they work and when they fail. Now use that knowledge on problems that don't come with instructions.
Choose a challenge
Pick one challenge (or more, if you finish). Build it in Python using your own functions from lessons 1 to 4. Each one tests a different piece of theory.
Moving hazards
Some cells flood every few turns and become impassable. Scout replans its route each time the map changes.
Make the map change every n moves, rerun A* from Scout's current position, and print the route after each replan.
Hint
Keep Scout's position in a variable. Each turn: move one step along the current path, update the map, then rerun the search from the new position if the path now crosses a flooded cell.
Teleport pads
Two cells marked T are linked: stepping onto one lets Scout move to the other for a cost of 1.
Update neighbours() to include the linked pad, then decide whether Manhattan distance is still admissible. Prove your answer with a test map.
Hint
A teleport can make the true remaining cost much smaller than Manhattan distance. If h can be bigger than the true cost, it's no longer admissible. One fix: h = the smaller of the direct Manhattan distance and (distance to the nearest pad + 1 + distance from the other pad to the goal).
The unseen map race
Your teacher releases a new map at the start of the lesson. Everyone's search runs on it.
The cheapest route wins, and the fewest nodes expanded breaks ties. You can tune your heuristic and tie-breaking, but a route that isn't the cheapest can't win.
Hint
When two cells have the same f, prefer the one with the larger g (it's further along its route). This small tie-breaking change often cuts explored cells noticeably on open maps.
Sandbox
Every algorithm, every tool, any map. Use this to test ideas and to build the maps you need for your challenge.
Shortest paths in the real world
Navigation apps
A road network is a weighted graph: intersections are nodes, roads are edges, and the weight is travel time. Real systems add tricks to cope with millions of roads, but underneath they build on Dijkstra and A*.
The internet
Routers share maps of the network and calculate the cheapest routes for data. The OSPF routing protocol, used inside many large networks, runs Dijkstra's algorithm directly.
Video games
When a character walks around obstacles to reach you, it's almost always A* on a grid or on a navigation mesh laid over the level.
Robots
Warehouse and delivery robots plan routes around shelves, people and each other, replanning as things move. That's the moving hazards challenge at full scale.
Reflect
Answer in full sentences. When you're done, copy your answers and paste them into your submission.
How you'll be assessed
| Developing | Consolidating | Extending |
|---|---|---|
| Implements BFS using the provided code and explains why it finds the shortest route on an unweighted grid. | Implements Dijkstra independently, rebuilds and displays the route, and explains why BFS fails once terrain has costs. | Implements A*, justifies whether the heuristic is admissible, and supports an efficiency comparison with their own data. |
Teacher solutions
These are the complete, tested solutions for every coding task. Enter the password to view them.
The full reference file. Running it prints the steps, cost and nodes expanded for every algorithm on both maps, then draws the BFS and Dijkstra routes on Arena B.