Unit 11: Going in Circles
Finding the loop, and where it starts.
Unit 11 of 25 in Competitive programming for kids. Its 4 lessons are Three Colours, Loops Without Arrows, Which Nodes Are In It and Tortoise and Hare — below is everything each one explains, and a question or two from it to try.
Every sample on this page was run through real Python before it shipped, and prints exactly what it says it prints.
This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.
🚦 Three Colours
Meeting a node you are still inside
Give every node a colour. White: not touched. Grey: started, still exploring below it. Black: completely finished.
A one-way graph has a cycle exactly when a search finds an arrow pointing at a grey node — because grey means "you are still standing on it", so that arrow leads back to where you came from.
The whole check
Arrows into black nodes are fine — that part of the graph is finished and led nowhere back. Only grey means trouble.
Here 1 to 2 to 3 to 1 is the loop, and the search finds it the moment it looks at 3's arrow.
Python
def has_cycle(n, adj):
colour = [0] * n
def dfs(node):
colour[node] = 1
for nxt in adj[node]:
if colour[nxt] == 1:
return True
if colour[nxt] == 0 and dfs(nxt):
return True
colour[node] = 2
return False
for i in range(n):
if colour[i] == 0 and dfs(i):
return True
return False
print(has_cycle(5, [[1], [2], [3], [1], [0]]))
print(has_cycle(5, [[1], [2], [3], [], [0]]))
It prints
True False
Why "any visited node" is the wrong test
A diamond — 0 points at 1 and 2, both point at 3 — has no cycle at all, but the second route into 3 finds a node that has been visited.
If you had checked "visited" rather than "grey", you would have reported a loop that is not there. Black exists precisely to say "visited, and safely finished".
Python
adj = [[1, 2], [3], [3], []]
colour = [0] * 4
seen_black = []
def dfs(node):
colour[node] = 1
for nxt in adj[node]:
if colour[nxt] == 1:
print("cycle!")
if colour[nxt] == 0:
dfs(nxt)
colour[node] = 2
seen_black.append(node)
dfs(0)
print(seen_black)
It prints
[3, 1, 2, 0]
And the order they turn black is useful
Nodes go black after everything they point at. So reversing that list gives a topological order — 0, 2, 1, 3 here — which is the other way to write unit 10's sort.
Kahn's version is still the safer one in Python, because this one is a recursion.
Try it yourself
A search finds an arrow pointing at a black node. What does that mean?
- There is a cycle
- Nothing is wrong — that part of the graph is finished and leads nowhere back
- The graph is undirected
- The node has been visited twice, which is a bug
What does this print?
Python
def has_cycle(n, adj):
colour = [0] * n
def dfs(node):
colour[node] = 1
for nxt in adj[node]:
if colour[nxt] == 1:
return True
if colour[nxt] == 0 and dfs(nxt):
return True
colour[node] = 2
return False
for i in range(n):
if colour[i] == 0 and dfs(i):
return True
return False
print(has_cycle(3, [[1, 2], [2], []]))
Answer them in the app
🔗 Loops Without Arrows
Every undirected edge looks like a loop
Walk from 0 to 1 along an edge. From 1 there is an edge back to 0, and 0 has been visited — but that is the road you just came down, not a cycle.
So remember which node you came from and ignore exactly that one.
Python
def has_cycle(n, edges):
adj = [[] for i in range(n)]
for a, b in edges:
adj[a].append(b)
adj[b].append(a)
visited = [False] * n
def dfs(node, parent):
visited[node] = True
for nxt in adj[node]:
if not visited[nxt]:
if dfs(nxt, node):
return True
elif nxt != parent:
return True
return False
for i in range(n):
if not visited[i] and dfs(i, -1):
return True
return False
print(has_cycle(4, [(0, 1), (1, 2), (2, 3)]))
print(has_cycle(4, [(0, 1), (1, 2), (2, 3), (3, 0)]))
It prints
False True
Counting edges answers it too
A connected graph with no cycles is a tree, and a tree with n nodes has exactly n - 1 edges. One more edge anywhere, and a cycle is unavoidable.
So for a connected graph you can answer without searching at all — although you still have to check it is connected.
Python
for n, m in [(4, 3), (4, 4), (7, 6), (7, 9)]:
print(n, m, m >= n)
It prints
4 3 False 4 4 True 7 6 False 7 9 True
Careful with a repeated edge
If the input can list the same edge twice, the parent trick reports "no cycle" — the second copy looks like the road you came down.
Two roads between the same pair of towns really is a loop. A problem allowing that needs the edge remembered by its number, not by the node at the end of it.
Try it yourself
A connected undirected graph has 10 nodes and 9 edges. What is it?
- It has a cycle
- It is a tree
- It is not connected
- Not enough information
What does this print?
Python
def has_cycle(n, edges):
adj = [[] for i in range(n)]
for a, b in edges:
adj[a].append(b)
adj[b].append(a)
visited = [False] * n
def dfs(node, parent):
visited[node] = True
for nxt in adj[node]:
if not visited[nxt]:
if dfs(nxt, node):
return True
elif nxt != parent:
return True
return False
return dfs(0, -1)
print(has_cycle(5, [(0, 1), (1, 2), (2, 0), (2, 3)]))
Answer them in the app
🔍 Which Nodes Are In It
Yes is rarely the answer they want
Problems ask "print the cycle", not "does one exist". The search already knows: when it meets a grey node, that node is the start of the loop and the node it is standing on is the end.
Walk the parents from the end back to the start and you have it.
Remembering who sent you
parent[nxt] = node as you go down. found is a two-item list rather than two ordinary variables, because a value assigned inside a nested function would otherwise be a brand new local one.
Python
n = 5
adj = [[1], [2], [3], [1], [0]]
colour = [0] * n
parent = [-1] * n
found = [-1, -1]
def dfs(node):
colour[node] = 1
for nxt in adj[node]:
if colour[nxt] == 1:
found[0] = nxt
found[1] = node
return True
if colour[nxt] == 0:
parent[nxt] = node
if dfs(nxt):
return True
colour[node] = 2
return False
for i in range(n):
if colour[i] == 0 and dfs(i):
break
print(found)
print(parent)
It prints
[1, 3] [-1, 0, 1, 2, -1]
Walking the chain back
From the end of the loop, follow parents until you reach the start, then reverse. Add the start on the front and you have the whole circle: 1, 2, 3.
Every arrow in that list really is in the graph, which is a check worth doing by eye once.
Python
parent = [-1, 0, 1, 2, -1]
start = 1
end = 3
cycle = [end]
node = end
while node != start:
node = parent[node]
cycle.append(node)
cycle.reverse()
print(cycle)
It prints
[1, 2, 3]
The same shape, for the negative cycle of unit 9
Bellman–Ford also finds a loop rather than a route: if an edge still relaxes on round n, its far end is inside — or reachable from — a negative cycle.
Following the "who improved me last" chain back n times lands you on the cycle, and walking the parents from there prints it. Same trick, different search.
Try it yourself
The search meets a grey node. Which node starts the cycle?
- The one being stood on
- The grey node that was pointed at
- The first node of the search
- Whichever has the smallest number
What does this print?
Python
parent = [-1, 0, 1, 2, 3]
start = 0
end = 4
cycle = [end]
node = end
while node != start:
node = parent[node]
cycle.append(node)
cycle.reverse()
print(cycle)
Answer them in the app
🏆 Tortoise and Hare
A path that must end in a loop
Back to a successor graph — one exit per node. Follow it for ever and you cannot escape: there are only n nodes, so somewhere you must arrive somewhere you have been.
The question is how far along the loop starts, and how long it is. A visited list answers it in n memory. Two pointers answer it in none.
One walks, one runs
The tortoise takes one step at a time, the hare two. If there is a loop, the hare comes round the back of the tortoise and they land on the same node.
They are guaranteed to meet, because on the loop the gap between them closes by exactly one each round.
Python
succ = [1, 2, 3, 4, 5, 3]
slow = succ[0]
fast = succ[succ[0]]
steps = 0
while slow != fast:
slow = succ[slow]
fast = succ[succ[fast]]
steps += 1
print(slow, fast, steps)
It prints
3 3 2
The bit that looks like magic
Now put the tortoise back at the start and move both one step at a time. Where they meet is the first node of the loop.
It is not magic, it is arithmetic: when they first met, the hare had walked exactly one whole number of loops more than the tortoise, which makes the distance from the start to the loop the same as the distance from the meeting point round to it.
Python
succ = [1, 2, 3, 4, 5, 3]
slow = succ[0]
fast = succ[succ[0]]
while slow != fast:
slow = succ[slow]
fast = succ[succ[fast]]
slow = 0
while slow != fast:
slow = succ[slow]
fast = succ[fast]
start = slow
length = 1
node = succ[start]
while node != start:
node = succ[node]
length += 1
print(start, length)
It prints
3 3
Three steps, no memory
Find a meeting point. Walk from the start to find where the loop begins. Walk once round to measure it.
With a visited array this is easier to write and needs a number for every node. On a graph with a thousand million nodes described by a formula rather than a list, only the two pointers will do.
Try it yourself
Why must the hare and tortoise meet if there is a loop?
- The hare is faster
- Once both are on the loop the gap shrinks by one every round, so it reaches zero
- They start together
- They only meet sometimes
What does this print?
Python
succ = [1, 2, 0]
slow = succ[0]
fast = succ[succ[0]]
while slow != fast:
slow = succ[slow]
fast = succ[succ[fast]]
slow = 0
while slow != fast:
slow = succ[slow]
fast = succ[fast]
length = 1
node = succ[slow]
while node != slow:
node = succ[node]
length += 1
print(slow, length)
Answer them in the app