Lecture 7: Doubly Linked Lists¶
A singly linked list has one glaring weakness: once you've walked past a node, there's no way back — and inserting or deleting at the end costs O(n) precisely because you have to walk the whole list just to find the last node. The doubly linked list fixes both problems by giving every node a second pointer, back to the node before it.
In This Lecture¶
- The doubly linked list concept, and its three-field node structure
- Forward and backward traversal, including a step-by-step worked trace
- Insertion and deletion at the beginning, end, and a specific position
- A direct pointer-count comparison: rewiring a singly vs. a doubly linked node
- Searching and updating
- Common pitfalls specific to keeping two directions of pointers consistent
- A worked application: a mini browser history built on a doubly linked list
- Real applications where "go backward" is exactly what you need
The Doubly Linked List Concept¶
A doubly linked list node stores three things: the data, a pointer to the next
node, and a pointer to the previous node. The list itself typically keeps both a head
pointer (first node) and a tail pointer (last node), which is what makes end-insertion
O(1) instead of the singly linked list's O(n).
flowchart LR
Head(["head"]) --> N1
Tail(["tail"]) --> N3
N1["prev: ✕<br/>data: 10<br/>next: ●"] <--> N2["prev: ●<br/>data: 20<br/>next: ●"]
N2 <--> N3["prev: ●<br/>data: 30<br/>next: ✕"]
Node Structure: Previous, Data, and Next¶
struct DNode {
int data;
DNode* prev;
DNode* next;
DNode(int value) : data(value), prev(nullptr), next(nullptr) {}
};
Singly vs. Doubly: Rewiring a Middle Insertion¶
This is the real cost (and the real benefit) of the second pointer, made concrete.
Inserting a new node between two existing nodes A and B takes two pointer
assignments in a singly linked list, but four in a doubly linked list — the extra two
are exactly what keeps prev consistent in both directions.
flowchart LR
subgraph Singly["Singly linked list -- 2 pointer assignments"]
direction LR
SA["A"] --> SB["B"]
SNew["New"] -.->|"1. New->next = A->next"| SB
SA -.->|"2. A->next = New"| SNew
end
flowchart LR
subgraph Doubly["Doubly linked list -- 4 pointer assignments"]
direction LR
DA["A"] <--> DB["B"]
DNew["New"] -.->|"1. New->next = A->next"| DB
DNew -.->|"2. New->prev = A"| DA
DA -.->|"3. A->next = New"| DNew
DB -.->|"4. B->prev = New"| DNew
end
Forgetting any one of the doubly linked list's four steps doesn't necessarily crash —
traverseForward() might still print correctly while traverseBackward() silently prints
garbage (or an incomplete list), because forward traversal never looks at the prev
pointer that was left stale. This is exactly why doubly linked list bugs are often caught
late: half the structure looks perfectly fine.
The Doubly Linked List Class¶
#include <iostream>
using namespace std;
struct DNode {
int data;
DNode* prev;
DNode* next;
DNode(int value) : data(value), prev(nullptr), next(nullptr) {}
};
class DoublyLinkedList {
private:
DNode* head;
DNode* tail;
public:
DoublyLinkedList() : head(nullptr), tail(nullptr) {}
void insertAtBeginning(int value) {
DNode* newNode = new DNode(value);
if (head == nullptr) { head = tail = newNode; return; }
newNode->next = head;
head->prev = newNode;
head = newNode;
}
// O(1): no walk needed, because `tail` already points at the last node.
void insertAtEnd(int value) {
DNode* newNode = new DNode(value);
if (tail == nullptr) { head = tail = newNode; return; }
newNode->prev = tail;
tail->next = newNode;
tail = newNode;
}
void insertAtPosition(int value, int position) {
if (position == 0) { insertAtBeginning(value); return; }
DNode* current = head;
for (int i = 0; i < position - 1 && current != nullptr; i++) {
current = current->next;
}
if (current == nullptr) return;
if (current == tail) { insertAtEnd(value); return; }
DNode* newNode = new DNode(value);
newNode->next = current->next;
newNode->prev = current;
current->next->prev = newNode;
current->next = newNode;
}
void deleteFromBeginning() {
if (head == nullptr) return;
DNode* oldHead = head;
head = head->next;
if (head != nullptr) head->prev = nullptr;
else tail = nullptr; // list is now empty
delete oldHead;
}
// O(1): `tail` already points at the node to remove, no walk needed.
void deleteFromEnd() {
if (tail == nullptr) return;
DNode* oldTail = tail;
tail = tail->prev;
if (tail != nullptr) tail->next = nullptr;
else head = nullptr; // list is now empty
delete oldTail;
}
void deleteAtPosition(int position) {
if (position == 0) { deleteFromBeginning(); return; }
DNode* current = head;
for (int i = 0; i < position && current != nullptr; i++) {
current = current->next;
}
if (current == nullptr) return;
if (current == tail) { deleteFromEnd(); return; }
current->prev->next = current->next;
current->next->prev = current->prev;
delete current;
}
int search(int value) const {
DNode* current = head;
int index = 0;
while (current != nullptr) {
if (current->data == value) return index;
current = current->next;
index++;
}
return -1;
}
bool updateAt(int position, int newValue) {
DNode* current = head;
for (int i = 0; i < position && current != nullptr; i++) {
current = current->next;
}
if (current == nullptr) return false;
current->data = newValue;
return true;
}
void traverseForward() const {
DNode* current = head;
while (current != nullptr) {
cout << current->data;
if (current->next != nullptr) cout << " <-> ";
current = current->next;
}
cout << endl;
}
void traverseBackward() const {
DNode* current = tail;
while (current != nullptr) {
cout << current->data;
if (current->prev != nullptr) cout << " <-> ";
current = current->prev;
}
cout << endl;
}
};
int main() {
DoublyLinkedList list;
list.insertAtEnd(10);
list.insertAtEnd(20);
list.insertAtEnd(30);
cout << "Forward after inserting 10,20,30 at end: ";
list.traverseForward();
list.insertAtBeginning(5);
cout << "Forward after inserting 5 at beginning: ";
list.traverseForward();
cout << "Backward (same list, walked from tail): ";
list.traverseBackward();
list.insertAtPosition(15, 2);
cout << "Forward after inserting 15 at position 2: ";
list.traverseForward();
cout << "Search for 20: index " << list.search(20) << endl;
list.deleteAtPosition(2);
cout << "Forward after deleting position 2: ";
list.traverseForward();
list.deleteFromEnd();
cout << "Forward after deleting from end: ";
list.traverseForward();
return 0;
}
$ g++ -std=c++17 -o doubly_linked_list doubly_linked_list.cpp
$ ./doubly_linked_list
Forward after inserting 10,20,30 at end: 10 <-> 20 <-> 30
Forward after inserting 5 at beginning: 5 <-> 10 <-> 20 <-> 30
Backward (same list, walked from tail): 30 <-> 20 <-> 10 <-> 5
Forward after inserting 15 at position 2: 5 <-> 10 <-> 15 <-> 20 <-> 30
Search for 20: index 3
Forward after deleting position 2: 5 <-> 10 <-> 20 <-> 30
Forward after deleting from end: 5 <-> 10 <-> 20
Worked Example: Tracing traverseBackward() Step by Step¶
traverseBackward() starts at tail instead of head, and follows prev instead of
next — otherwise it's the exact same loop shape as forward traversal. Tracing it on the
4-node list from main() (5 <-> 10 <-> 20 <-> 30) makes the symmetry concrete:
flowchart LR
N1["5<br/>(4th printed)"] <--> N2["10<br/>(3rd printed)"] <--> N3["20<br/>(2nd printed)"] <--> N4["30<br/>(1st printed,<br/>current = tail)"]
N4 -.->|"1. print 30,<br/>current = current->prev"| N3
N3 -.->|"2. print 20,<br/>current = current->prev"| N2
N2 -.->|"3. print 10,<br/>current = current->prev"| N1
N1 -.->|"4. print 5,<br/>current->prev == nullptr, stop"| StopNull["nullptr"]
| Step | current |
Printed | Next current |
|---|---|---|---|
| 1 | node holding 30 (tail) |
30 |
current->prev → node holding 20 |
| 2 | node holding 20 |
20 |
current->prev → node holding 10 |
| 3 | node holding 10 |
10 |
current->prev → node holding 5 |
| 4 | node holding 5 |
5 |
current->prev is nullptr → loop ends |
Compare this table against the real captured output above: 30 <-> 20 <-> 10 <-> 5 reads
right to left exactly as this trace predicts — traverseBackward() is genuinely just
traverseForward() with every direction swapped, which is the entire point of paying for
a second pointer per node.
Common Pitfalls With Doubly Linked Lists¶
- Updating
nextbut forgettingprev(or vice versa). As the rewiring diagram above shows, every insertion or deletion that isn't at an end touches four pointers, not two — missing one leaves the structure "half correct": one traversal direction works, the other doesn't. - Forgetting to update
tailafter removing the last node.deleteFromEndmust settail = tail->prev— and if that newtailisnullptr(the list just became empty),headmust also be reset tonullptr, or the two pointers disagree about whether the list is empty. - Off-by-one when the target is the last node.
insertAtPositionanddeleteAtPositionabove both special-casecurrent == tail, delegating toinsertAtEnd/deleteFromEnd— without that check, the general-position logic would try to dereferencecurrent->next, which isnullptrat the tail. - Assuming a single stray pointer bug will crash immediately. It often won't — a
wrong
prevpointer just makes backward traversal wrong; nothing about forward traversal,search, orupdateAtwould ever notice, since none of them readprev. This is exactly why testing both directions matters, not just one.
Application: A Mini Browser History¶
Every one of Lecture 5 and 6's operations was justified by ordering or search speed — but
the browser-history application mentioned below is a case where the shape of the doubly
linked list solves the problem almost by itself: back() and forward() map directly
onto prev and next, and "visiting a new page from the middle of history" is exactly
what happens when you insert a node and discard everything that used to be ahead of it.
#include <iostream>
#include <string>
using namespace std;
// A minimal browser-history implementation on top of a doubly linked list.
// `current` is the page being viewed right now; `back()` moves current to
// `prev`, `forward()` moves it to `next` -- both O(1) precisely because
// every node already stores both directions.
struct PageNode {
string url;
PageNode* prev;
PageNode* next;
PageNode(const string& u) : url(u), prev(nullptr), next(nullptr) {}
};
class BrowserHistory {
private:
PageNode* current;
public:
BrowserHistory(const string& homepage) {
current = new PageNode(homepage);
}
// Visiting a new page from the middle of history discards everything
// that was "forward" of it -- exactly how a real browser behaves.
void visit(const string& url) {
PageNode* newPage = new PageNode(url);
newPage->prev = current;
current->next = newPage; // drop the old forward chain, if any
current = newPage;
}
void back() {
if (current->prev != nullptr) current = current->prev;
}
void forward() {
if (current->next != nullptr) current = current->next;
}
string currentUrl() const { return current->url; }
};
int main() {
BrowserHistory history("home.com");
history.visit("news.com");
history.visit("docs.com");
history.visit("mail.com");
cout << "After visiting 3 pages, current: " << history.currentUrl() << endl;
history.back();
history.back();
cout << "After two back() calls, current: " << history.currentUrl() << endl;
history.forward();
cout << "After one forward() call, current: " << history.currentUrl() << endl;
// Visiting from the middle discards the old "docs.com -> mail.com" forward chain.
history.visit("shop.com");
cout << "After visiting shop.com from the middle: " << history.currentUrl() << endl;
history.forward(); // nothing ahead anymore -- forward() is a safe no-op
cout << "forward() with nothing ahead, current: " << history.currentUrl() << endl;
history.back();
history.back();
cout << "After two more back() calls, current: " << history.currentUrl() << endl;
return 0;
}
$ g++ -std=c++17 -o browser_history browser_history.cpp
$ ./browser_history
After visiting 3 pages, current: mail.com
After two back() calls, current: news.com
After one forward() call, current: docs.com
After visiting shop.com from the middle: shop.com
forward() with nothing ahead, current: shop.com
After two more back() calls, current: news.com
Notice what happens after visit("shop.com"): the old mail.com node is still sitting in
memory (nothing explicitly freed it — a real implementation would need to walk and delete
the discarded forward chain), but it's no longer reachable from current no matter how
many times forward() is called. That unreachability is exactly what "the forward history
was discarded" means at the pointer level.
Applications of Doubly Linked Lists¶
- Browser history — the back and forward buttons need to move in both directions
through the pages you've visited, exactly what
prev/nextprovide directly. - Music/video playlists — "previous track" and "next track" map one-to-one onto
prevandnext. - The undo/redo stack in editors — undo walks backward through past states, redo walks forward again; a doubly linked list (or a structure built on the same idea) supports both without extra bookkeeping.
- LRU (Least Recently Used) caches — moving an item to the front on every access, and evicting from the back, are both O(1) with a doubly linked list plus a hash table, a combination you'll be well-equipped to build after Unit 8.
Complexity, Compared to a Singly Linked List¶
| Operation | Singly Linked List | Doubly Linked List |
|---|---|---|
| Insert/delete at beginning | O(1) | O(1) |
| Insert/delete at end | O(n) (must walk to find the last node) | O(1) (tail pointer already there) |
| Backward traversal | Not possible | O(n) |
| Extra memory per node | One pointer | Two pointers |
The doubly linked list's tail pointer is what turns end-insertion from O(n) into O(1) —
a direct fix for the singly linked list's weakest operation, paid for with one extra
pointer per node.
Try It Yourself¶
- Compile and run
doubly_linked_list.cpp, then add a call todeleteFromBeginning()repeated until the list is empty, printingtraverseForward()after each call. Confirm from the output that the list correctly ends up empty with no crash. - Add a method
DNode* findNode(int value) constthat returns a pointer to the node holdingvalue(ornullptr), and use it to implementupdateAtdifferently — searching by value to update, instead of by position. - Deliberately introduce the bug described in "Common Pitfalls" above: comment out just
the
newNode->prev = current;line insideinsertAtPosition, recompile, and calltraverseForward()thentraverseBackward()after inserting in the middle. Confirm forward traversal still looks correct while backward traversal doesn't — then explain in your own words why forward traversal can't detect this particular bug. - Extend
browser_history.cppwith avoid printHistory() constmethod that walks from the earliest reachable page (followingprevfromcurrentuntil it hitsnullptr) forward tocurrent, printing each URL. Call it after eachvisit,back, andforwardinmainto watch the visible history change in real time.
Key Takeaways¶
- A doubly linked list's node adds a
prevpointer alongsidenext, and the list itself tracks both aheadand atail. - The tail pointer is what makes insertion and deletion at the end O(1), fixing the singly linked list's weakest operation — at the cost of one extra pointer per node.
- A middle insertion costs 4 pointer assignments in a doubly linked list versus 2 in a singly linked list — the direct, concrete price of being able to traverse backward.
- Backward traversal, impossible in a singly linked list, becomes a simple O(n) walk from
tail, followingprevinstead ofnext— otherwise identical in shape to forward traversal. - Every insertion and deletion must keep both directions' pointers consistent — this
is the most common source of bugs when hand-writing doubly linked list code, and it's
especially dangerous because a missed
prevupdate can leave forward traversal looking completely correct while backward traversal is silently broken. - A doubly linked list isn't just "a singly linked list with extra bookkeeping" — its
shape is often the right fit for a problem, as the mini browser history shows:
back()andforward()fall directly out ofprevandnext, with no extra logic needed.