Every list we have built so far ends the same way: a last node whose next is None, the "it ends here" signal that stops our traversals. In this lesson we remove that signal on purpose: the last node will point to the first, closing the chain into a ring. The result is the circular list, in its singly and doubly linked variants, a structure tailor-made for problems that never end: turns that rotate, fair distributions, processes that start over. In TaskFlow we will give it its natural use: the cyclic distribution of tasks among team members, round-robin style. But a ring without None is also a trap: any traversal written "the usual way" (while current is not None) becomes an infinite loop. Learning to traverse with the right stopping condition is as important here as the structure itself.
Contents
- Closing the ring: what changes and what breaks
- Singly linked circular list: implementation with insertion and removal
- Safe traversals: correct stopping conditions
- Round-robin: cyclic distribution in TaskFlow
- Doubly linked circular list: the ring with a reverse gear
- When (and when not) to use a circular list
Closing the ring: what changes and what breaks
The structural change is a single arrow: where the singly linked list had last.next = None, the circular one has last.next = first.
graph LR
A["Anna"] --> B["Bruno"]
B --> C["Carla"]
C -- "the arrow that closes the ring" --> A
U[last] --> C
Immediate consequences:
Noneno longer exists as an end signal. Every traversal must invent another stopping condition; otherwise, it will spin forever. It is danger number one and we devote an entire section to it.- The distinction "being at the front / being at the back" disappears in the physical sense: from any node you can reach any other simply by advancing. The ring has no ends, only whatever reference point we decide on.
- A single reference is enough to manage it: by keeping
self.last, the first is alwaysself.last.next. Two for the price of one — that is how we get O(1) insertion at both "ends" without maintaining two references.
Singly linked circular list: implementation with insertion and removal
We reuse the Node class from 02-02 (data + next); what changes is the container:
class CircularList:
"""Singly linked circular list: the last node points to the first."""
def __init__(self):
self.last = None # reference to the last; the first is last.next
self.size = 0
def is_empty(self):
return self.last is None
def insert_back(self, data):
"""Adds after the last and becomes the new last. Cost: O(1)."""
new_node = Node(data)
if self.is_empty():
new_node.next = new_node # it points to itself!
self.last = new_node
else:
new_node.next = self.last.next # 1: the new node looks at the first
self.last.next = new_node # 2: the old last hooks it on
self.last = new_node # 3: the last moves
self.size += 1
def __len__(self):
return self.sizePause at the empty-list case: the first node points to itself (new_node.next = new_node). It is the minimal ring, of a single link, and it satisfies the definition: the last (itself) points to the first (itself). Many bugs in circular lists are born from mishandling this case.
And insert_front? Here circularity hands us a gem: in a ring, inserting at the front and inserting at the back are exactly the same rewiring — the new node always hooks in between the last and the first. The only difference is whether self.last moves to the new node (then the new node is the last: insertion at the back) or stays where it was (then the new node, being "the last one's next", is the new first: insertion at the front):
def insert_front(self, data):
"""Adds ahead of the first. Cost: O(1)."""
new_node = Node(data)
if self.is_empty():
new_node.next = new_node
self.last = new_node
else:
new_node.next = self.last.next
self.last.next = new_node
# self.last is untouched: the new node ends up as the first
self.size += 1And the removal, with its double edge case:
def remove(self, condition):
"""Removes the first node (in traversal order) meeting the condition.
Returns the data or None. Cost: O(n)."""
if self.is_empty():
return None
prev = self.last
current = self.last.next # we start at the first
for _ in range(self.size): # at most, one full lap
if condition(current.data):
if current is current.next: # only node in the ring
self.last = None
else:
prev.next = current.next # bridge
if current is self.last: # we removed the last
self.last = prev
self.size -= 1
return current.data
prev = current
current = current.next
return None- The
prev/currentpattern from 02-02 is still in force, with an extra elegance:prevstarts atself.last, which is exactly the node before the first. In a circular list, every node has a predecessor; there is no special head case. - The "only node" case is detected with
current is current.next(it points to itself) and empties the list. - The loop's stopping condition is no longer
None: it is counting (for _ in range(self.size)), the first of the safe traversal techniques we formalize next.
Safe traversals: correct stopping conditions
The lethal mistake in circular lists is this:
# NEVER with a circular list!
current = ring.last.next
while current is not None: # it will never be None: infinite loop
print(current.data)
current = current.nextThere are two correct techniques, each with its own terrain:
Technique 1 — Counting the nodes (requires a reliable size):
def __iter__(self):
"""One full lap starting at the first. Cost: O(n)."""
if self.is_empty():
return
current = self.last.next
for _ in range(self.size):
yield current.data
current = current.nextTechnique 2 — The sentinel node: remember the starting node and stop upon seeing it again:
def one_lap(ring):
"""Walks the ring exactly once, without using size."""
if ring.is_empty():
return
start = ring.last.next
current = start
while True:
print(current.data)
current = current.next
if current is start: # we are back at the starting point: done
break| Technique | Requires | Advantage | Risk |
|---|---|---|---|
Counting (range(size)) |
A correct counter | Simple, no identity comparisons | If size is wrong, one lap too many or too few |
Sentinel (is start) |
Nothing extra | Works even without a counter | Check after advancing; with == instead of is, false stops |
Two sentinel details worth an exam question: the comparison uses is (identity: the same node, not equal data — two distinct tasks could hold equal data) and it happens after advancing, not before; if you check at the top of the loop, the condition is true on the first iteration and nothing gets visited. The while True + break-after-advancing pattern solves that "do at least one pass" cleanly.
Round-robin: cyclic distribution in TaskFlow
The TaskFlow team has a classic problem: distributing incoming tasks in a fair, rotating fashion among its members — each new task goes to the next member in the rotation, and after the last one, back to the first. This scheme is called round-robin and it is everywhere: operating systems sharing CPU among processes, load balancers sharing requests among servers, games sharing turns among players. Its natural structure is the ring:
class TaskDispatcher:
"""Assigns tasks to team members in a rotating turn."""
def __init__(self, names):
self.team = CircularList()
for name in names:
self.team.insert_back(name)
self.turn = self.team.last # the "turn pointer" on the ring
def assign(self, task):
"""Assigns the task to the next member in the rotation. Cost: O(1)."""
self.turn = self.turn.next # advance the turn (there is never an end)
task["assigned_to"] = self.turn.data
return task
def remove_member(self, name):
"""A member leaves the team: out of the ring. Cost: O(n)."""
if self.turn.data == name: # don't leave the turn on the one leaving
self.turn = self.turn.next
self.team.remove(lambda d: d == name)
dispatcher = TaskDispatcher(["Anna", "Bruno", "Carla"])
tasks = [{"id": i, "title": f"Task {i}", "priority": 2, "status": "pending"}
for i in range(1, 8)]
for t in tasks:
dispatcher.assign(t)
print(f'{t["title"]} -> {t["assigned_to"]}')Output:
Task 1 -> Anna Task 2 -> Bruno Task 3 -> Carla Task 4 -> Anna ← the ring starts over, without a single if Task 5 -> Bruno Task 6 -> Carla Task 7 -> Anna
The elegance is in assign: there is no "have I reached the end?" check whatsoever. With a regular list we would have written the classic index = (index + 1) % len(team), with its modular arithmetic; the ring wraps around by pure topology: advancing always works. On top of that, team joins and departures (people entering and leaving the rotation) are the cheap insertions and removals linked lists give us without shifting anyone — with the fine detail in remove_member: if the turn sat on whoever is leaving, it advances before removing them, so we don't end up pointing at a node outside the ring.
Doubly linked circular list: the ring with a reverse gear
If we close the ring over double nodes (DoubleNode from 02-03), we get the doubly linked circular list: last.next is the first and first.prev is the last. All arrows matched, no None anywhere in the structure.
graph LR
A["Anna"] -- next --> B["Bruno"]
B -- prev --> A
B -- next --> C["Carla"]
C -- prev --> B
C -- next --> A
A -- prev --> C
class DoublyCircularList:
"""Doubly linked ring: navigable in both directions, with no ends."""
def __init__(self):
self.ref = None # some node of the ring ("the first")
self.size = 0
def insert(self, data):
"""Inserts before the reference (= at the back of the ring). Cost: O(1)."""
new_node = DoubleNode(data)
if self.ref is None:
new_node.next = new_node # a one-link ring...
new_node.prev = new_node # ...in both directions
self.ref = new_node
else:
last = self.ref.prev # the last one comes free!
new_node.prev = last # the new node looks both ways
new_node.next = self.ref
last.next = new_node # the neighbors return the gaze
self.ref.prev = new_node
self.size += 1
def remove_node(self, node):
"""Takes the node out of the ring. Cost: O(1) given the node."""
if node.next is node: # only link
self.ref = None
else:
node.prev.next = node.next
node.next.prev = node.prev
if node is self.ref:
self.ref = node.next
self.size -= 1
return node.dataTwo gifts from double circularity:
- The last one comes free:
self.ref.prev. In the singly linked circular list, reaching the node before a given one cost a full lap; here it is one arrow. That is why this class doesn't need to storelast: with a single reference it has both "ends" one hop away. remove_nodewith no head or tail cases: in a double ring, all nodes are interior —node.prevandnode.nextalways exist. Compare with the four branches of the linear doubly linked list from 02-03: here only the "single link" case remains, plus the care not to leaverefon the departing node. Circularity, which looked like a complication, simplifies removal.
Why would TaskFlow want the double version? For a rotating turn that can also be undone: "task 7 got canceled, give the turn back to whoever had it before" is self.turn = self.turn.prev, O(1). With the singly linked circular list, stepping the turn back would cost a full lap around the ring.
When (and when not) to use a circular list
| Situation | Circular? | Why |
|---|---|---|
| Rotating turns (round-robin, games, load balancing) | Yes | The "after the last, the first" is the structure, not an if |
| Looping playback (playlist, carousel, cyclic animation) | Yes | The traversal must never end |
| A buffer that overwrites itself circularly | Yes (a kindred idea) | Module 4's circular queue uses this same idea on an array |
| A sequence with a clear start and end (the task board) | No | The final None is information: "there is no more"; the ring destroys it |
| You need traversals that stop on their own | No | Every stop demands a counter or a sentinel: gratuitous complexity |
The practical rule: use a circular list when rotation is part of the problem domain, not as a general substitute for the linear list. The TaskFlow board will remain linear; the turn rotation, circular. Each structure in its place.
Common Mistakes and Tips
- The infinite loop from
while current is not None. The defining mistake of circular lists. The moment a list is circular, that pattern is forbidden: either you count nodes or you use a sentinel. If your program "hangs" while traversing, it is almost certainly this. - Checking the sentinel before advancing.
while current is not startas the loop's first line doesn't run a single iteration (you start atstart). The correct pattern visits, advances, and then compares. - Using
==instead ofiswith the sentinel. Two distinct nodes may hold equal data (two tasks with the same title);==would stop at the impostor. Node identity is always checked withis. - Forgetting the one-link ring. The node that points to itself (in the double version, in both directions) is the edge case of every circular list: inserting the first and removing the last must create and undo it carefully. Test your methods with lists of 0, 1, and 2 nodes, as always.
- Leaving an external reference on a removed node. The dispatcher's
self.turnor the double ring'sself.refmust move before removing the node they point at. A turn pointer on a node outside the ring will hand tasks to a ghost. - Tip: with circular lists, paper diagrams matter even more than with linear ones: draw the ring, mark the external reference, and simulate removing the pointed-at node. The three previous mistakes are visible at a glance in the drawing.
Exercises
Exercise 1 — A homegrown Josephus. The TaskFlow team draws lots for who presents the demo: standing in a circle, they count off by 3 and whoever is pointed at is eliminated; the last one standing wins. Write survivor(names, k) that, using CircularList (or circular nodes by hand), eliminates every k-th person and returns the name of the last one. Test with ["Anna", "Bruno", "Carla", "David", "Elena"] and k=3. (This is the Josephus problem, a classic with two thousand years of history.)
Exercise 2 — A turn with a reverse gear. Extend TaskDispatcher so it uses DoublyCircularList and add the method undo_assignment(), which steps the turn back one position in O(1) (the next task will again go to whoever was due before the last assignment). Demonstrate with a sequence: assign 4 tasks, undo one, assign another.
Exercise 3 — Counted laps. Write a function traverse_laps(ring, n_laps) that walks the ring exactly n_laps full times using the sentinel technique (without using size), returning the list of visited data. Verify with a 3-element ring and 2 laps that it returns 6 items and that each lap starts at the same node.
Solutions
Solution 1:
def survivor(names, k):
"""Josephus problem on a ring. Cost: O(n·k)."""
ring = CircularList()
for name in names:
ring.insert_back(name)
prev = ring.last
current = ring.last.next # the first
while len(ring) > 1:
for _ in range(k - 1): # advance k-1 places
prev = current
current = current.next
print("Eliminated:", current.data)
prev.next = current.next # bridge: out of the ring
if current is ring.last:
ring.last = prev
ring.size -= 1
current = prev.next # the one after the eliminated keeps counting
return ring.last.data
print(survivor(["Anna", "Bruno", "Carla", "David", "Elena"], 3))Output: Carla is eliminated, then Anna, then Elena, then Bruno — David survives. Notice that the elimination uses the usual bridge (prev.next = current.next) and that the ring makes "keep counting from the next one" natural: there is no special case when passing where the eliminated one used to be. With a list you would need index juggling with %; with the ring, topology does the work for us.
Solution 2:
class TaskDispatcherV2:
def __init__(self, names):
self.team = DoublyCircularList()
for name in names:
self.team.insert(name)
self.turn = self.team.ref.prev # so the 1st assigned is the 1st inserted
def assign(self, task):
self.turn = self.turn.next # advance: O(1)
task["assigned_to"] = self.turn.data
return task
def undo_assignment(self):
"""Returns the turn to its previous position. Cost: O(1)."""
self.turn = self.turn.prev # the 'prev' arrow in action!
dispatcher = TaskDispatcherV2(["Anna", "Bruno", "Carla"])
for i in range(1, 5):
t = dispatcher.assign({"id": i, "title": f"T{i}", "priority": 2, "status": "pending"})
print(t["title"], "->", t["assigned_to"]) # Anna, Bruno, Carla, Anna
dispatcher.undo_assignment() # the turn steps back to Carla
t = dispatcher.assign({"id": 5, "title": "T5", "priority": 2, "status": "pending"})
print(t["title"], "->", t["assigned_to"]) # T5 -> Anna (it's their turn again)undo_assignment is a single assignment thanks to the double ring's prev arrow: exactly the operation that in the singly linked circular list would have cost a full lap. It is the same moral as 02-03 (the extra arrow buys O(1) steps backward), now in ring form.
Solution 3:
def traverse_laps(ring, n_laps):
"""Walks the ring n_laps times with a sentinel. Without using size."""
if ring.is_empty() or n_laps <= 0:
return []
start = ring.last.next # sentinel: the first
visited = []
laps = 0
current = start
while True:
visited.append(current.data)
current = current.next
if current is start: # compare AFTER advancing, with 'is'
laps += 1
if laps == n_laps:
break
return visited
ring = CircularList()
for x in ("A", "B", "C"):
ring.insert_back(x)
print(traverse_laps(ring, 2)) # ['A', 'B', 'C', 'A', 'B', 'C']Six items, and each lap starts at the same node A: the sentinel works. The three ingredients of the safe traversal are in plain sight: sentinel fixed before starting, comparison with is, and the check after advancing. Change any of the three and you will get, depending on the case, zero iterations, false stops, or an eternal loop.
Conclusion
Closing the ring — making the last node point to the first, and in the double version the other way around too — turns the list into the natural structure for everything that rotates: TaskFlow's round-robin dispatcher assigns turns without modular arithmetic or end checks, the Josephus problem is solved with the usual bridge, and the double ring gifts us O(1) turn rollback and a removal with no head or tail cases. In exchange, we lost the None that stopped traversals, and we learned the two disciplines that replace it: counting nodes or watching a sentinel with is after advancing. With this, the list family is complete: singly linked, doubly linked, and circular, each with its cost table and its place in TaskFlow. In the next lesson there will be no new theory: it will be pure training — reversing lists, detecting cycles with two pointers, merging boards sorted by priority, moving tasks between positions, and finishing off the navigable history — the exercises that turn what you've learned into craft, several of them absolute classics of the technical interview.
Data Structures Course
Module 1: Introduction to Data Structures
- What Are Data Structures?
- The Importance of Data Structures in Programming
- Types of Data Structures
- Algorithmic Complexity and Big O Notation
- Arrays and Memory: the Foundation of Data Structures
Module 2: Lists
Module 3: Stacks
- Introduction to Stacks
- Basic Stack Operations
- Stack Implementation
- Stack Applications
- Stack Exercises
Module 4: Queues
- Introduction to Queues
- Basic Queue Operations
- Circular Queues
- Priority Queues
- Double-Ended Queues (Deques)
- Queue Exercises
Module 5: Hash Tables and Dictionaries
- Introduction to Hash Tables
- Hash Functions and Collision Resolution
- Dictionaries and Sets in Practice
- Hash Table Exercises
Module 6: Trees
- Introduction to Trees
- Binary Trees
- Tree Traversals
- Binary Search Trees
- AVL Trees
- B-Trees
- Heaps
- Tree Exercises
Module 7: Graphs
- Introduction to Graphs
- Graph Representation
- Graph Search Algorithms
- Shortest Path Algorithms
- Minimum Spanning Trees
- Graph Applications
- Graph Exercises
