Pebellslearn by building
0%
RoadmapMatrixCatalogSandboxSign in
Graphs  ›  Lesson

Depth-First Search & Listener Circles

Depth First Search 24 minFollowing one path as far as it goes
You're building a piece ofTunebox — recommendations
This piece — listener_circles(): Finds groups of listeners connected to each other.
Scenario Tunebox's Circles tab groups friends who are linked to each other, directly or through someone else, so each group can share a blend playlist.
Your task
Build listener_circles(graph). `graph` is Tunebox's undirected friendship graph — each friendship listed from both sides, every listener a key. Return the connected groups: each circle sorted alphabetically, circles ordered by their first name, and a listener with no friends as a circle of one. Example: listener_circles({"ana": ["ben"], "ben": ["ana"], "cal": []}) → [["ana", "ben"], ["cal"]].

Depth-First Search & Listener Circles

Tunebox's Circles tab groups friends who are linked to each other — directly, or through a chain of friends — so each circle can share a blend playlist. Finding a circle means following friendships until you run out, which is the natural shape of depth-first search (DFS): go as deep as possible down one path, back up, try the next. The same walk finds loops and puts tasks in a safe order.

Go deep first

Recursion is the most natural way to write DFS: visit a listener, then — before looking at anyone else — visit their first friend, and that friend's first friend, and so on. The call stack remembers where to come back to.

python

The indentation shows the shape: from Ana down to Ben, down to Dia, dead end, back up to Ana, then across to Cal.

What does this print?
graph = {"ana": ["ben", "cal"], "ben": ["dia"], "cal": [], "dia": []}
order = []

def dfs(name):
    order.append(name)
    for nxt in graph[name]:
        dfs(nxt)

dfs("ana")
print(order)

That snippet has no visited set, and it got away with it only because this graph has no loops. Friendships always loop — Ana lists Ben and Ben lists Ana:

What happens when this runs?
graph = {"ana": ["ben"], "ben": ["ana"]}

def dfs(name):
    for nxt in graph[name]:
        dfs(nxt)

dfs("ana")
print("done")

The same walk with an explicit stack

Recursion uses Python's call stack. You can hold that stack yourself — a list you append to and pop from — and the walk no longer depends on the recursion limit. Two details make it visit in exactly the recursive order:

  1. Push neighbours in reverse, so the first-listed one is on top and popped next.
  2. Check seen when you pop, because a listener may be pushed more than once before its turn comes.
python

❓ Cross-question — "If the iterative version is safer, why learn recursion?" The recursive version says what DFS is in six lines, and some jobs — like the topological sort below — need work after all of a vertex's neighbours finish, which recursion gives you for free. Switch to a stack when a chain could run thousands deep.

Coming from Java/JS: deep recursion ends in StackOverflowError in Java and RangeError: Maximum call stack size exceeded in JS. Python's default limit is much lower — about 1000 frames — and raises RecursionError.

Connected components

One DFS finds everyone reachable from one listener: that listener's connected component, or circle. To find every circle, loop over all listeners and start a new DFS from each one nobody has visited yet. Step through it:

step through it
1graph = {"ana": ["ben"], "ben": ["ana"], "cal": [], "dia": ["eli"], "eli": ["dia"]}
2seen, circles = set(), []
3for name in graph:
4 if name in seen:
5 continue
6 circle, stack = [], [name]
7 seen.add(name)
8 while stack:
9 current = stack.pop()
10 circle.append(current)
11 for friend in graph[current]:
12 if friend not in seen:
13 seen.add(friend)
14 stack.append(friend)
15 circles.append(circle)
16print(circles)

Order inside a circle doesn't matter here, so this stack marks listeners as it pushes them — each is pushed once, and the reverse-and-check-on-pop details aren't needed. Every listener is visited exactly once across all the searches, so the whole thing is still O(V + E). Cal, with no friends, is a circle of one.

Cycle detection

In an undirected graph, a friendship back to the listener you just came from isn't a loop — every edge is stored both ways. A loop is reaching an already-visited listener by any other edge.

In a directed graph — playlists that include other playlists — it's subtler: reaching a visited node may just mean two routes lead there, like a diamond. This version can't tell the difference:

broken — fix it

This diamond has no loop, so it should print False — but it prints True. Fix the check so only a playlist still on the current path counts as a cycle.

The fix is three states — often called colours: white (not visited), grey (on the current path) and black (finished). Meeting a grey node means an edge points back up the path you're on: a cycle. Meeting a black node is harmless.

python

Topological sort

Tunebox's upload pipeline has steps that depend on others: you can't fingerprint a track before it's transcoded, and nothing is published until it's fingerprinted and has artwork. A graph with no cycles (a DAG) of "must happen before" edges can be put in a topological order — every step after everything it depends on.

DFS gives it almost for free: a step finishes only after everything it leads to has finished. Record each step as it finishes, then reverse the list.

python

If the graph has a cycle there is no valid order at all — combine this with the grey/black check to report it.

What DFS costs

Operation Cost
DFS from one vertex O(V + E)
All connected components O(V + E) — each vertex visited once overall
Cycle detection (three colours) O(V + E)
Topological sort O(V + E)
Memory O(V) — the visited set plus the stack (or call stack)

Idioms & real-world patterns

A nested function sees its enclosing variables

Defining visit inside the function that uses it lets it mutate seen and order without passing them down. Rebinding a name (count += 1) needs nonlocal count first:

python

graphlib — topological sort in the standard library

graphlib.TopologicalSorter (Python 3.9+) orders a graph given each node's predecessors, and raises CycleError on a loop:

python

⚡ Advanced — Kahn's algorithm

A second classic topological sort uses no DFS at all. Count each step's unmet prerequisites (its in-degree); everything at zero is ready. Take one, and lower the count of everything it unlocks — anything reaching zero becomes ready too. Run out of ready steps before placing them all, and the leftovers sit on a cycle. Because you choose which ready step goes next, Kahn's algorithm is the one to reach for when the order has to follow a rule.


🎯 Your turn

Write listener_circles(graph) — the connected groups in Tunebox's undirected friendship graph (each friendship is listed from both sides, and every listener is a key). Sort each circle alphabetically, order the circles by their first name, and give a listener with no friends a circle of their own:

  • listener_circles({"ana": ["ben"], "ben": ["ana"], "cal": []})[["ana", "ben"], ["cal"]]
  • listener_circles({})[]

Hint — loop over the listeners in sorted order; each unvisited one starts a new DFS that collects their whole circle.

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-upRecursive DFS order

Write dfs_order(graph, start) returning listeners in the order a recursive depth-first search visits them from start — exploring neighbours in the order they're listed and skipping anyone already visited. Every name is a key. Example: {"ana": ["ben", "cal"], "ben": ["dia"], "cal": [], "dia": []} from "ana"["ana", "ben", "dia", "cal"].

dfsrecursionvisited-set
DrillSame walk, explicit stack

Same walk, no recursion. Write stack_order(graph, start) using an explicit stack (a list) that returns exactly the order the recursive DFS would — neighbours explored in listed order, visited listeners skipped. Every name is a key. Example: {"ana": ["ben", "cal"], "ben": ["dia"], "cal": [], "dia": []} from "ana"["ana", "ben", "dia", "cal"].

dfsstack
BuildFriendship loops

Write has_friend_loop(graph) for Tunebox's undirected friendship graph — each friendship listed from both sides, every name a key, nobody friends with themselves — returning True if some chain of friendships leads from a listener back to themselves without reusing a friendship, else False. Example: {"ana": ["ben", "cal"], "ben": ["ana", "cal"], "cal": ["ana", "ben"]}True.

cycle-detectiondfsstack
BossBadge unlock order

Tunebox badges unlock in stages. badges lists every badge; rules is an edge list where [a, b] means a must be earned before b. Write unlock_order(badges, rules) returning an order of all the badges that respects every rule. Whenever more than one badge is available, take the alphabetically first. If the rules contain a cycle, no order exists — return [].

topological-sortheapqin-degreecycle-detection
CapstonePlaylist battle teams

Playlist Battle splits friends into two teams so that no two friends are on the same team. graph is an undirected friendship graph (each friendship listed from both sides; every name is a key). Write battle_teams(graph) returning [team_a, team_b], each sorted, or [] if no such split exists. To make the answer unique: go through listeners alphabetically, and whenever one has no team yet, put them in team_a — their friends' teams then follow.

dfsconnected-componentsstack
listener_circles.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
listener_circles(graph) → listEvery circle of connected listeners, each sorted, ordered by first name.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.
Depth-First Search & Listener Circles — Pebells