Nodes and pointers. The first data structure where you control the wiring yourself.
โฌก BeginnerA linked list is a chain of nodes (objects that bundle data with one or more pointers) where each node holds two things: some data and a reference (pointer) to the next node. Unlike an array, the nodes don't sit next to each other in memory. They can be scattered anywhere โ the pointers are what hold the chain together.
Picture a scavenger hunt. Each clue tells you where to find the next clue. You can't jump straight to clue #5 โ you have to follow the chain from the beginning. That's the trade-off: you lose random access (the ability to jump to any position in O(1)), but you gain the ability to insert and remove elements without shifting anything.
Linked lists were invented in 1955-56 by Allen Newell, Cliff Shaw, and Herbert A. Simon at the RAND Corporation. They were building the Information Processing Language (IPL) for an early AI program called the Logic Theorist, and they needed a way to represent flexible, changing data. Arrays were too rigid โ if you needed to insert something in the middle, you had to shift everything. Their solution: store each piece of data with a pointer to the next piece. The data could live anywhere in memory, and rearranging the list meant changing a few pointers instead of moving data around.
This was a radical idea at the time. Most early computers worked exclusively with fixed-size blocks of sequential memory. The linked list introduced the concept of dynamic data structures โ structures whose shape can change at runtime.
null (or None in Python), indicating the end of the chain.null. Can be singly or doubly linked. Useful when you need to cycle through elements repeatedly (think round-robin scheduling, or a playlist on repeat).Arrays are better in most cases โ faster access, better cache performance, simpler code. But linked lists win when:
malloc implementation uses them)Each node is a tiny object with at minimum two fields. In a singly linked list: data and next. In a doubly linked list: data, next, and prev. The list itself just holds a pointer to the first node (the head). Some implementations also track the tail (last node) for O(1) appends, and a size counter so you don't have to traverse the whole list to count elements.
At the head: Create a new node. Point its next to the current head. Update head to be the new node. That's it โ O(1), no shifting, no matter how long the list is. Order matters here: if you update the head before setting the new node's next, you lose access to the rest of the list.
At the tail: If you have a tail pointer, same deal โ O(1). Without one, you have to walk the entire list to find the end: O(n).
In the middle: Walk to the node before where you want to insert. Point the new node's next to the node after. Point the previous node's next to the new node. O(1) for the rewiring itself, but O(n) to find the position.
Same idea in reverse. To delete a node, you need access to the node before it (in a singly linked list) so you can bypass the deleted node. In a doubly linked list, you can delete any node you have a reference to in O(1) because you can reach both neighbors through prev and next.
One edge case that catches people: deleting the head node. You can't "unlink" the head from a previous node โ there isn't one. You have to update the head pointer itself.
Many linked list problems get messy because inserting/deleting at the head is a special case โ you need to update the head pointer itself, not some node's next. A sentinel node (also called a dummy head) eliminates this. You create a fake node before the real head, and every operation is now "insert/delete after some node" โ no special cases. At the end, the real head is dummy.next.
Each node in a singly linked list carries one pointer (8 bytes on a 64-bit machine). A doubly linked list carries two. Plus the object header overhead that languages like Python and Java add. For an integer (4 bytes of actual data), a singly linked node might use 40-60 bytes total in Python. An array storing the same integer uses about 8 bytes. That's 5-7x more memory per element. For large datasets, this matters.
In interviews, always ask yourself: "Do I need a dummy head?" If you're modifying the list and the head might change, the answer is usually yes. It simplifies your code and eliminates off-by-one bugs.
Watch how pointers get rewired during insertion and deletion. The arrows show the next references between nodes.
| Operation | Time | Notes |
|---|---|---|
| Access by index | O(n) | Must walk from head |
| Search | O(n) | Linear scan |
| Insert at head | O(1) | Just rewire one pointer |
| Insert at tail | O(1)* | *With tail pointer; O(n) without |
| Insert at position | O(n) | O(n) to find, O(1) to insert |
| Delete head | O(1) | Move head forward |
| Delete at position | O(n) | Need previous node's reference |
| Operation | Time | Notes |
|---|---|---|
| Access by index | O(n) | Can start from closer end |
| Search | O(n) | Linear scan in either direction |
| Insert at head/tail | O(1) | Rewire two pointers |
| Insert at position | O(n) | O(n) to find, O(1) to insert |
| Delete any node (given ref) | O(1) | Can access prev directly |
| Delete at position | O(n) | O(n) to find, O(1) to delete |
Space complexity: O(n) โ one node per element, plus one pointer (singly) or two pointers (doubly) per node.
class Node:
__slots__ = ('val', 'next') # Save memory by avoiding __dict__
def __init__(self, val, nxt=None):
self.val = val
self.next = nxt
class SinglyLinkedList:
def __init__(self):
self.head = None
self.size = 0
def insert_head(self, val):
# New node points to current head, then becomes head
# Order matters โ point first, then update head
self.head = Node(val, self.head)
self.size += 1
def insert_tail(self, val):
new_node = Node(val)
if not self.head:
self.head = new_node
else:
# Walk to the end โ this is why a tail pointer helps
curr = self.head
while curr.next:
curr = curr.next
curr.next = new_node
self.size += 1
def delete(self, val):
# Dummy head avoids special-casing deletion of the first node
dummy = Node(0, self.head)
prev, curr = dummy, self.head
while curr:
if curr.val == val:
prev.next = curr.next # Bypass the deleted node
self.size -= 1
self.head = dummy.next # Head might have changed
return True
prev, curr = curr, curr.next
return False
def find(self, val):
curr = self.head
while curr:
if curr.val == val:
return curr
curr = curr.next
return None
def to_list(self):
result, curr = [], self.head
while curr:
result.append(curr.val)
curr = curr.next
return result
def reverse(self):
"""Reverse the list in-place by flipping all pointers.
Uses three pointers: prev, curr, next_temp.
At each step: save next, point curr backward, advance both.
"""
prev = None
curr = self.head
while curr:
next_temp = curr.next # Save next before we break the link
curr.next = prev # Flip the arrow
prev = curr # Advance prev
curr = next_temp # Advance curr
self.head = prev # prev is now the new head
class ListNode {
constructor(val, next = null) {
this.val = val;
this.next = next;
}
}
class SinglyLinkedList {
constructor() {
this.head = null;
this.size = 0;
}
insertHead(val) {
// Point new node to current head, then swap
this.head = new ListNode(val, this.head);
this.size++;
}
insertTail(val) {
const node = new ListNode(val);
if (!this.head) {
this.head = node;
} else {
let curr = this.head;
while (curr.next) curr = curr.next;
curr.next = node;
}
this.size++;
}
delete(val) {
// Dummy head pattern โ no special case for deleting head
const dummy = new ListNode(0, this.head);
let prev = dummy, curr = this.head;
while (curr) {
if (curr.val === val) {
prev.next = curr.next;
this.head = dummy.next;
this.size--;
return true;
}
prev = curr;
curr = curr.next;
}
return false;
}
find(val) {
let curr = this.head;
while (curr) {
if (curr.val === val) return curr;
curr = curr.next;
}
return null;
}
toArray() {
const result = [];
let curr = this.head;
while (curr) {
result.push(curr.val);
curr = curr.next;
}
return result;
}
}
class DNode:
__slots__ = ('val', 'prev', 'next')
def __init__(self, val, prev=None, nxt=None):
self.val = val
self.prev = prev
self.next = nxt
class DoublyLinkedList:
def __init__(self):
# Sentinel nodes eliminate all edge cases
# head_sentinel <-> ... real nodes ... <-> tail_sentinel
self.head = DNode(None)
self.tail = DNode(None)
self.head.next = self.tail
self.tail.prev = self.head
self.size = 0
def insert_after(self, node, val):
# Splice new node between node and node.next
new = DNode(val, node, node.next)
node.next.prev = new
node.next = new
self.size += 1
return new
def remove(self, node):
# Unlink node from both neighbors โ O(1) because
# we have direct access to prev and next
node.prev.next = node.next
node.next.prev = node.prev
self.size -= 1
return node.val
def push_front(self, val):
return self.insert_after(self.head, val)
def push_back(self, val):
return self.insert_after(self.tail.prev, val)
def pop_front(self):
if self.size == 0: raise IndexError("empty")
return self.remove(self.head.next)
def pop_back(self):
if self.size == 0: raise IndexError("empty")
return self.remove(self.tail.prev)
def move_to_front(self, node):
"""Move an existing node to the front. Used in LRU caches."""
self.remove(node)
self.insert_after(self.head, node.val)
Floyd's cycle detection (also called the tortoise and hare algorithm) uses two pointers moving at different speeds. If there's a cycle, they'll meet. The math behind it: if the slow pointer has entered the cycle, the fast pointer closes the gap by one node per step, so they must collide within one full loop of the cycle.
def has_cycle(head):
"""Detect if a linked list has a cycle using O(1) space.
Slow pointer moves 1 step, fast pointer moves 2 steps.
If they meet, there's a cycle. If fast hits None, no cycle.
"""
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
def find_cycle_start(head):
"""Find where the cycle begins (if any).
After slow and fast meet inside the cycle, reset one pointer
to head. Move both one step at a time. They'll meet at the
cycle's entry point. This works because of a mathematical
relationship between the distances traveled.
"""
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
# Reset one pointer to head
slow = head
while slow is not fast:
slow = slow.next
fast = fast.next
return slow # This is the cycle start
return None # No cycle
node.next, you sever the link to everything after it. Always save next_temp = curr.next before rewiring pointers. This is the #1 bug in linked list code.head is None, calling head.next crashes. Always check for None before accessing properties. A dummy head node eliminates this class of bugs entirely.node.next pointers but forget to reassign self.head, your list is broken.An LRU (Least Recently Used) cache stores the N most recently accessed items. When the cache is full and a new item arrives, the least recently used item gets evicted. Every major browser, CDN, and database uses this strategy.
The trick: combine a doubly linked list (for O(1) reordering) with a hash map (for O(1) lookup). The most recently used item is at the head, the least recently used is at the tail.
class LRUCache:
"""LRU Cache with O(1) get and O(1) put.
Hash map: key -> node (for instant lookup)
Doubly linked list: maintains access order
- Head side = most recently used
- Tail side = least recently used (eviction candidate)
"""
def __init__(self, capacity):
self.capacity = capacity
self.cache = {} # key -> DNode
# Sentinel nodes (dummy head/tail)
self.head = DNode(None)
self.tail = DNode(None)
self.head.next = self.tail
self.tail.prev = self.head
def get(self, key):
if key not in self.cache:
return -1
node = self.cache[key]
# Move to front โ this item was just accessed
self._remove(node)
self._add_front(node)
return node.val
def put(self, key, value):
if key in self.cache:
self._remove(self.cache[key])
node = DNode(value)
node.key = key # Store key so we can clean up the dict on eviction
self._add_front(node)
self.cache[key] = node
if len(self.cache) > self.capacity:
# Evict the least recently used (tail side)
lru = self.tail.prev
self._remove(lru)
del self.cache[lru.key]
def _remove(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _add_front(self, node):
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
This is exactly the approach used by Python's functools.lru_cache, Redis's key eviction, and Linux's page cache. The doubly linked list is what makes O(1) eviction possible โ you always know which node is least recently used (it's right next to the tail sentinel).
Linked list problems test your ability to manipulate pointers without losing track of nodes. The patterns are fewer than arrays, but the pointer logic can get tricky fast.
Move one pointer one step at a time and another two steps at a time. If there's a cycle, they'll meet. If there's no cycle, the fast pointer hits null. Also used to find the middle of a list in one pass โ when fast reaches the end, slow is at the middle.
Works for: cycle detection, finding cycle start, finding the middle node, checking palindrome.
The bread and butter of linked list interviews. Use three pointers: prev, curr, and next_temp. At each step, save the next node, point current node backward, then advance. You're literally flipping arrows one by one.
Can be done iteratively (O(1) space) or recursively (O(n) space from the call stack). Interviewers usually prefer iterative.
Create a fake node that points to the head of your list. Build your result off this dummy node. At the end, return dummy.next. This eliminates every "what if the head changes?" edge case and lets you write cleaner code under pressure.
Almost mandatory for: merge two sorted lists, remove nth from end, partition list.
Walk both lists with one pointer each. At each step, pick the smaller node (for merge) or alternate (for zip). Attach it to your result. When one list runs out, attach the rest of the other.
This pattern is the basis for merge sort on linked lists, which is actually the preferred sort since linked lists don't have random access (so quicksort's partition step is awkward).
To find the Nth node from the end in one pass: advance the first pointer N steps ahead. Then move both pointers forward together. When the first pointer hits null, the second pointer is at the Nth node from the end.
Classic problem: Remove Nth Node From End of List (LC #19). The gap technique avoids needing to know the list's length.
Many linked list problems have clean recursive solutions. The idea: solve the problem for head.next (the rest of the list), then combine with head. Reversing a list recursively: reverse everything after head, then make head's next point back to head.
Be careful: recursion uses O(n) stack space. For lists longer than ~1,000 nodes in Python (or ~10,000 in other languages), use iteration instead.
Do these in order. The first three are warm-ups. The last three will test you.
| # | Problem | Difficulty | Key Pattern |
|---|---|---|---|
| 206 | Reverse Linked List | Easy | Three-pointer reversal |
| 21 | Merge Two Sorted Lists | Easy | Dummy head + two-pointer merge |
| 141 | Linked List Cycle | Easy | Fast/slow pointers |
| 19 | Remove Nth Node From End | Medium | Two pointers with gap |
| 143 | Reorder List | Medium | Find middle + reverse + merge |
| 146 | LRU Cache | Medium | Doubly linked list + hash map |
| 25 | Reverse Nodes in k-Group | Hard | Reverse sublists + pointer bookkeeping |