// STUHUB · C++ DATA STRUCTURES

Doubly and Circular Linked Lists in C++: Pointer-Write Order and Termination

The two linked-list variants that break code the singly linked list never does. Why a doubly linked insert is four pointer writes whose order is not negotiable, why deletion needs a separate null check at each end, and why a circular list traversed with while (cur != nullptr) hangs forever while do { } while (cur != head) works. Includes a compiled forward-and-backward walk, a full circular lap, the O(1) tail-pointer trick, a three-way comparison table, and three worked exercises.

Introduction

A singly linked list has one pointer per node and one way to be wrong: you overwrite the only reference to the rest of the chain. Doubly and circular lists each add exactly one new idea to that picture, and each new idea comes with a failure mode that a singly linked list simply cannot produce.

The doubly linked list's new idea is a second pointer running backwards. That gives you reverse traversal and O(1) deletion of a node you already hold — but it doubles the number of links an insertion has to fix, from two to four, and those four writes have an order that is not a matter of taste. Get it wrong and the program compiles, runs, and prints the same element forever.

The circular list's new idea is that the last node's link points back at the first instead of being null. That is a one-line change to the data and a total change to every loop you write over it: there is no null to stop at, so while (cur != nullptr) never terminates. The replacement is do { ... } while (cur != head), and the reason it has to be a do-while rather than a while is the single most common way people get an empty output instead of an infinite loop.

Every listing below was compiled with g++ -std=c++17 -Wall -Wextra and run under AddressSanitizer, and the outputs quoted in the prose are the outputs those programs actually produce.

Two pointers per node, and what the second one buys

A doubly linked node carries its payload and two links: next, to the node in front, and prev, to the node behind. The head's prev is null and the tail's next is null; everything in between has both.

That one extra field changes four things:

  • Reverse traversal becomes possible at all. Start at tail and follow prev. On a singly linked list the only way to print in reverse is recursion (which costs O(n) stack) or reversing the list first (which mutates it).
  • Deleting a node you already have a pointer to becomes O(1). The expensive part of singly linked deletion is not the unlinking — it is finding the predecessor, which means walking from the head. With prev the predecessor is one field access away.
  • Deleting the last node becomes O(1). This is the one people get backwards. Adding a tail pointer to a singly linked list does not make deleteLast() fast: you still have to walk the list to find the second-to-last node, because you cannot get from the tail to its predecessor. A doubly linked list with a tail pointer can do it in constant time. Exercise 3 measures the difference.
  • Positional access gets roughly twice as fast on average. With both head and tail, an operation at index i can start from whichever end is closer: if (i <= size / 2) walk forward from the head, otherwise walk backward from the tail. Still O(n) — you have halved the constant, not the complexity.

What you pay is one pointer per node — 8 bytes on a 64-bit build, which on a list of int is more memory than the data itself — plus the maintenance cost of keeping two chains consistent instead of one. That second cost is the real one, and it is what the next two sections are about.

Where doubly linked lists show up in practice: browser back/forward history, undo/redo stacks, LRU caches (where you need to yank an arbitrary node out of the middle in O(1) and move it to the front), and music or video playlists with next/previous controls. std::list is a doubly linked list; std::forward_list is the singly linked one.

cpp
// The node. Two links instead of one -- that is the whole difference.
struct DNode {
    int    info;
    DNode* prev;   // the node behind us; nullptr if we are the head
    DNode* next;   // the node in front; nullptr if we are the tail
};

// The list keeps both ends, which is what makes O(1) tail deletion possible.
class DoublyLinkedList {
    DNode* head_ = nullptr;
    DNode* tail_ = nullptr;
    int    size_ = 0;
    // Invariant: head_ and tail_ are both null (empty list) or both non-null.
    //            head_->prev == nullptr and tail_->next == nullptr, always.
    //            For every node p with p->next != nullptr: p->next->prev == p.
};

Insertion: four pointer writes, and only one safe order

Inserting a node N between P and S requires four links to end up correct:

  1. N->next = S
  2. N->prev = P
  3. S->prev = N
  4. P->next = N

Three of those four can be reordered freely. One cannot: P->next = N must be last, or at least must come after you have read P->next into N->next.

The reason is that P->next is the only pointer that tells you where S is. The moment you overwrite it, S — and the entire rest of the list behind it — is unreachable. If your next statement is the very natural-looking N->next = P->next;, you now read back the value you just wrote, so N->next becomes N. The node points at itself.

That self-loop is not a crash. It is a list that traverses forever, printing the same element, while the rest of the chain leaks. The program below builds both versions side by side and walks each with a hard step limit so the broken one can still exit. Its output is:

wrong order: 1 2 2 2 2 2 2 2 ... (still going)
right order: 1 2 3

The rule that generalises out of this — and it covers head insertion, tail insertion, middle insertion, singly linked, doubly linked and circular alike — is: wire up the new node's outgoing links first, then redirect the existing nodes' links into it. The new node is not yet reachable from anywhere, so writing to it can't destroy anything. The existing nodes are load-bearing, so write to them last.

One more thing the listing does not show, because it keeps the example small: if P is the tail then S is null, and step 3 would dereference a null pointer. Every real insertAfter needs if (P->next != nullptr) P->next->prev = N; else tail_ = N; — the same shape of check that dominates the next section.

cpp
#include <iostream>

struct DNode { int info; DNode* prev; DNode* next; };

// WRONG: the predecessor's next is overwritten before the new node has read it.
void insertAfterWrong(DNode* prevNode, int value) {
    DNode* newNode = new DNode{value, nullptr, nullptr};
    prevNode->next = newNode;         // 1. the successor is now unreachable
    newNode->next  = prevNode->next;  // 2. reads back the value we just wrote
    newNode->prev  = prevNode;
}

// RIGHT: read the old successor first, publish the new node last.
void insertAfterRight(DNode* prevNode, int value) {
    DNode* newNode = new DNode{value, nullptr, nullptr};
    newNode->next = prevNode->next;
    newNode->prev = prevNode;
    if (prevNode->next != nullptr) prevNode->next->prev = newNode;
    prevNode->next = newNode;
}

// Walk at most `limit` nodes so a broken list cannot hang the program.
void walk(const char* label, DNode* head, int limit) {
    std::cout << label;
    DNode* p = head;
    for (int i = 0; p != nullptr && i < limit; ++i) {
        std::cout << " " << p->info;
        p = p->next;
    }
    if (p != nullptr) std::cout << " ... (still going)";
    std::cout << "\n";
}

DNode* buildTwo() {
    DNode* a = new DNode{1, nullptr, nullptr};
    DNode* b = new DNode{3, a, nullptr};
    a->next = b;
    return a;
}

void destroy(DNode* head, int limit) {
    DNode* p = head;
    for (int i = 0; p != nullptr && i < limit; ++i) {
        DNode* next = p->next;
        if (next == p) next = nullptr;   // self-loop: stop after this node
        delete p;
        p = next;
    }
}

int main() {
    DNode* bad = buildTwo();
    insertAfterWrong(bad, 2);
    walk("wrong order:", bad, 8);

    DNode* good = buildTwo();
    insertAfterRight(good, 2);
    walk("right order:", good, 8);

    destroy(bad, 8);
    destroy(good, 8);
}

Deletion: two writes, and the two ends with no neighbour

Removing a node C from the middle is symmetric and short — two writes that stitch C's neighbours to each other:

  • C->prev->next = C->next
  • C->next->prev = C->prev

Then delete C. No traversal, no predecessor hunt: O(1), and that is the headline advantage of the structure.

The complication is that a node at either end does not have both neighbours. If C is the head, C->prev is null and the first write would crash; instead the list's head pointer is the thing that has to move. If C is the tail, C->next is null and the second write would crash; the list's tail pointer moves instead. These are two independent conditions, so they need two separate if/else pairs rather than one combined branch — and a single-node list satisfies both at once, which is exactly the case that catches code written with else if.

Write it as two mirrored blocks and the single-node case falls out for free: the first block sets head = nullptr (since C->next is null) and the second sets tail = nullptr (since C->prev is null), leaving a correctly empty list.

The program below runs all four cases — middle, head, tail, only-node — and prints each result walked in both directions, which is the check that matters: a deletion that fixes next but forgets prev looks perfect forwards and is corrupt backwards.

erase middle   forward: 1 3 | backward: 3 1
erase head     forward: 2 3 | backward: 3 2
erase tail     forward: 1 2 | backward: 2 1
erase only     forward: | backward:
head is null? 1  tail is null? 1

Note that erase takes head and tail by reference (DNode*&). Passing a bare DNode* gives the function a copy of the pointer, so reassigning it inside changes nothing at the call site and the caller's head is left pointing at freed memory. This is a distinct bug from forgetting the null check, and it produces a use-after-free rather than a null dereference — which means it often appears to work.

cpp
#include <initializer_list>
#include <iostream>

struct DNode { int info; DNode* prev; DNode* next; };

// Unlink and destroy curr. Both ends need their own null check, because a
// node at either end has no neighbour to write into.
void erase(DNode*& head, DNode*& tail, DNode* curr) {
    if (curr == nullptr) return;

    if (curr->prev != nullptr) curr->prev->next = curr->next;  // write 1
    else head = curr->next;                                    // curr was the head

    if (curr->next != nullptr) curr->next->prev = curr->prev;  // write 2
    else tail = curr->prev;                                    // curr was the tail

    delete curr;
}

DNode* build(DNode*& head, DNode*& tail, std::initializer_list<int> values) {
    head = tail = nullptr;
    for (int v : values) {
        DNode* n = new DNode{v, tail, nullptr};
        if (tail) tail->next = n; else head = n;
        tail = n;
    }
    return head;
}

void show(const char* label, DNode* head, DNode* tail) {
    std::cout << label << " forward:";
    for (DNode* p = head; p; p = p->next) std::cout << " " << p->info;
    std::cout << " | backward:";
    for (DNode* p = tail; p; p = p->prev) std::cout << " " << p->info;
    std::cout << "\n";
}

int main() {
    DNode* head = nullptr;
    DNode* tail = nullptr;

    build(head, tail, {1, 2, 3});
    erase(head, tail, head->next);          // middle: both writes run
    show("erase middle  ", head, tail);
    while (head) { DNode* n = head->next; delete head; head = n; }

    build(head, tail, {1, 2, 3});
    erase(head, tail, head);                // head: prev is null, head moves
    show("erase head    ", head, tail);
    while (head) { DNode* n = head->next; delete head; head = n; }

    build(head, tail, {1, 2, 3});
    erase(head, tail, tail);                // tail: next is null, tail moves
    show("erase tail    ", head, tail);
    while (head) { DNode* n = head->next; delete head; head = n; }

    build(head, tail, {1});
    erase(head, tail, head);                // only node: both branches fire
    show("erase only    ", head, tail);
    std::cout << "head is null? " << (head == nullptr)
              << "  tail is null? " << (tail == nullptr) << "\n";
}

A doubly linked list you can run

Putting insertion, deletion and both traversals into one class. A few things worth noticing as you read it:

  • insertAtBeginning and insertAtEnd are exact mirrors. Swap every next for prev and every head_ for tail_ and you get the other one. If your two functions are not mirror images, one of them is wrong.
  • Both of them branch on the empty list, because the new node then has to become both ends. if (head_ == nullptr) tail_ = newNode; is the whole of it — and the matching else is what keeps the backward chain intact.
  • erase is O(1), but find is O(n). Deleting by value on a doubly linked list is still linear, because you have to locate the node first. The constant-time claim is about deleting a node you were handed — which is precisely the situation an LRU cache or an intrusive list arranges for itself.
  • The destructor, and the deleted copy operations. Owning raw newed nodes means the compiler's default copy would duplicate two pointers and give you two objects sharing one chain, with two destructors racing to free it. Deleting the copy operations is the honest minimum; writing them properly is the alternative.

The program's output:

forward : 8 17 92 45
backward: 45 92 17 8
after inserting 63 behind 92
forward : 8 17 92 63 45
backward: 45 63 92 17 8
after erasing both ends
forward : 17 92 63
backward: 63 92 17
size    : 3

The backward line being the exact reverse of the forward line, after every operation, is the invariant to test. Print both after each mutation and pointer bugs surface immediately instead of three operations later.

cpp
#include <iostream>

struct DNode {
    int info;
    DNode* prev;
    DNode* next;
};

class DoublyLinkedList {
public:
    DoublyLinkedList() = default;
    ~DoublyLinkedList() { clear(); }
    DoublyLinkedList(const DoublyLinkedList&) = delete;
    DoublyLinkedList& operator=(const DoublyLinkedList&) = delete;

    // Insert at the front. Four writes, and the two on newNode come first.
    void insertAtBeginning(int value) {
        DNode* newNode = new DNode{value, nullptr, head_};  // info, prev, next
        if (head_ == nullptr) tail_ = newNode;              // list was empty
        else head_->prev = newNode;                         // old head looks back
        head_ = newNode;
        ++size_;
    }

    // Insert at the back. The mirror image of the above.
    void insertAtEnd(int value) {
        DNode* newNode = new DNode{value, tail_, nullptr};
        if (tail_ == nullptr) head_ = newNode;              // list was empty
        else tail_->next = newNode;                         // old tail looks forward
        tail_ = newNode;
        ++size_;
    }

    // Insert directly after an existing node. This is the four-write case.
    void insertAfter(DNode* prevNode, int value) {
        if (prevNode == nullptr) return;
        DNode* newNode = new DNode{value, nullptr, nullptr};
        newNode->next = prevNode->next;   // 1. new node's forward link
        newNode->prev = prevNode;         // 2. new node's backward link
        if (prevNode->next != nullptr)
            prevNode->next->prev = newNode;  // 3. successor looks back at us
        else
            tail_ = newNode;                 // inserted after the last node
        prevNode->next = newNode;         // 4. LAST: predecessor looks forward
        ++size_;
    }

    // Unlink and destroy a node we already hold a pointer to. O(1).
    void erase(DNode* curr) {
        if (curr == nullptr) return;
        if (curr->prev != nullptr) curr->prev->next = curr->next;
        else head_ = curr->next;          // curr was the head
        if (curr->next != nullptr) curr->next->prev = curr->prev;
        else tail_ = curr->prev;          // curr was the tail
        delete curr;
        --size_;
    }

    DNode* find(int value) const {
        for (DNode* p = head_; p != nullptr; p = p->next)
            if (p->info == value) return p;
        return nullptr;
    }

    void printForward() const {
        for (DNode* p = head_; p != nullptr; p = p->next) std::cout << p->info << " ";
        std::cout << "\n";
    }

    void printBackward() const {
        for (DNode* p = tail_; p != nullptr; p = p->prev) std::cout << p->info << " ";
        std::cout << "\n";
    }

    void clear() {
        while (head_ != nullptr) {
            DNode* next = head_->next;
            delete head_;
            head_ = next;
        }
        tail_ = nullptr;
        size_ = 0;
    }

    int size() const { return size_; }

private:
    DNode* head_ = nullptr;
    DNode* tail_ = nullptr;
    int size_ = 0;
};

int main() {
    DoublyLinkedList list;
    list.insertAtEnd(17);
    list.insertAtEnd(92);
    list.insertAtEnd(45);
    list.insertAtBeginning(8);

    std::cout << "forward : "; list.printForward();
    std::cout << "backward: "; list.printBackward();

    list.insertAfter(list.find(92), 63);
    std::cout << "after inserting 63 behind 92\n";
    std::cout << "forward : "; list.printForward();
    std::cout << "backward: "; list.printBackward();

    list.erase(list.find(8));    // the head
    list.erase(list.find(45));   // the tail
    std::cout << "after erasing both ends\n";
    std::cout << "forward : "; list.printForward();
    std::cout << "backward: "; list.printBackward();
    std::cout << "size    : " << list.size() << "\n";
}

Circular lists: there is no null to stop at

A list is made circular by pointing the last node's link back at the first. The node definition does not change at all — struct Node { int info; Node* link; }; is the same struct you already have. What changes is that no link in the list is ever null, and every loop you have ever written over a linked list depended on one being null.

So while (current != nullptr) does not terminate. It is not slow, it is not subtly off by one: it walks the ring forever. This is the single most common bug in the topic, and it is worth knowing cold.

The replacement is to stop when you arrive back where you started:

cpp
do {
    cout << current->info << " ";
    current = current->link;
} while (current != head);

And it has to be a do-while, not a while. At the moment you enter the loop current == head is already true, so a while (current != head) test fails on the first evaluation and the body never executes — you get an empty line rather than a hang. Both failures come from the same misunderstanding, and they look nothing alike from the outside, which is why people fix one and then hit the other.

The do-while shape has one precondition of its own: the body runs before the test, so it dereferences current unconditionally. An empty circular list has no head to start from, so the null check must sit outside the loop — if (head == nullptr) return; on the first line, always.

Running all three versions against the same 3-node ring:

do-while       : 17 92 63
while(!=head)  : (printed nothing)
while(!=nullptr): 17 92 63 17 92 63 17 92 63 17 ... never reaches nullptr

The third line is bounded by an artificial step budget purely so this program can finish. Without it, that loop runs until you kill the process.

cpp
#include <iostream>

struct Node { int info; Node* link; };

// Correct: enter the loop once unconditionally, stop on returning to head.
void printCircular(Node* head) {
    if (head == nullptr) return;
    Node* current = head;
    do {
        std::cout << current->info << " ";
        current = current->link;
    } while (current != head);
    std::cout << "\n";
}

// Wrong #1: the guard is already false on entry, so the body never runs.
void printWhileHead(Node* head) {
    Node* current = head;
    while (current != head) {
        std::cout << current->info << " ";
        current = current->link;
    }
    std::cout << "(printed nothing)\n";
}

// Wrong #2: no link is ever null, so this never terminates. Bounded here so
// the program still exits; in your own code it is an infinite loop.
void printWhileNull(Node* head, int budget) {
    Node* current = head;
    int steps = 0;
    while (current != nullptr && steps < budget) {
        std::cout << current->info << " ";
        current = current->link;
        ++steps;
    }
    std::cout << (current != nullptr ? "... never reaches nullptr\n" : "\n");
}

int main() {
    Node* a = new Node{17, nullptr};
    Node* b = new Node{92, nullptr};
    Node* c = new Node{63, nullptr};
    a->link = b; b->link = c; c->link = a;   // close the loop

    std::cout << "do-while       : "; printCircular(a);
    std::cout << "while(!=head)  : "; printWhileHead(a);
    std::cout << "while(!=nullptr): "; printWhileNull(a, 10);

    delete a; delete b; delete c;
}

Why the tail pointer is the one to keep

Here is the reason circular lists exist rather than being a curiosity.

On an ordinary list you keep head, and if you want cheap appends you keep tail as well — two pointers of bookkeeping, both of which have to be maintained correctly through every insert and delete. On a circular list, tail->link is the head. So a single tail pointer gives you O(1) access to both ends:

  • the back of the list is tail
  • the front of the list is tail->link

One field, both ends, and no possibility of the two falling out of sync with each other because there is only one of them.

Why the tail and not the head. From a lone head pointer on a circular list you would have to walk the entire ring to reach the last node before you could append — O(n). From a lone tail you reach the head in a single hop. The general principle: keep the pointer to the end you cannot cheaply reach from the other one.

Two consequences fall straight out:

  • Appending and prepending are the same function. Insert after the tail; the new node is the last element. Insert after the tail and then move tail forward one step; the same node is now the first element. Written the other way round — as in the listing in the next section — insertAtEnd is literally insertAtBeginning followed by tail_ = tail_->link;.
  • Rotation is free. tail_ = tail_->link; moves the current front element to the back in constant time, with no allocation, no copying, and no pointer rewiring at all. That single line is why round-robin schedulers use this layout: they cycle through clients forever rather than draining a queue, and this gives them "next client, put the current one at the end of the rotation" for the price of one assignment.

Other places the structure earns its keep: the squares on a Monopoly board, players taking turns in a game, a media player in repeat-all mode, and the ring buffers that sit underneath circular queues. Anywhere the data has no natural last element, a structure with no natural last element is the honest representation.

A circular list with a single tail pointer

The full implementation. The invariant is written at the top of the class and it is worth reading before the code: tail_ is null, or every link in the list is non-null and following them from anywhere eventually returns you to where you started.

Three moments carry the risk:

  • Inserting into an empty list. The new node must point at itself: newNode->link = newNode. This looks strange and is exactly right — a one-element ring. Set it up any other way (leaving link null, say) and every subsequent operation is dealing with a list that is not actually circular.
  • Inserting into a non-empty list. newNode->link = tail_->link; before tail_->link = newNode; — new node's outgoing link first, exactly the rule from the insertion section. Reverse the two and the new node points at itself while the rest of the ring is orphaned.
  • Removing the last remaining node. if (head == tail_) tail_ = nullptr; before the delete. Skip it and tail_ points into freed memory; the list still looks fine until the next operation dereferences it. This is the circular-list version of the dangling rear pointer in a linked queue.

Output:

one full lap : 17 92 63 45
head=17 tail=45 size=4
tail wraps to: 17
after rotate : 92 63 45 17
removed 92, now : 63 45 17
after clear  : (empty)

tail wraps to: 17 is the structural claim of the whole section, printed: the last node's link really does land on the first. And after rotate shows the whole sequence shifted by one with no node having been allocated, freed, or moved.

cpp
#include <iostream>

struct Node { int info; Node* link; };

// A circular singly linked list holding only a tail pointer.
// Invariant: tail_ == nullptr (empty), or tail_->link is the head and every
//            link eventually returns to tail_. No link is ever nullptr.
class CircularList {
public:
    CircularList() = default;
    ~CircularList() { clear(); }
    CircularList(const CircularList&) = delete;
    CircularList& operator=(const CircularList&) = delete;

    bool empty() const { return tail_ == nullptr; }
    Node* head() const { return tail_ ? tail_->link : nullptr; }
    Node* tail() const { return tail_; }
    int size() const { return size_; }

    void insertAtBeginning(int value) {
        Node* newNode = new Node{value, nullptr};
        if (tail_ == nullptr) {
            newNode->link = newNode;     // one-node cycle points at itself
            tail_ = newNode;
        } else {
            newNode->link = tail_->link; // new node takes over as head
            tail_->link = newNode;       // tail closes the loop onto it
        }
        ++size_;
    }

    void insertAtEnd(int value) {
        insertAtBeginning(value);
        tail_ = tail_->link;             // the new head becomes the new tail
    }

    // Remove the head. O(1) even though we only store the tail.
    bool removeFirst(int& out) {
        if (tail_ == nullptr) return false;
        Node* head = tail_->link;
        out = head->info;
        if (head == tail_) tail_ = nullptr;   // it was the only node
        else tail_->link = head->link;        // splice the head out of the loop
        delete head;
        --size_;
        return true;
    }

    // Move the current head to the back without allocating anything.
    void rotate() { if (tail_ != nullptr) tail_ = tail_->link; }

    void print() const {
        if (tail_ == nullptr) { std::cout << "(empty)\n"; return; }
        Node* current = tail_->link;          // start at the head
        do {
            std::cout << current->info << " ";
            current = current->link;
        } while (current != tail_->link);     // stop on returning to the head
        std::cout << "\n";
    }

    void clear() { int x; while (removeFirst(x)) {} }

private:
    Node* tail_ = nullptr;
    int size_ = 0;
};

int main() {
    CircularList ring;
    for (int v : {17, 92, 63, 45}) ring.insertAtEnd(v);

    std::cout << "one full lap : "; ring.print();
    std::cout << "head=" << ring.head()->info
              << " tail=" << ring.tail()->info
              << " size=" << ring.size() << "\n";

    std::cout << "tail wraps to: " << ring.tail()->link->info << "\n";

    ring.rotate();
    std::cout << "after rotate : "; ring.print();

    int x;
    ring.removeFirst(x);
    std::cout << "removed " << x << ", now : "; ring.print();

    ring.clear();
    std::cout << "after clear  : "; ring.print();
}

Singly, doubly, circular: what actually differs

Singly linkedDoubly linkedCircular (singly)
Pointers per node1 (link)2 (prev, next)1
Last node's linknullptrnullptrpoints back to the first node
Traversal directionsforward onlyforward and backwardforward, forever
Loop terminationcurrent != nullptrcurrent != nullptrcurrent != head, with do-while
Insert at frontO(1)O(1)O(1) with a tail pointer
Insert at backO(1) with tailO(1) with tailO(1) with a tail pointer
Delete a node you holdO(n) — must find the predecessorO(1)prev is right thereO(n)
Delete the last nodeO(n) even with tailO(1) with tailO(n)
Pointer writes per insert242
Memory overheadsmallestone extra pointer per nodesame as singly
Typical usesstacks, queues, general listsback/forward, undo/redo, LRU caches, playlistsround-robin scheduling, turn order, repeat-all playback, ring buffers

The row worth staring at is "delete the last node". A singly linked list with a tail pointer can reach the last node instantly and still cannot remove it in constant time, because removal requires setting the second-to-last node's link to null and there is no way to get from the tail to its predecessor except by walking from the head. This is the cleanest single argument for why doubly linked lists exist, and it is the thing people most often state backwards.

The trade-off in one sentence. A doubly linked list spends one extra pointer of space per node to buy reverse traversal and O(1) deletion — a classic time–space trade-off. A circular list spends no extra space at all; it trades away the null terminator, and what it buys with that is a single pointer that reaches both ends plus free rotation.

And they compose. A circular doubly linked list — last node's next points to the first, first node's prev points to the last — keeps only a tail and gets O(1) at both ends, O(1) deletion anywhere, and traversal in both directions with no null anywhere in the structure. That is a media playlist with working next, previous, and repeat-all. It is also what std::list is internally on most implementations, using a sentinel node as the ring's anchor so that an empty list is a ring of one.

The mistakes people actually make

Doubly linked

  1. Overwriting prevNode->next before reading it. prevNode->next = newNode; followed by newNode->next = prevNode->next; sets the new node's next to itself. Infinite forward traversal, rest of the list leaked. Always: new node's links first.
  2. Fixing next and forgetting prev. The list traverses perfectly forwards and is corrupt backwards. Because most of your debugging output walks forwards, this survives testing. Print both directions after every mutation.
  3. One combined branch instead of two independent ones on delete. Head-ness and tail-ness are separate properties, and a single-node list has both. if (prev) ... else head = ... and then a separate if (next) ... else tail = ... — not an else if chain.
  4. Forgetting that inserting into an empty list must set both head and tail. Set only one and the list is permanently inconsistent — usually it reports itself empty forever while leaking every node you add.
  5. Passing head/tail by value. void erase(DNode* head, ...) cannot update the caller's head. You need DNode*& (or a member function operating on members). The symptom is a caller pointer aimed at freed memory.
  6. Believing a tail pointer makes singly linked deleteLast() O(1). It does not. Only prev does.
  7. Naming drift. info/prev/next in one place, data/next/prev in another. The field order in the struct also matters the moment you use aggregate initialisation: DNode{val, nullptr, nullptr} assigns positionally, so swapping prev and next in the declaration silently swaps them at every construction site.

Circular

  1. while (current != nullptr). No link is null, so this never ends. Use do { ... } while (current != head);.
  2. while (current != head) instead of do-while. The condition is false on entry, so the body runs zero times and you get silence rather than a hang. Same root cause, opposite symptom.
  3. No empty-list guard before the do-while. The body executes before the test, so it dereferences the head unconditionally. if (head == nullptr) return; goes on the line above the loop.
  4. Failing to close the loop on the first insertion. The single node has to point at itself. Leaving its link null gives you a list that is circular in intent and linear in fact, and every later do-while runs off the end.
  5. Leaving tail_ dangling after removing the only node. Set tail_ = nullptr before the delete, or at least before anything reads it again. The list will keep reporting plausible answers right up until it corrupts the heap.
  6. Keeping the head instead of the tail. Costs you an O(n) walk on every append, with no error message — just a quietly quadratic program.
  7. Reusing a singly linked list's getSize() or find() verbatim. Every one of them is written against a null terminator. Each has to be re-derived against current != head, and a search that fails must stop after one lap rather than looping forever.

Exercises with solutions

Work through each question before opening the solution below it.

Exercise 1

The order of four writes. You have a doubly linked list 1 <-> 3 and a pointer prevNode to the node holding 1. You want to insert a new node holding 2 between them.

  1. Write the four pointer assignments, in a correct order.
  2. For each of the four, say what goes wrong if it is executed first, before the others. Which orders are safe and which are not?
  3. The two-node list 1 <-> 3 has no node after 3. Extend your code so that inserting after 3 also works, and say which pointer the list itself has to update.

Solution

1. The four assignments. Naming the new node N, its predecessor P and its successor S:

cpp
N->next = P->next;   // (a) N points forward at S
N->prev = P;         // (b) N points backward at P
P->next->prev = N;   // (c) S points backward at N
P->next = N;         // (d) P points forward at N   <-- must be last

2. What each one destroys if run first.

Run firstConsequence
(a) N->next = P->nextSafe. N is not reachable from the list yet, so writing to it cannot break anything, and this is the read of P->next that everything else depends on.
(b) N->prev = PSafe, for the same reason. Reads nothing that later writes will change.
(c) P->next->prev = NSafe as long as it precedes (d). It reads P->next while that still means S. After (d) it would resolve to N->prev = N.
(d) P->next = NFatal. P->next is the only pointer to S. Once overwritten, S and everything behind it are unreachable, and any later P->next reads back N. (a) becomes N->next = N — a self-loop — and (c) becomes N->prev = N.

So the safe orders are exactly those in which (d) comes after (a) and (c). (b) can go anywhere. The habit that makes this automatic: write the new node's outgoing links first, then redirect the existing nodes into it. The new node is invisible to the rest of the list, so it is the only thing you can safely scribble on.

3. Inserting after the tail. If P is the last node then P->next is null and (c) dereferences null. Guard it, and update the list's own tail:

cpp
void insertAfter(DNode* prevNode, int val) {
    if (prevNode == nullptr) return;
    DNode* newNode = new DNode{val, nullptr, nullptr};
    newNode->next = prevNode->next;                       // (1)
    newNode->prev = prevNode;                             // (2)
    if (prevNode->next != nullptr)
        prevNode->next->prev = newNode;                   // (3)
    else
        tail_ = newNode;         // prevNode was the tail
    prevNode->next = newNode;                             // (4) last
}

The pointer that has to change is the list's tail_, not anything inside a node. That is the general shape of every doubly linked edge case: when the neighbour you would have written to does not exist, the list's end pointer takes its place. Deletion has the mirror version of the same rule, at both ends.

Compiled, this inserts 2 between 1 and 3 and prints 1 2 3 forwards and 3 2 1 backwards — and the backward line is the one that actually proves you got (c) right.

Exercise 2

Traverse a ring, and grow it from the back. Complete both functions below for a circular singly linked list, then answer three questions about them.

cpp
void printCircularList(Node* head) {
    if (head == nullptr) return;
    Node* temp = head;
    do {
        cout << temp->data << " ";
        temp = ____(1)____;
    } while (____(2)____);
}

void insertTailCircular(Node*& head, Node*& tail, int val) {
    Node* newNode = new Node{val, nullptr};
    if (head == nullptr) { head = tail = newNode; newNode->next = head; }
    else {
        newNode->next = ____(3)____;
        tail->next    = ____(4)____;
        tail          = ____(5)____;
    }
}

(a) Why must the traversal be a do-while rather than a while? (b) The three assignments in the else branch have a required order — which pairs are forced, and what breaks if you violate them? (c) The empty-list branch sets newNode->next = head after assigning head = tail = newNode. Would newNode->next = newNode be equivalent?

Solution

The blanks. (1) temp->next (2) temp != head (3) head (4) newNode (5) newNode.

(a) Why do-while. temp is initialised to head, so the condition temp != head is false at the moment of entry. A while loop tests before the first iteration, sees false, and skips the body entirely — the function prints nothing at all for a perfectly valid list. A do-while tests after the body, so every node including the head is visited exactly once and the loop stops when the walk arrives back at its starting point.

The other tempting condition, temp != nullptr, is worse: no link in a circular list is ever null, so it never terminates. Two conditions, two opposite failure modes — silence and hanging — from the same misunderstanding of what ends a circular list.

This is also why the if (head == nullptr) return; guard cannot be folded into the loop condition. A do-while runs its body before any test, so it would dereference a null head immediately.

(b) The forced order in insertTailCircular.

  • (3) before (4). Both are about the new node's forward link. Blank (3) makes newNode point at the head, closing the ring; blank (4) makes the old tail point at newNode. If you run (4) first, tail->next no longer holds anything you needed — but more importantly, if you had written blank (3) as tail->next (which is also the head, and is how you would write it if you only kept a tail pointer), running (4) first turns it into newNode->next = newNode: a self-loop, with the rest of the ring cut off. Same trap as the doubly linked insert.
  • (5) last. tail is the only handle the list keeps on the back of the ring. Advancing it before the links are wired means the intermediate state has a tail whose next is null (or stale), and if anything traverses at that moment — an exception, a logging call, another thread — it walks off the ring.

The invariant to hold in mind: at every point between statements, the ring should still be a ring. That is what forces the order here, and it is a stronger and more useful rule than memorising the three lines.

(c) newNode->next = newNode versus newNode->next = head. In this specific code they are equivalent, because head was assigned newNode on the line immediately before, so both expressions evaluate to the same address. newNode->next = newNode is the better line to write: it says this is a one-element ring directly, and it does not depend on the reader verifying that head was updated first. Written the other way round — newNode->next = head; before head = newNode; — the same-looking code would point the new node at the old head, which for an empty list is null, and you would have a non-circular list that the do-while traversal runs straight off the end of.

Compiled, inserting 10, 20, 30 gives one lap of 10 20 30, and tail->next->data prints 10 — the ring closing back on itself.

Exercise 3

Prove the deleteLast() claim. A common statement is "adding a tail pointer makes deleting the last node O(1)". It is true for a doubly linked list and false for a singly linked one.

Write both versions — deleteLast on a singly linked list that already has a tail pointer, and deleteLast on a doubly linked list that has one — and have each return the number of nodes it had to traverse. Run them on a 5-element list and explain the two numbers. Then: is there any way to make the singly linked version O(1)?

Solution

The two implementations. Run on 1 2 3 4 5 they print:

singly deleteLast walked 3 nodes; new tail = 4
doubly deleteLast walked 0 nodes; new tail = 4
cpp
#include <iostream>

struct SNode { int info; SNode* link; };
struct DNode { int info; DNode* prev; DNode* next; };

// Singly linked list, tail pointer available: still O(n).
// The tail's predecessor is not reachable from the tail, so we walk for it.
int deleteLastSingly(SNode*& head, SNode*& tail) {
    if (head == nullptr) return 0;
    int steps = 0;
    if (head == tail) { delete head; head = tail = nullptr; return steps; }
    SNode* p = head;
    while (p->link != tail) { p = p->link; ++steps; }   // the traversal
    delete tail;
    p->link = nullptr;
    tail = p;
    return steps;
}

// Doubly linked list: the predecessor is one field away.
int deleteLastDoubly(DNode*& head, DNode*& tail) {
    if (tail == nullptr) return 0;
    DNode* old = tail;
    tail = old->prev;                                   // no traversal at all
    if (tail != nullptr) tail->next = nullptr;
    else head = nullptr;
    delete old;
    return 0;
}

int main() {
    SNode* shead = nullptr; SNode* stail = nullptr;
    DNode* dhead = nullptr; DNode* dtail = nullptr;
    for (int i = 1; i <= 5; ++i) {
        SNode* s = new SNode{i, nullptr};
        if (stail) stail->link = s; else shead = s;
        stail = s;
        DNode* d = new DNode{i, dtail, nullptr};
        if (dtail) dtail->next = d; else dhead = d;
        dtail = d;
    }
    std::cout << "singly deleteLast walked " << deleteLastSingly(shead, stail)
              << " nodes; new tail = " << stail->info << "\n";
    std::cout << "doubly deleteLast walked " << deleteLastDoubly(dhead, dtail)
              << " nodes; new tail = " << dtail->info << "\n";

    while (shead) { SNode* n = shead->link; delete shead; shead = n; }
    while (dhead) { DNode* n = dhead->next; delete dhead; dhead = n; }
}

Why 3. Removing the last node is not really about the last node — it is about the second-to-last one, whose link has to become null. On a singly linked list, links only point forward, so there is no expression that gets you from tail back to its predecessor. The only route is to start at the head and walk until you find the node whose link equals tail. On a list of n nodes that is n - 2 steps from the head (3 steps for n = 5), so deleteLast() is O(n).

The tail pointer is not useless here — it saves you the test p->link->link == nullptr and lets you compare against a known address — but it cannot save you the walk. The cost of singly linked deletion has never been the unlinking; it has always been finding the predecessor. That is true of deleting by value too, and it is the single sharpest reason the doubly linked list exists.

Why 0. tail->prev is the predecessor. Two assignments and a delete, no traversal, O(1) — and note the null check: if tail becomes null after the reassignment, the list is now empty and head must be nulled too, otherwise head dangles at freed memory.

Can the singly linked version be made O(1)? Not while keeping the structure honest, but there are three ways around it, each giving something up:

  • Make the list circular and keep only the tail. Now tail->link is the head, and you can reach any node from the tail in one hop — but the predecessor is still n - 1 hops away, so deleting the last node is still O(n). Circularity buys you cheap access to both ends, not cheap access to a predecessor. Worth being precise about, because the two get conflated.
  • Add the prev pointer. That is a doubly linked list, and it is the answer.
  • Copy-and-delete-the-successor trick. For deleting a node in the middle given only a pointer to it, you can copy the successor's data into it and delete the successor instead — O(1), and a classic interview answer. It does not work for the last node, because there is no successor to copy from. Which is another way of saying: the tail is exactly the case that has no shortcut.

The one-sentence version. A singly linked list can find the end cheaply and can never step backwards from it; a doubly linked list pays one pointer per node for the ability to step backwards, and O(1) tail deletion is what that pointer buys.

Published and updated 2026-08-09 · DUOCODE TECHNOLOGY