Build the mental model
Related-lessons data naturally forms a graph: each lesson is a node, and an edge to another lesson means 'this leads naturally to that,' possibly weighted by difficulty jump or estimated time. Finding the best route from a learner's current lesson to a goal is a shortest-path problem, and Dijkstra's algorithm is the right tool because edge weights are non-negative (you never have a 'negative cost' lesson transition) and you need the true minimum-cost path, not just any path. The naive alternative — plain breadth-first search — only works if every edge has equal weight; the moment lessons differ in cost (a big conceptual leap costs more than a small refinement), BFS's 'fewest edges' answer can be wrong, picking a path with fewer hops but a higher real cost. Dijkstra fixes this by always expanding the frontier node with the lowest known total cost first, using a priority queue (`heapq`) so it never has to re-examine settled nodes, giving the mathematically shortest weighted path in O((V+E) log V).
Connect it to a real scenario
This models a real 'recommended learning path' feature: a learner finishing an intro Python lesson wants to reach 'Building REST APIs,' and the platform needs to suggest the shortest sequence of prerequisite lessons connecting the two, rather than dumping the entire catalog on them or listing lessons in an arbitrary order. Each edge weight could represent estimated minutes-to-complete or a difficulty delta, so the shortest path is genuinely the fastest reasonable route through the curriculum, not just the fewest clicks. The same graph could also drive the 'Related Lessons' sidebar already implied by the site's per-tutorial chapter structure, ranking suggestions by path cost from the lesson currently open.
Try the working example
import heapq
def shortest_path(graph, start, goal):
"""graph: {node: [(neighbor, cost), ...]}. Returns (path, total_cost)."""
# (cumulative_cost, node, path_so_far)
frontier = [(0, start, [start])]
visited = set()
while frontier:
cost, node, path = heapq.heappop(frontier) # always pop lowest cost
if node == goal:
return path, cost
if node in visited:
continue
visited.add(node)
for neighbor, edge_cost in graph.get(node, []):
if neighbor not in visited:
heapq.heappush(frontier, (cost + edge_cost, neighbor, path + [neighbor]))
return None, float("inf") # goal unreachable
# Small "related lessons" graph: edge weight = estimated extra minutes needed
lesson_graph = {
"python-basics": [("python-functions", 10), ("python-oop", 25)],
"python-functions": [("python-oop", 12), ("python-decorators", 15)],
"python-oop": [("rest-apis", 20)],
"python-decorators": [("rest-apis", 8)],
"rest-apis": [],
}
path, cost = shortest_path(lesson_graph, "python-basics", "rest-apis")
print("Path:", " -> ".join(path))
print("Total estimated minutes:", cost)
Prints `Path: python-basics -> python-functions -> python-decorators -> rest-apis` and `Total estimated minutes: 33`, the cheaper route versus going through `python-oop`.5-minute try-it
Extend the graph so edge costs adjust dynamically based on a learner's mastery of prerequisite topics — lower the cost of an edge if the learner already knows that topic.
One important caution
Checking `visited` only when popping instead of also skipping already-visited neighbors before pushing lets many stale, higher-cost entries pile up in the heap — doesn't break correctness but wastes memory and time on large graphs
Adding a negative edge weight (e.g. a '-5 minute skip credit' for a review lesson) silently violates Dijkstra's non-negative-weight assumption and can produce a wrong shortest path with no error raised — negative weights require Bellman-Ford instead
Wikipedia — A* search algorithm — Data Structures & Algorithms