// 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
tailand followprev. 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
prevthe predecessor is one field access away. - Deleting the last node becomes O(1). This is the one people get backwards. Adding a
tailpointer to a singly linked list does not makedeleteLast()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 atailpointer can do it in constant time. Exercise 3 measures the difference. - Positional access gets roughly twice as fast on average. With both
headandtail, an operation at indexican 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.
// 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:
N->next = SN->prev = PS->prev = NP->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 3The 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.
#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->nextC->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? 1Note 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.
#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:
insertAtBeginningandinsertAtEndare exact mirrors. Swap everynextforprevand everyhead_fortail_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 matchingelseis what keeps the backward chain intact. eraseis O(1), butfindis 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 : 3The 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.
#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:
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 nullptrThe 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.
#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
tailforward one step; the same node is now the first element. Written the other way round — as in the listing in the next section —insertAtEndis literallyinsertAtBeginningfollowed bytail_ = 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 (leavinglinknull, 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;beforetail_->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 thedelete. Skip it andtail_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.
#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 linked | Doubly linked | Circular (singly) | |
|---|---|---|---|
| Pointers per node | 1 (link) | 2 (prev, next) | 1 |
| Last node's link | nullptr | nullptr | points back to the first node |
| Traversal directions | forward only | forward and backward | forward, forever |
| Loop termination | current != nullptr | current != nullptr | current != head, with do-while |
| Insert at front | O(1) | O(1) | O(1) with a tail pointer |
| Insert at back | O(1) with tail | O(1) with tail | O(1) with a tail pointer |
| Delete a node you hold | O(n) — must find the predecessor | O(1) — prev is right there | O(n) |
| Delete the last node | O(n) even with tail | O(1) with tail | O(n) |
| Pointer writes per insert | 2 | 4 | 2 |
| Memory overhead | smallest | one extra pointer per node | same as singly |
| Typical uses | stacks, queues, general lists | back/forward, undo/redo, LRU caches, playlists | round-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
- Overwriting
prevNode->nextbefore reading it.prevNode->next = newNode;followed bynewNode->next = prevNode->next;sets the new node'snextto itself. Infinite forward traversal, rest of the list leaked. Always: new node's links first. - Fixing
nextand forgettingprev. 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. - 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 separateif (next) ... else tail = ...— not anelse ifchain. - Forgetting that inserting into an empty list must set both
headandtail. Set only one and the list is permanently inconsistent — usually it reports itself empty forever while leaking every node you add. - Passing
head/tailby value.void erase(DNode* head, ...)cannot update the caller's head. You needDNode*&(or a member function operating on members). The symptom is a caller pointer aimed at freed memory. - Believing a
tailpointer makes singly linkeddeleteLast()O(1). It does not. Onlyprevdoes. - Naming drift.
info/prev/nextin one place,data/next/previn another. The field order in the struct also matters the moment you use aggregate initialisation:DNode{val, nullptr, nullptr}assigns positionally, so swappingprevandnextin the declaration silently swaps them at every construction site.
Circular
while (current != nullptr). No link is null, so this never ends. Usedo { ... } while (current != head);.while (current != head)instead ofdo-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.- 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. - 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-whileruns off the end. - Leaving
tail_dangling after removing the only node. Settail_ = nullptrbefore thedelete, or at least before anything reads it again. The list will keep reporting plausible answers right up until it corrupts the heap. - 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.
- Reusing a singly linked list's
getSize()orfind()verbatim. Every one of them is written against a null terminator. Each has to be re-derived againstcurrent != 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.
- Write the four pointer assignments, in a correct order.
- 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?
- The two-node list
1 <-> 3has no node after3. Extend your code so that inserting after3also 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:
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 last2. What each one destroys if run first.
| Run first | Consequence |
|---|---|
(a) N->next = P->next | Safe. 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 = P | Safe, for the same reason. Reads nothing that later writes will change. |
(c) P->next->prev = N | Safe 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 = N | Fatal. 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:
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.
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
newNodepoint at the head, closing the ring; blank (4) makes the old tail point atnewNode. If you run (4) first,tail->nextno longer holds anything you needed — but more importantly, if you had written blank (3) astail->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 intonewNode->next = newNode: a self-loop, with the rest of the ring cut off. Same trap as the doubly linked insert. - (5) last.
tailis the only handle the list keeps on the back of the ring. Advancing it before the links are wired means the intermediate state has atailwhosenextis 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#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->linkis the head, and you can reach any node from the tail in one hop — but the predecessor is stilln - 1hops 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
prevpointer. 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