Algorithms - 10. Linked List
When to use Linked List
Use Linked List when you need dynamic size with efficient insertion/deletion at known positions. While arrays excel at random access, linked lists shine for:
- Frequent insertions/deletions at front (classic use case)
- Implementing stacks and queues (with O(1) operations)
- Undo functionality (maintaining operation history)
- Music playlists (next/previous navigation)
- Browser history (forward/backward navigation)
- Any problem where you frequently add/remove elements and don't need random access
Example problem
Text Editor Undo System: You're building a text editor that needs to track every edit operation for unlimited undo/redo functionality.
- User types "Hello" (5 operations)
- User deletes "lo" (2 operations)
- User types " World" (6 operations)
- User wants to undo last 3 operations
Answer: A linked list perfectly maintains the operation sequence, allowing O(1) insertion of new operations and easy traversal backwards for undo operations. Unlike an array, we don't waste memory pre-allocating space we might not use.
Single most important insight
A linked list trades random access for the ability to insert/delete anywhere in O(1) time - and by using dummy nodes at boundaries, we eliminate all edge cases because there's always a node before and after our operation point.
Mental note: The linked list's genius lies in its pointer-based connections. Each node only knows about its immediate neighbor(s), creating a chain. This local knowledge means we can insert or remove nodes by just updating a few pointers - no shifting of elements needed. The dummy node pattern ensures we never deal with null head/tail, making every operation uniform.
Decision Framework
At every linked list operation, the decision is about pointer manipulation:
- Insert: Update pointers to weave new node into the chain
- Delete: Update pointers to bypass the target node
- Traverse: Follow the chain one node at a time
- Edge case elimination: Use dummy nodes to ensure uniform operations
Code
Singly Linked List with Dummy Node
// Each element in our list is a "node" with a value and pointer to next
class ListNode {
constructor(value) {
this.value = value;
this.next = null; // Points to the next node in chain
}
}
class LinkedList {
constructor() {
// Dummy head acts like a permanent "start" marker
this.dummyHead = new ListNode("START");
this.tail = this.dummyHead; // Tail tracks the last node
}
// push: Add to the end (like array.push)
push(value) {
const newNode = new ListNode(value);
// DECISION FRAMEWORK: Simply attach to tail
this.tail.next = newNode; // Current last node points to new node
this.tail = newNode; // Update tail to be the new last node
}
// pop: Remove from the end (like array.pop)
pop() {
// DECISION FRAMEWORK: Check if list is empty
if (this.dummyHead.next === null) {
return undefined;
}
// Find the second-to-last node
let current = this.dummyHead;
while (current.next !== this.tail) {
current = current.next;
}
// Save value to return
const poppedValue = this.tail.value;
// Remove last node
current.next = null;
this.tail = current;
return poppedValue;
}
// unshift: Add to the front (like array.unshift)
unshift(value) {
const newNode = new ListNode(value);
// DECISION FRAMEWORK: Insert right after dummy
const oldFirst = this.dummyHead.next;
newNode.next = oldFirst; // New node points to old first
this.dummyHead.next = newNode; // Dummy points to new node
// If list was empty, update tail
if (this.tail === this.dummyHead) {
this.tail = newNode;
}
}
// shift: Remove from the front (like array.shift)
shift() {
// DECISION FRAMEWORK: Check if list is empty
if (this.dummyHead.next === null) {
return undefined;
}
const firstNode = this.dummyHead.next;
const shiftedValue = firstNode.value;
// Bypass first node
this.dummyHead.next = firstNode.next;
// If we removed the only node, reset tail
if (firstNode === this.tail) {
this.tail = this.dummyHead;
}
return shiftedValue;
}
// Helper: See what's in the list
display() {
const items = [];
let current = this.dummyHead.next; // Skip dummy
while (current !== null) {
items.push(current.value);
current = current.next;
}
return items.join(" → ");
}
}
// Example: Managing a todo list
const todos = new LinkedList();
// Add tasks to the end
todos.push("Buy milk");
todos.push("Write code");
todos.push("Exercise");
console.log(todos.display()); // Buy milk → Write code → Exercise
// Add urgent task to front
todos.unshift("Call mom");
console.log(todos.display()); // Call mom → Buy milk → Write code → Exercise
// Complete first task
const completed = todos.shift();
console.log(`Completed: ${completed}`); // Completed: Call mom
console.log(todos.display()); // Buy milk → Write code → Exercise
// Remove last task
const removed = todos.pop();
console.log(`Removed: ${removed}`); // Removed: Exercise
console.log(todos.display()); // Buy milk → Write code
Doubly Linked List with Dual Dummy Nodes
// Each node has pointers to both next AND previous nodes
class DoublyListNode {
constructor(value) {
this.value = value;
this.next = null; // Points forward
this.prev = null; // Points backward
}
}
class DoublyLinkedList {
constructor() {
// Two dummy nodes act as permanent "bookends"
this.dummyHead = new DoublyListNode("START");
this.dummyTail = new DoublyListNode("END");
// Connect the bookends to each other
this.dummyHead.next = this.dummyTail;
this.dummyTail.prev = this.dummyHead;
}
// push: Add to the end (like array.push)
push(value) {
const newNode = new DoublyListNode(value);
// DECISION FRAMEWORK: Insert between last real node and dummyTail
const lastNode = this.dummyTail.prev;
// Wire up all 4 connections
newNode.prev = lastNode; // New node points back to last
newNode.next = this.dummyTail; // New node points forward to dummy tail
lastNode.next = newNode; // Last node points forward to new
this.dummyTail.prev = newNode; // Dummy tail points back to new
return this; // For chaining
}
// pop: Remove from the end (like array.pop)
pop() {
// DECISION FRAMEWORK: Check if list is empty
if (this.dummyHead.next === this.dummyTail) {
return undefined;
}
const lastNode = this.dummyTail.prev;
const secondToLast = lastNode.prev;
const poppedValue = lastNode.value;
// Bypass the last node
secondToLast.next = this.dummyTail;
this.dummyTail.prev = secondToLast;
return poppedValue;
}
// unshift: Add to the front (like array.unshift)
unshift(value) {
const newNode = new DoublyListNode(value);
// DECISION FRAMEWORK: Insert between dummyHead and first real node
const firstNode = this.dummyHead.next;
// Wire up all 4 connections
newNode.prev = this.dummyHead; // New node points back to dummy head
newNode.next = firstNode; // New node points forward to first
this.dummyHead.next = newNode; // Dummy head points forward to new
firstNode.prev = newNode; // First node points back to new
return this; // For chaining
}
// shift: Remove from the front (like array.shift)
shift() {
// DECISION FRAMEWORK: Check if list is empty
if (this.dummyHead.next === this.dummyTail) {
return undefined;
}
const firstNode = this.dummyHead.next;
const secondNode = firstNode.next;
const shiftedValue = firstNode.value;
// Bypass the first node
this.dummyHead.next = secondNode;
secondNode.prev = this.dummyHead;
return shiftedValue;
}
// Helper: See what's in the list
display() {
const items = [];
let current = this.dummyHead.next;
while (current !== this.dummyTail) {
items.push(current.value);
current = current.next;
}
return items.join(" ⟷ ");
}
// Bonus: Navigate backwards
displayReverse() {
const items = [];
let current = this.dummyTail.prev;
while (current !== this.dummyHead) {
items.push(current.value);
current = current.prev;
}
return items.join(" ⟷ ");
}
}
// Example: Music playlist with next/previous
const playlist = new DoublyLinkedList();
// Add songs
playlist.push("Song A");
playlist.push("Song B");
playlist.push("Song C");
console.log(playlist.display()); // Song A ⟷ Song B ⟷ Song C
// Add song to beginning
playlist.unshift("Intro Song");
console.log(playlist.display()); // Intro Song ⟷ Song A ⟷ Song B ⟷ Song C
// Play in reverse
console.log("Reverse:", playlist.displayReverse()); // Song C ⟷ Song B ⟷ Song A ⟷ Intro Song
// Skip first song
const skipped = playlist.shift();
console.log(`Skipped: ${skipped}`); // Skipped: Intro Song
// Remove last song
const removed = playlist.pop();
console.log(`Removed: ${removed}`); // Removed: Song C
console.log(playlist.display()); // Song A ⟷ Song B
Doubt buster
Common doubt: "Why use linked lists when arrays seem simpler? Arrays give us direct access to any element with an index!"
This is a fundamental question about choosing the right data structure. Let's examine the tradeoffs.
Why you might think arrays are always better
Consider these array advantages:
- "Arrays give O(1) random access with indices"
- "Arrays have better cache locality (elements stored contiguously)"
- "No extra memory needed for pointers"
- "Simpler mental model - just a continuous block"
Why this thinking misses crucial scenarios
Key realization: Arrays have a hidden cost for insertions and deletions!
Let's trace what happens when inserting in the middle of an array:
Array: [A, B, C, D, E]
Insert X at index 2:
Step 1: Make space by shifting everything right
[A, B, _, C, D, E] // C moved to index 3
[A, B, _, _, D, E] // D moved to index 4
[A, B, _, _, _, E] // E moved to index 5
Step 2: Insert X
[A, B, X, C, D, E]
Total operations: n/2 shifts on average = O(n)
Concrete example: Performance comparison
Inserting 1000 elements at the front:
Array approach:
- First insert: shift 0 elements
- Second insert: shift 1 element
- Third insert: shift 2 elements
- ...
- 1000th insert: shift 999 elements
- Total shifts: 0 + 1 + 2 + ... + 999 = 499,500 operations!
Linked list approach:
- Every insert: update 2 pointers
- Total operations: 1000 × 2 = 2,000 operations
That's 250× fewer operations with a linked list!
When each structure shines
Use Arrays when:
- You need random access (accessing arr[500] directly)
- Data size is relatively fixed
- You mostly read data
- Cache performance matters
Use Linked Lists when:
- Frequent insertions/deletions at known positions
- Data size varies significantly
- You only traverse sequentially
- Memory is fragmented (linked lists can use scattered memory)
Why dummy nodes are genius
Without dummy nodes, every operation must check:
- "Is this the first node?" (special case for head)
- "Is this the last node?" (special case for tail)
- "Is the list empty?" (null checks everywhere)
With dummy nodes:
- There's always a node before your target (dummyHead)
- There's always a node after your target (dummyTail in doubly-linked)
- Empty list still has the dummy structure
- Every operation uses the same pattern - no special cases!
The guarantee: Dummy nodes transform a data structure full of edge cases into one with uniform operations. This isn't just convenience - it's about writing bug-free code by eliminating the possibility of null pointer errors and special case handling.