Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Graph Structure & Decomposition  ›  Lesson

Finding the Cycle

Cycle Detection 28 minThree colours: an edge back into grey is the loop
You're building a piece ofMeridian — the network model
This piece — find_loop(): Finds a chain of overflow rules that would send parcels round in a circle forever.
Scenario When a Meridian depot fills up, it forwards overflow to another depot, and each depot manager sets their own rule. Nobody sees the whole picture, so sooner or later north forwards to east, east to south, and south back to north, and a van of parcels drives in circles all night. The loop detector finds that circle before the vans do.
Your task
Build find_loop(depots, forwards). Each [a, b] in `forwards` is an overflow rule: when depot a is full, it forwards parcels to depot b. Return a loop of rules as the list of depots in forwarding order, rotated to start at its alphabetically smallest depot, or [] if there is no loop. Every test has at most one loop. Example: find_loop(["a", "b", "c"], [["b", "c"], ["c", "a"], ["a", "b"]]) → ["a", "b", "c"].

Finding the Cycle

In short: colour every depot white, grey or black as DFS runs: grey means "on the path you're walking right now", black means "finished, nothing below it can lead back to you". An arrow into a grey depot is a back edge, and it closes a loop — the loop is exactly the grey path from that depot down to where you stand. An arrow into a black depot never is a loop, because anything reachable from a black depot was already explored while it was still grey.

Five depots: hub and north and east and south are grey, yard is black, with a dashed red back edge from south to north closing the loop north, east, south

When a Meridian depot fills up, it forwards its overflow to another depot. Each depot manager sets their own rule, and nobody looks at all of them together. So one night north forwards to east, east forwards to south, south forwards back to north, and a van of parcels drives in a circle until morning.

Kahn's algorithm from the last lesson could tell you that some loop exists, because loops are what it leaves behind. The operations team wants more than that: which depots, in what order, so someone can phone the right manager. This lesson finds the loop itself, using depth-first search and three colours.

Visited is not enough

The first idea is to run DFS and shout "loop!" the moment you reach a depot you've already visited. Try it on four depots where two routes happen to meet:

What does this print?
after = {"a": ["b", "c"], "b": ["d"], "c": ["d"], "d": []}
visited = set()

def visit(depot):
    if depot in visited:
        return True                # "seen it before, must be a loop"
    visited.add(depot)
    return any(visit(nxt) for nxt in after[depot])

print(visit("a"))

Reaching an old depot tells you two routes meet. It doesn't tell you there's a way back. For that you need to know whether the old depot is still on the path you are walking right now.

Three colours

Give every depot one of three colours:

  • white: not visited yet;
  • grey: visited, and still on the current DFS path, because we are somewhere underneath it;
  • black: finished, meaning everything reachable from it has been explored.

A depot turns grey when DFS enters it and black when DFS leaves it. The grey depots always form a single path from the root to where you're standing. Now the rule is exact:

An edge into a grey depot is a back edge, and it closes a loop: the grey path runs from that depot down to you, and the edge leads back up to it. An edge into a black depot is never a loop, because if anything reachable from it led back to you, DFS would have found that while it was still grey.

Here it is on a small slice of the city. Grey depots are highlighted and badged, black ones are finished:

watch it move · graph

Only an edge back into a grey depot is a loop. The grey depots on screen are the loop's candidates, in path order.

Start at hub. Grey means it is on the current path and not finished. 5 nodes, 6 edges, directed. At hub. Frontier: hub. hub: grey.

1 / 8

The last frame is the whole trick. The loop is the part of the grey path from the depot you hit to the depot you're on. If you keep the grey path in a list, and an iterative DFS already does, reading the loop off takes one slice:

What does this print?
path = ["hub", "north", "east", "south"]
back_edge = ("south", "north")
print(path[path.index(back_edge[1]):])

An iterative three-colour DFS

A recursive DFS would work, but a long chain of depots can have thousands of levels and Python's default recursion limit is about 1,000. The explicit version keeps a stack of (depot, iterator over its rules). The iterator remembers how far through that depot's rules we've got, which is what a recursive call's local loop would have remembered:

python

Every depot changes colour at most twice and every rule is followed once, so this is O(V + E), the same as the DFS it's built from. A depot that forwards to itself is handled for free: it is grey when its own rule is examined. Space is O(V + E) too: the colour map and the explicit stack hold at most one entry per depot, and sends holds every rule once.

Step through the colours on a three-depot loop:

step through it
1sends = {"a": ["b"], "b": ["c"], "c": ["a"]}
2colour = {"a": "white", "b": "white", "c": "white"}
3colour["a"] = "grey"
4stack = [("a", iter(sends["a"]))]
5found = None
6while stack and found is None:
7 depot, rest = stack[-1]
8 nxt = next(rest, None)
9 if nxt is None:
10 colour[depot] = "black"
11 stack.pop()
12 elif colour[nxt] == "grey":
13 found = [d for d, _ in stack]
14 elif colour[nxt] == "white":
15 colour[nxt] = "grey"
16 stack.append((nxt, iter(sends[nxt])))
17print(found)

Two-way roads are different

Meridian's roads mostly run both ways, and an undirected graph needs a different test. In an adjacency list, every two-way road a–b appears twice, once as b in a's list and once as a in b's. So the moment DFS walks from a to b, b sees a as a visited neighbour. That's just the road you arrived on, and it isn't a ring. This checker misses that:

broken — fix it

A straight line of three junctions has no ring, but this prints True for it. Fix has_ring so the road you arrived on doesn't count, and a real triangle is still caught.

In an undirected graph there are no one-way traps like the diamond, so any visited neighbour other than your parent really does mean a ring. There's also the union-find test from the graphs section: add roads one by one, and the first road whose two ends already share a root closes a ring. It is the drill exercise below.

❓ Cross-question: "Why doesn't union-find work for one-way rules?" Union-find only knows "connected", not "in which direction". The diamond's four rules connect all four depots into one group, and the fourth rule joins two depots already in the same group, yet there is no directed loop. Direction is exactly the information union-find throws away.

Your turn to drive it

Here's the three-colour DFS on the forwarding rules, badging each depot with its colour and marking the back edge in red when it appears. Delete the rule ["south", "north"] and run it again: every depot should finish black with no error. Then add ["yard", "hub"] and find out which loop it reports.

your code, drawn · graph

Your three-colour DFS. The grey depots are always one path, and a loop is an arrow from the bottom of that path back into it.


Idioms & real-world patterns

Where loops hide in real systems

The same check guards a lot of production software. Build systems refuse circular targets, spreadsheets refuse a cell that refers to itself through other cells, package managers refuse circular dependencies, and databases look for deadlocks where transaction A waits for B and B waits for A. That last one is exactly Meridian's forwarding loop: each edge means "is waiting for".

Rotate before you compare

A loop has no natural first element, so north, east, south and east, south, north are the same loop. Before storing, comparing or testing loops, rotate each to start at its smallest member, and for two-way rings choose a direction too. The main task and the capstone exercise both ask for this.

⚡ Advanced: which edges are which

DFS sorts every directed edge into one of four kinds: tree edges (into white), back edges (into grey), and forward or cross edges (both into black). A directed graph has a loop exactly when some DFS finds a back edge. The next two lessons use the same classification: strongly connected components are built out of back edges, and bridges are the tree edges that no back edge spans.


Quick practice

What does this print?
sends = {"p": ["q"], "q": ["r"], "r": []}
colour = {d: "white" for d in sends}
found = False

def visit(d):
    global found
    colour[d] = "grey"
    for nxt in sends[d]:
        if colour[nxt] == "grey":
            found = True
        elif colour[nxt] == "white":
            visit(nxt)
    colour[d] = "black"

visit("p")
print(found)
broken — fix it

a, b, c, d form a diamond with no loop, but this prints True. d is reached once as black and once more as a false "loop". Fix the colour test.

🎯 Your turn

Write find_loop(depots, forwards). Each [a, b] rule forwards overflow from a to b. Return a loop as a list of depots in forwarding order, rotated to start at its alphabetically smallest depot, or [] if parcels can never circle. Each test has at most one loop.

  • find_loop(["a", "b", "c"], [["b", "c"], ["c", "a"], ["a", "b"]]) → ["a", "b", "c"]
  • find_loop(["a", "b", "c", "d"], [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]) → []

Hint: the iterative three-colour DFS above, plus i = loop.index(min(loop)) and loop[i:] + loop[:i].

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

Practice — level up

0/6 solved

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

Warm-upIs this a real loop?

A dispatcher reports a loop as a list of depots. Write is_loop(path, forwards) returning True if there is a rule from each depot to the next and from the last back to the first, else False. An empty path is not a loop. Example: ["a", "b", "c"] with rules a→b, b→c, c→a → True.

cycle-detectionset
DrillA ring of two-way roads

Two-way roads have no direction, so the colour trick needs care there. Union-find gives a simpler test. Write road_has_ring(junctions, roads) returning True if the undirected roads contain a cycle. Example: ["a", "b", "c"], [["a", "b"], ["b", "c"], ["c", "a"]] → True.

cycle-detectionunion-find
BuildAny loop at all?

Write has_loop(depots, forwards) returning True if the directed overflow rules contain any loop. Use three colours and an explicit stack, so a chain of 10,000 depots can't overflow Python's recursion limit. Example: ["a", "b", "c", "d"], [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]] → False.

cycle-detectionthree-colourdfs
BossWhich rule broke it?

Overflow rules are added one at a time from an audit log. Write first_bad_rule(depots, rules) returning the index of the first rule that creates a loop together with the rules before it, or -1 if none ever does. Example: ["a", "b", "c"], [["a", "b"], ["b", "c"], ["c", "a"]] → 2.

cycle-detectionback-edgebfs
CapstoneName the ring

Two-way roads again, and now Meridian wants the ring itself. Write find_ring(junctions, roads) for an undirected road network with at most one ring (no road joins a junction to itself, and no two roads join the same pair). Return the ring's junctions in order around it, starting at its alphabetically smallest junction and heading towards the smaller of that junction's two ring neighbours; return [] if there is no ring. Example: roads p–s, s–q, q–r, r–p → ["p", "r", "q", "s"].

cycle-detectiondfs-treedfs
ExplainWhy 'already visited' is the wrong test

A teammate's loop detector for one-way rules reports a loop whenever DFS reaches a depot it has already visited. It flags Meridian's rules even though parcels never circle. Explain what kind of graph fools it, why, and what the correct test is.

find_loop.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
find_loop(depots, forwards) → listOne loop of overflow rules, starting at its smallest depot, or [] if parcels can never circle.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.