Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Structures & Algorithms (DSA)  Module 7 – Linked lists

Doubly linked and circular lists

Doubly linked lists with sentinels remove a node you hold in O(1), and circular lists drive round-robin turns: Python code, library types and Josephus.

  • Beginner
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, Java 25, Clang 21.0.0 and Go 1.27.1
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: Two links per node, and a sentinel at each end · Circular lists and round-robin turns · Which list type to use in each language · Practise: the Josephus circle. With a pass: 3 in-depth parts and 3 interview answers, about 20 minutes.

What you will learn

  • Implement a doubly linked list with sentinel nodes
  • Use circular lists for round-robin processing
  • Choose the standard-library list types appropriately
  • Prove that insertion and removal keep every next and prev link paired

Before you start

A doubly linked list gives every node two references: next to the node after it and prev to the node before it. A circular list joins the ends, so that the last node’s next leads back to the first. The extra reference of a doubly linked list buys what a singly linked list cannot do cheaply: walking backwards, and removing a node you already hold in O(1), because the node knows both of its neighbours. Circular lists suit anything that takes turns, such as round-robin scheduling, because a walk never falls off the end. The price is memory and care: one more pointer per node, four link updates per insertion, and loops over circular lists that need a stopping rule of their own. This lesson builds both kinds in Python, compares the list types the standard libraries ship, and practises the circle on the Josephus problem.

Removing a node from a doubly linked list takes two assignments: the node before it skips forward with node.prev.next = node.next, and the node after it skips back with node.next.prev = node.prev. Inserting takes four, two for the new node’s own links and one for each neighbour. Written plainly, each of these needs checks for an empty reference at the first and the last node. Two sentinel nodes remove those checks: a head sentinel before the first real node and a tail sentinel after the last, created with the list and never removed. Every real node then has a node on both sides, and an empty list is just the two sentinels pointing at each other.

A doubly linked list docs, mail, maps between head and tail sentinels; removing mail links docs and maps directly.head sentineldocsmail (removed)mapstail sentinelnext / prevbeforebeforeafter remove(mail)next / prev

Two sentinels, and remove(node) in two link changes

Text description of the diagram

The list holds three tabs, docs, mail and maps, between two sentinel nodes, the head sentinel and the tail sentinel, which hold no tab and are never removed. Every link goes both ways: each node's next points at the node after it and its prev at the node before it.

The drawing shows remove on the mail node. Before the removal, docs and maps both point at mail; those links are dashed. The removal makes two changes: the next of docs now points at maps, and the prev of maps now points at docs. Mail can no longer be reached in either direction.

Because the sentinels are always there, every real node has a node on both sides, so the same two assignments remove the first tab, the last tab or the only tab, and no step checks for an empty reference.

A doubly linked list of n nodes with two sentinels
Operation Time Extra space Note
push_front(v), push_back(v) O(1) O(1) insert after a sentinel
insert_after(node, v) O(1) O(1) four link updates
remove(node) for a node you hold O(1) O(1) O(n) in a singly linked list
find(v) O(n) O(1) from either end
walk backwards O(n) O(1) follow prev from the tail sentinel

The module below implements the list with sentinels, and the program beside it keeps browser tabs in it. A dict maps each tab’s name to its node, so closing a tab needs no search at all, the idea behind the LRU cache at the end of this module.

Browser tabs in a doubly linked list

dlist/tabs.py

"""Browser tabs in a doubly linked list: a dict hands over the node of any tab, so closing it needs no search."""
from dlist import DoublyLinkedList

bar = DoublyLinkedList()
tab = {}
for name in ["docs", "mail", "maps", "music"]:
    tab[name] = bar.push_back(name)
print("left to right:", " <-> ".join(bar.forwards()))
print("right to left:", " <-> ".join(bar.backwards()))

bar.remove(tab.pop("maps"))
print("close maps:   ", " <-> ".join(bar.forwards()))

tab["news"] = bar.insert_after(tab["docs"], "news")
print("open news after docs:", " <-> ".join(bar.forwards()))

current = tab["news"]
print("from news, the tab on the left is", current.prev.value, "and on the right", current.next.value)
bar.remove(tab.pop("docs"))
print("close docs:   ", " <-> ".join(bar.forwards()), "| backwards:", " <-> ".join(bar.backwards()))
print("tabs open:", bar.size)

Output

left to right: docs <-> mail <-> maps <-> music
right to left: music <-> maps <-> mail <-> docs
close maps:    docs <-> mail <-> music
open news after docs: docs <-> news <-> mail <-> music
from news, the tab on the left is docs and on the right mail
close docs:    news <-> mail <-> music | backwards: music <-> mail <-> news
tabs open: 3

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tabs.py

dlist/dlist.py

"""A doubly linked list with two sentinel nodes (lesson dsa/doubly-and-circular-lists)."""


class Node:
    __slots__ = ("value", "prev", "next")

    def __init__(self, value=None):
        self.value = value
        self.prev = None
        self.next = None


class DoublyLinkedList:
    def __init__(self):
        # Two sentinels that are never removed, so every real node always has a node on both sides.
        self.head = Node()
        self.tail = Node()
        self.head.next = self.tail
        self.tail.prev = self.head
        self.size = 0

    def insert_after(self, node, value):
        """O(1): link a new node between node and node.next, and return it."""
        new = Node(value)
        new.prev = node
        new.next = node.next
        node.next.prev = new
        node.next = new
        self.size += 1
        return new

    def push_front(self, value):
        return self.insert_after(self.head, value)

    def push_back(self, value):
        return self.insert_after(self.tail.prev, value)

    def remove(self, node):
        """O(1): unlink a node you hold; its two neighbours now point at each other."""
        node.prev.next = node.next
        node.next.prev = node.prev
        node.prev = node.next = None
        self.size -= 1
        return node.value

    def forwards(self):
        node = self.head.next
        while node is not self.tail:
            yield node.value
            node = node.next

    def backwards(self):
        node = self.tail.prev
        while node is not self.head:
            yield node.value
            node = node.prev

Closing “maps” and then “docs” took constant time each, whatever the number of tabs, and the walk from the right shows that the prev links were kept as carefully as the next links. The memory price is one more reference per node, 8 bytes on a 64-bit computer, before the value itself.

Circular lists and round-robin turns

In a circular singly linked list one reference is enough: keep tail, the last node, and tail.next is the first, so both ends are one step away. Giving the next job its turn is one pointer move, tail = tail.next, and removing the job after tail is one link change. A walk stops when it comes back to its starting node, because no next is ever empty. The scheduler below gives each job at most 3 units of time per turn and drops a job from the circle once it finishes.

Round-robin turns over a circular list Python · circular/round_robin.py
"""Round-robin turns over a circular singly linked list: each job runs for at most one time slice, a finished job
leaves the circle, and the next turn is always tail.next."""


class Job:
    __slots__ = ("name", "left", "next")

    def __init__(self, name, left):
        self.name = name
        self.left = left
        self.next = None


def run_round_robin(jobs, time_slice):
    tail = None  # the job that ran last; tail.next is the job whose turn is next
    for name, work in jobs:
        job = Job(name, work)
        if tail is None:
            job.next = job  # a circle of one points at itself
        else:
            job.next = tail.next
            tail.next = job
        tail = job

    clock, finished = 0, []
    while tail is not None:
        job = tail.next
        ran = min(time_slice, job.left)
        clock += ran
        job.left -= ran
        if job.left > 0:
            print(f"t={clock:2}  {job.name} ran {ran}, {job.left} left")
            tail = job  # rotate: one pointer moves, nothing is copied
        else:
            print(f"t={clock:2}  {job.name} ran {ran}, finished")
            finished.append(job.name)
            if job is tail:
                tail = None  # it was the last job in the circle
            else:
                tail.next = job.next  # unlink the finished job
    return finished


order = run_round_robin([("A", 5), ("B", 2), ("C", 7), ("D", 3)], time_slice=3)
print("finishing order:", order)

Output

t= 3  A ran 3, 2 left
t= 5  B ran 2, finished
t= 8  C ran 3, 4 left
t=11  D ran 3, finished
t=13  A ran 2, finished
t=16  C ran 3, 1 left
t=17  C ran 1, finished
finishing order: ['B', 'D', 'A', 'C']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 round_robin.py

Trace it once by hand. After B and D finish, only A and C are left, and A, which arrived first, gets its turn first. A circle of one job points at itself, which is why the code checks whether the finished job is tail before it unlinks anything. Counting round a circle and removing every k-th member is the Josephus problem, which the exercise below asks you to simulate.

Which list type to use in each language

Linked list types in four standard libraries
Criterion PythonJavaC++Go
Doubly linked list None in the libraryLinkedListstd::listcontainer/list
Singly linked list None in the libraryNone in the librarystd::forward_listNone in the library
Circular list None in the libraryNone in the libraryNone in the librarycontainer/ring
Queue or stack collections.dequeArrayDequestd::dequeA slice
When to choose deque for both ends; write nodes when you must hold positionsArrayDeque unless you remove through an iteratorstd::list when iterators must stay validcontainer/list or container/ring for held positions

Java’s LinkedList is documented as a doubly linked list, yet the ArrayDeque documentation says that it is likely to be faster than LinkedList when used as a queue. Python ships no general linked list: collections.deque appends and pops at either end in O(1), and its documentation warns that indexing slows to O(n) in the middle. C++ has std::list, doubly linked, and std::forward_list, singly linked. Go’s container/list is a doubly linked list, and container/ring implements circular lists directly. The rule of thumb follows: use an array-based deque for queues and stacks, and a real linked list only when you insert or remove at positions you already hold.

Practise: the Josephus circle

Simulate the circle with a circular list and return the order in which people leave. The sample tests compare your answer with a slow model on every circle of up to 40 people and every count from 1 to 12.

Exercise · Easy · Python, JavaScript

Print the order in which a circle of people leaves

People numbered 1 to n stand in a circle. Counting starts at person 1 and goes round the circle; every k-th person counted leaves, and counting goes on from the next person still in the circle, until nobody is left. Write elimination_order(n, k) (in JavaScript eliminationOrder(n, k)), which returns the numbers in the order the people leave. The last number is the survivor.

  • elimination_order(7, 3) is [3, 6, 2, 7, 5, 1, 4]: 3 leaves first, then counting starts again at 4.
  • elimination_order(3, 5) is [2, 3, 1]: a count longer than the circle simply goes round again.
  • elimination_order(1, 4) is [1], and elimination_order(0, 2) is [].

Use a circular linked list: keep a reference to the person before the one being counted, so that removing someone is one link change. The sample tests compare your answer with a slow model on every circle of up to 40 people.

Python · Starter code · josephus.py

def elimination_order(n, k):
    """The numbers 1..n in the order people leave the circle when every k-th person counted leaves (k >= 1)."""
    return []
The sample tests · test_josephus.py
from josephus import elimination_order


def model(n, k):
    """Plainly right and slow: the circle as a Python list."""
    people, i, out = list(range(1, n + 1)), 0, []
    while people:
        i = (i + k - 1) % len(people)
        out.append(people.pop(i))
    return out


def test_seven_people_every_third():
    """the example: n = 7, k = 3"""
    assert elimination_order(7, 3) == [3, 6, 2, 7, 5, 1, 4]


def test_count_longer_than_the_circle():
    """a count longer than the circle goes round again"""
    assert elimination_order(3, 5) == [2, 3, 1]


def test_every_person_in_turn():
    """with k = 1 people leave in their own order"""
    assert elimination_order(6, 1) == [1, 2, 3, 4, 5, 6]


def test_one_person_and_nobody():
    """a circle of one, and an empty circle"""
    assert elimination_order(1, 4) == [1]
    assert elimination_order(0, 2) == []


def test_every_pair_against_the_model():
    """every n from 1 to 40 and every k from 1 to 12 agree with the model"""
    for n in range(1, 41):
        for k in range(1, 13):
            assert elimination_order(n, k) == model(n, k), (n, k)


def test_the_result_is_a_permutation():
    """everyone leaves exactly once"""
    assert sorted(elimination_order(97, 7)) == list(range(1, 98))

JavaScript · Starter code · josephus.mjs

/** The numbers 1..n in the order people leave the circle when every k-th person counted leaves (k >= 1). */
export function eliminationOrder(n, k) {
  return [];
}
The sample tests · josephus.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { eliminationOrder } from './josephus.mjs';

/** Plainly right and slow: the circle as an array. */
function model(n, k) {
  const people = Array.from({ length: n }, (_, i) => i + 1);
  const out = [];
  let i = 0;
  while (people.length) {
    i = (i + k - 1) % people.length;
    out.push(people.splice(i, 1)[0]);
  }
  return out;
}

test('the example: n = 7, k = 3', () => {
  assert.deepEqual(eliminationOrder(7, 3), [3, 6, 2, 7, 5, 1, 4]);
});

test('a count longer than the circle goes round again', () => {
  assert.deepEqual(eliminationOrder(3, 5), [2, 3, 1]);
});

test('with k = 1 people leave in their own order', () => {
  assert.deepEqual(eliminationOrder(6, 1), [1, 2, 3, 4, 5, 6]);
});

test('a circle of one, and an empty circle', () => {
  assert.deepEqual(eliminationOrder(1, 4), [1]);
  assert.deepEqual(eliminationOrder(0, 2), []);
});

test('every n from 1 to 40 and every k from 1 to 12 agree with the model', () => {
  for (let n = 1; n <= 40; n++) {
    for (let k = 1; k <= 12; k++) assert.deepEqual(eliminationOrder(n, k), model(n, k), `n = ${n}, k = ${k}`);
  }
});

test('everyone leaves exactly once', () => {
  const order = eliminationOrder(97, 7);
  assert.deepEqual([...order].sort((a, b) => a - b), Array.from({ length: 97 }, (_, i) => i + 1));
});
A hint

Build the circle as nodes whose next points at the next person and whose last node points back at person 1, and keep prev on person n, the node before person 1. To remove the k-th person, move prev forward k - 1 times; the node after prev is the one who leaves, and prev.next = prev.next.next closes the circle behind them.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 You hold a node in the middle of a doubly linked list of a million nodes. What does removing it cost?

    Choose one answer.

    Show the answer to question 1

    Answer: O(1), because the node itself knows both neighbours

    The node's prev and next references lead straight to its neighbours, and removing it is two link changes: the neighbour before it skips forward, the neighbour after it skips back. In a singly linked list the node before it can only be found by a walk from the head.

  2. Question 3 of 5 A loop walks a circular singly linked list from start. When should it stop?

    Choose one answer.

    Show the answer to question 3

    Answer: When the next node to visit is start again

    No next reference in a circular list is ever None, so a test for None never ends the loop. The walk has gone all the way round when it is about to visit its starting node again. Comparing values fails as soon as two nodes hold the same value.

  3. Question 4 of 5 What does round_robin.py print?

    What does this program print? Choose one answer.

    """Round-robin turns over a circular singly linked list: each job runs for at most one time slice, a finished job
    leaves the circle, and the next turn is always tail.next."""
    
    
    class Job:
        __slots__ = ("name", "left", "next")
    
        def __init__(self, name, left):
            self.name = name
            self.left = left
            self.next = None
    
    
    def run_round_robin(jobs, time_slice):
        tail = None  # the job that ran last; tail.next is the job whose turn is next
        for name, work in jobs:
            job = Job(name, work)
            if tail is None:
                job.next = job  # a circle of one points at itself
            else:
                job.next = tail.next
                tail.next = job
            tail = job
    
        clock, finished = 0, []
        while tail is not None:
            job = tail.next
            ran = min(time_slice, job.left)
            clock += ran
            job.left -= ran
            if job.left > 0:
                print(f"t={clock:2}  {job.name} ran {ran}, {job.left} left")
                tail = job  # rotate: one pointer moves, nothing is copied
            else:
                print(f"t={clock:2}  {job.name} ran {ran}, finished")
                finished.append(job.name)
                if job is tail:
                    tail = None  # it was the last job in the circle
                else:
                    tail.next = job.next  # unlink the finished job
        return finished
    
    
    order = run_round_robin([("A", 5), ("B", 2), ("C", 7), ("D", 3)], time_slice=3)
    print("finishing order:", order)
    Show the answer to question 4

    Answer: it prints

    t= 3  A ran 3, 2 left
    t= 5  B ran 2, finished
    t= 8  C ran 3, 4 left
    t=11  D ran 3, finished
    t=13  A ran 2, finished
    t=16  C ran 3, 1 left
    t=17  C ran 1, finished
    finishing order: ['B', 'D', 'A', 'C']

    After D finishes at t = 11 the circle holds A and C, and A's turn comes first because the circle keeps the order the jobs arrived in. A needs only 2 more units and finishes at 13; C then runs 3 and 1 units and finishes at 17.

  4. Question 5 of 5 A Java program needs a plain first-in, first-out queue with no removal from the middle. What does the Java documentation suggest?

    Choose one answer.

    Show the answer to question 5

    Answer: ArrayDeque, which it describes as likely to be faster than LinkedList when used as a queue

    The ArrayDeque documentation says it is likely to be faster than LinkedList when used as a queue. LinkedList earns its place when code removes or inserts at positions it already holds, through a ListIterator.

Every change to a doubly linked list rewrites up to four links, and a slip in one of them leaves a list that reads correctly in one direction only. This part states the invariant every operation must keep, proves that insertion and removal keep it, reproduces the classic one-link slip, and runs the same list in JavaScript, Java, C++ and Go.

  • The invariant: every link has a partner pointing back
  • Why insertion and removal keep it: the proof
  • A remove that forgets one link, reproduced
  • The same list in JavaScript, Java, C++ and Go

4 runnable examples (C++, Go, Java, JavaScript, dlist.py, dlist_session.py, one_link_short.py, session.py) · about 3 minutes

Opening this part with your pass…

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Circular lists at scale: the Josephus count, checked and measured Pro: opens with a Pro, Premium or Ultimate pass

A circle that loses one member at a time is the Josephus problem, and it shows both when a circular list is the right tool and when arithmetic beats it. This part derives the recurrence for the survivor, checks it against the simulation for twenty circles, and times the circular list against a Python list with pop(i).

In this part:

  • The survivor without a simulation: the recurrence and its proof
  • Twenty circles, simulated and checked
  • Measured: the circle against list.pop(i)

2 runnable examples (Python) · about 2 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Library lists in practice: iterators, rings and queues, measured Pro: opens with a Pro, Premium or Ultimate pass

The library types save you from writing nodes, but each comes with rules about which references survive a change and with a point where an array-based structure beats it. Each rule is run here in C++, Go or Java, and the part closes with the boundary cases worth a test and a handful of questions with their answers.

In this part:

  • Which references survive a change: std::list against std::vector
  • Go’s container/ring for turns
  • Java’s LinkedList against ArrayDeque as a queue, measured
  • Edge cases to test before you trust a list
  • A practice set with answers

4 runnable examples (C++, Go, Java, library_session.py, queue_session.py, session.py) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher · Warm-up

    What does a circular linked list give you that a list with head and tail references does not?

    Mostly convenience for code that goes round. In a circular singly linked list one reference to the last node reaches both ends, because its next is the first node, so adding at either end and passing the turn to the next node are O(1), and a rotation is a single pointer move with nothing copied. There is no end to fall off, which suits round-robin turns and any walk that wraps around. The costs are traps rather than time: a loop cannot stop at an empty reference and must stop when it returns to its start, a list of one node points at itself, and the empty list needs a case of its own. A list with head and tail references can do the same jobs with one more reference to keep right.

  2. Fresher to mid

    After a deletion, your doubly linked list prints correctly from front to back, but walking from the back still shows the deleted item. What went wrong, how do you find every bug of this kind before release, and how do sentinel nodes change the code?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    A Java service buffers 20 million small integers in a LinkedList<Integer> used as a first-in, first-out queue, and the heap is far larger than the data suggests. Estimate the memory per element, compare ArrayDeque<Integer> and a ring buffer of int, and choose one.

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    Design the run queue of a round-robin scheduler: jobs arrive at any time, finish at any time, and the next job must get its turn in O(1). Which list do you use, where does a new job go, and what goes wrong when a job finishes while it holds the turn?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A doubly linked list stores prev and next in every node, so it walks both ways and removes a node you hold in O(1), at the cost of one more pointer per node and four link updates per insertion.
  • Two sentinels give every real node a neighbour on both sides, so the same few assignments work at every position.
  • Circular lists make turn-taking a single pointer move; every loop over one stops when it returns to its start.
  • Standard libraries ship these lists, but an array-based deque is the better queue in Java and Python.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.