Pebellslearn by building
0%
RoadmapMatrixCatalogSandboxSign in
Graphs  ›  Lesson

Dijkstra & Smooth Radio

Shortest Paths 24 minAlways settle the cheapest place next
You're building a piece ofTunebox — recommendations
This piece — smoothest_mix(): The cheapest chain of transitions between two songs.
Scenario Smooth radio takes you from the song you're on to the one you asked for without a jarring key change. Each crossfade has a cost, and the station plays the cheapest chain.
Your task
Build smoothest_mix(transitions, start, goal). `transitions` is an undirected weighted edge list of [song, song, cost], where cost is how jarring that crossfade sounds. Return {"cost": total, "path": songs} for the cheapest chain from `start` to `goal` (every test mix has exactly one cheapest chain), or {"cost": -1, "path": []} if none exists. Example: smoothest_mix([["intro", "drift", 4], ["intro", "glow", 1], ["glow", "drift", 2]], "intro", "drift") → {"cost": 3, "path": ["intro", "glow", "drift"]}.

Dijkstra & Smooth Radio

Smooth radio gets you from the song you're on to the song you asked for without a jarring jump. Every crossfade has a cost — a clash of key, a lurch in tempo — and the station should play the chain with the lowest total cost, even if that means a few extra songs on the way. Counting hops won't find it. This lesson's algorithm will: Dijkstra's shortest path, the idea inside every route planner.

Why BFS isn't enough

BFS finds the route with the fewest edges. Once edges carry different weights, fewest isn't cheapest:

python

BFS would happily return the one-step jump costing 9. The three-step chain costs 3. BFS's guarantee — "first found is best" — came from every edge costing the same.

Always settle the cheapest song next

Dijkstra's fix is one rule: of all the songs you can reach but haven't settled, settle the one with the cheapest known cost next. With no negative weights, nothing discovered later can undercut it — any other route has to leave through a song that already costs at least as much.

"Give me the cheapest one" is a job for a priority queue, and in Python that's heapq. Push (cost, song) tuples; heappop always returns the smallest.

What does this print?
import heapq
heap = []
heapq.heappush(heap, (5, "glow"))
heapq.heappush(heap, (2, "drift"))
heapq.heappush(heap, (2, "aura"))
print(heapq.heappop(heap))

Relaxation

When you settle a song, look at each transition out of it and ask: is going through here cheaper than the best route I know? If so, lower that neighbour's cost. That update is called relaxation. Step through two rounds of it:

step through it
1inf = float("inf")
2dist = {"intro": 0, "glow": inf, "drift": inf}
3out = {"intro": [("drift", 4), ("glow", 1)], "glow": [("drift", 2)]}
4for song in ["intro", "glow"]:
5 for nxt, step in out[song]:
6 if dist[song] + step < dist[nxt]:
7 dist[nxt] = dist[song] + step
8print(dist)

Drift first gets 4 (straight from intro), then relaxes to 3 once glow is settled.

Dijkstra, in full

Put the two together: pop the cheapest entry, relax its neighbours, push any improvement. A song can end up in the heap more than once — an old, pricier entry left behind by a later improvement — so skip entries that are stale.

python

❓ Cross-question — "Why not remove the old entry instead of skipping it?" heapq can't find an item in the middle of the heap without an O(n) scan. Pushing a duplicate and ignoring the stale one when it surfaces ("lazy deletion") keeps every operation O(log n). The heap holds at most O(E) entries, and log E is within a constant of log V.

A song's cost is only final when it's popped, not when it's first pushed. Mark it finished any earlier and a cheaper route that arrives later is ignored:

broken — fix it

The cheapest chain is intro → glow → drift, costing 3, but this prints 9. Fix it so a later, cheaper route can still win.

Getting the chain, not just the cost

Exactly like BFS, keep a parent dict — but update it every time you relax, because a song's best predecessor can change:

python

When the goal is popped, walk parent back to None and reverse — the same walk-back you wrote for BFS.

Negative weights break it

Dijkstra's promise was "nothing later can be cheaper". A negative edge breaks it: a route through an expensive song can still come out cheaper after a big discount.

What does this print?
import heapq
graph = {"start": [("a", 2), ("b", 5)], "b": [("a", -4)], "a": []}
best = {"start": 0}
heap = [(0, "start")]
while heap:
    cost, song = heapq.heappop(heap)
    if song == "a":
        print(cost)
        break
    for nxt, step in graph[song]:
        if cost + step < best.get(nxt, float("inf")):
            best[nxt] = cost + step
            heapq.heappush(heap, (cost + step, nxt))

For graphs with negative weights, use Bellman-Ford (below). Smooth radio's costs are never negative, so Dijkstra is the right tool.

What it costs

Algorithm Time Works with
BFS O(V + E) every edge costs the same
Dijkstra with a binary heap O((V + E) log V) non-negative weights
Dijkstra with a plain array scan O(V²) non-negative weights, dense graphs
Bellman-Ford O(V × E) negative weights; detects negative cycles
heappush / heappop O(log n)

Coming from Java/JS: heapq is Java's PriorityQueue — but as functions acting on a plain list, and always a min-heap. JS has no built-in heap at all. For a max-heap in Python, push negated keys.


Idioms & real-world patterns

Break ties with a counter

When two costs tie, Python compares the next item in the tuple. If that item can't be compared — a dict, say — heappush raises TypeError. Put a running counter between the cost and the payload so the payload is never compared:

python

Stop when the goal is popped

For one start and one goal, return the moment the goal comes off the heap — its cost is final then. Returning when it's merely pushed is the bug from the fix above.

⚡ Advanced — Bellman-Ford

Bellman-Ford skips the heap and simply relaxes every edge, V − 1 times. After round k, every route that uses at most k edges has been accounted for, and a shortest route never needs more than V − 1. If a V-th round still improves something, there's a negative cycle — a loop that gets cheaper every time round.

python

⚡ Advanced — A*

Dijkstra expands outward in every direction. A* adds an estimate of the cost still to go — straight-line distance on a map, say — to each heap key, so the search leans toward the goal. With an estimate that never overshoots, it still finds the cheapest route, usually after exploring far less.


🎯 Your turn

Write smoothest_mix(transitions, start, goal). transitions is an undirected, weighted edge list — [song, song, cost] — and a transition works in both directions. Return the cheapest chain as {"cost": total, "path": songs}, or {"cost": -1, "path": []} if no chain exists:

  • smoothest_mix([["intro", "drift", 4], ["intro", "glow", 1], ["glow", "drift", 2]], "intro", "drift"){"cost": 3, "path": ["intro", "glow", "drift"]}
  • the same transitions from "glow" to "glow"{"cost": 0, "path": ["glow"]}

Hint — build an adjacency dict both ways, run Dijkstra with heapq, record parent whenever you relax, and rebuild the path when goal is popped.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of the app is built. ✅

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upPrice a chain

Before finding the cheapest chain, price a given one. Write path_cost(transitions, path)transitions is an undirected weighted edge list like [["intro", "glow", 1]] and path is a list of songs — returning the total cost of playing path in order, or -1 if some consecutive pair has no transition. A one-song path costs 0. Example: [["intro", "glow", 1], ["glow", "drift", 2]], ["intro", "glow", "drift"]3.

weighted-graphzipdict
DrillCheapest cost to every song

Write mix_costs(transitions, start) returning a dict of the cheapest total cost from start to every song it can reach, including start itself at 0. transitions is an undirected weighted edge list; songs that can't be reached are left out. Example: [["intro", "drift", 4], ["intro", "glow", 1], ["glow", "drift", 2]] from "intro"{"intro": 0, "glow": 1, "drift": 3}.

dijkstraheapqrelaxation
BuildRelease sync time

A new release is uploaded to one Tunebox server and copied outward. links is a directed weighted edge list — [a, b, s] means a copy travels from a to b in s seconds — and a server starts forwarding the moment it has the file. Write sync_time(links, origin) returning how many seconds pass until every server named in links has the release, or -1 if some server never gets it. With no links, only the origin exists: 0.

dijkstraweighted-graphmax
BossMost reliable stream

Tunebox streams between regions over links that sometimes drop. links is an undirected edge list of [a, b, r], where r (between 0 and 1) is the chance a stream survives that link, and a route's reliability is the product of its links. Write most_reliable(links, start, goal) returning the best reliability from start to goal, rounded to 4 decimals — 1.0 if they're the same region, 0.0 if no route exists.

dijkstraheapqpriority-queue
CapstoneCheapest mix within a step limit

Smooth radio gets a new rule: a mix may use at most max_steps transitions, however smooth a longer chain would be. transitions is an undirected weighted edge list with non-negative costs. Write mix_within(transitions, start, goal, max_steps) returning the cheapest total cost from start to goal using no more than max_steps transitions, or -1 if it can't be done. start == goal costs 0.

shortest-pathsrelaxationweighted-graph
smoothest_mix.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
smoothest_mix(transitions, start, goal) → dictThe cheapest chain of transitions as {cost, path}; cost -1 and an empty path if unreachable.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.