Linked Lists: Singly, Doubly, and Circular Variants
Master linked list operations including insertion, deletion, reversal, cycle detection, and knowing when to use singly, doubly, or circular variants.
Linked Lists: Singly, Doubly, and Circular Variants
Linked lists trade O(1) random access for O(1) insertion and deletion at known positions. Each node stores its data plus a pointer to the next node (singly) or both next and previous (doubly). This scattered memory layout makes them flexible but cache-unfriendly—understanding when to use them matters.
Introduction
A linked list is a linear data structure where elements (nodes) are connected via pointers rather than stored contiguously. Each node contains its data and a reference to the next node. Linked lists trade O(1) random access for O(1) insertion and deletion at arbitrary positions—a fundamental trade-off that makes them essential in specific scenarios.
Linked lists matter because they form the backbone of many common abstractions: stacks, queues, and deques are typically implemented with linked lists in production systems. They shine when you need frequent insertions and deletions at known positions, when element sizes vary significantly, or when you need data structures that grow and shrink without array resizing overhead.
Singly Linked List
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class SinglyLinkedList:
def __init__(self):
self.head = None
self.tail = None
self.size = 0
def append(self, val):
"""O(1) append to end."""
new_node = ListNode(val)
if not self.head:
self.head = self.tail = new_node
else:
self.tail.next = new_node
self.tail = new_node
self.size += 1
def prepend(self, val):
"""O(1) insert at head."""
new_node = ListNode(val, self.head)
self.head = new_node
if not self.tail:
self.tail = new_node
self.size += 1
def delete(self, val):
"""O(n) delete first occurrence."""
if not self.head:
return False
if self.head.val == val:
self.head = self.head.next
if not self.head:
self.tail = None
self.size -= 1
return True
current = self.head
while current.next:
if current.next.val == val:
if current.next == self.tail:
self.tail = current
current.next = current.next.next
self.size -= 1
return True
current = current.next
return False
def get(self, index):
"""O(n) get by index."""
if index < 0 or index >= self.size:
raise IndexError
current = self.head
for _ in range(index):
current = current.next
return current.val
def reverse(self):
"""O(n) iterative reversal."""
prev = None
current = self.head
self.tail = self.head
while current:
next_node = current.next
current.next = prev
prev = current
current = next_node
self.head = prev
Singly Linked List Node Structure
graph LR
subgraph SinglyLinked["Singly Linked List"]
direction LR
A["Node A<br/>val: A<br/>next: →"] --> B["Node B<br/>val: B<br/>next: →"] --> C["Node C<br/>val: C<br/>next: null"]
end
Head["head"] --> A
Tail["tail"] --> C
Each node has a value and a pointer to the next node. The head pointer marks the start, and tail.next is null to mark the end. You traverse starting at head, following next pointers until you hit null.
Doubly Linked List
class DListNode:
def __init__(self, val=0, prev=None, next=None):
self.val = val
self.prev = prev
self.next = next
class DoublyLinkedList:
def __init__(self):
self.head = None
self.tail = None
self.size = 0
def append(self, val):
"""O(1) append."""
new_node = DListNode(val, self.tail, None)
if self.tail:
self.tail.next = new_node
else:
self.head = new_node
self.tail = new_node
self.size += 1
def prepend(self, val):
"""O(1) insert at head."""
new_node = DListNode(val, None, self.head)
if self.head:
self.head.prev = new_node
else:
self.tail = new_node
self.head = new_node
self.size += 1
def delete(self, node):
"""O(1) delete given node reference."""
if node.prev:
node.prev.next = node.next
else:
self.head = node.next
if node.next:
node.next.prev = node.prev
else:
self.tail = node.prev
self.size -= 1
def reverse(self):
"""O(n) reversal by swapping prev/next."""
current = self.head
self.tail, self.head = self.head, self.tail
while current:
current.prev, current.next = current.next, current.prev
current = current.prev # Was next before swap
Circular Linked List
class CircularLinkedList:
"""Circular list where last node points back to first."""
def __init__(self):
self.head = None
self.size = 0
def append(self, val):
new_node = ListNode(val)
if not self.head:
new_node.next = new_node # Points to itself
self.head = new_node
else:
# Insert at end, before head
new_node.next = self.head
# Find last node
current = self.head
while current.next != self.head:
current = current.next
current.next = new_node
self.size += 1
def Josephus_circle(self, k):
"""
Josephus problem: people standing in circle, eliminate every kth.
Returns survivor's position.
"""
if self.size == 1:
return 0
current = self.head
while self.size > 1:
# Count k-1 steps
for _ in range(k - 1):
current = current.next
# Remove kth person (current.next)
removed = current.next
current.next = removed.next
if removed == self.head:
self.head = removed.next
self.size -= 1
return current.val
Cycle Detection
def has_cycle(head):
"""Floyd's cycle detection - O(n) time, O(1) space."""
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
def cycle_start(head):
"""
Find start of cycle using Floyd's algorithm.
Returns node where cycle begins, or None.
"""
slow = fast = head
has_cycle = False
# Find meeting point
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
has_cycle = True
break
if not has_cycle:
return None
# Reset slow to head, move both one step at a time
# They'll meet at cycle start
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
def remove_cycle(head, cycle_start):
"""Remove cycle given the cycle start node."""
if not cycle_start:
return
# Find the node just before cycle_start
pointer = cycle_start
while pointer.next != cycle_start:
pointer = pointer.next
pointer.next = None # Break the cycle
Linked List vs Array
| Operation | Linked List | Array |
|---|---|---|
| Access by index | O(n) | O(1) |
| Insert at head | O(1) | O(n) |
| Insert at tail | O(1)* | O(1)* |
| Delete at head | O(1) | O(n) |
| Delete at tail | O(n)** | O(1) |
| Insert in middle | O(1)*** | O(n) |
| Memory overhead | +8-16 bytes/node | 0 |
| Cache performance | Poor | Excellent |
*With tail pointer Requires traversal to find predecessor *If you have iterator to position
When to Use Each Type
Singly Linked List:
- Memory is constrained (only one pointer per node)
- You only need forward traversal
- Implementing stacks or simple queues
Doubly Linked List:
- You need to delete nodes given only a reference (O(1) deletion)
- You need reverse traversal
- Implementing LRU cache or deque
Circular Linked List:
- Round-robin scheduling
- Circular buffers
- Games with elimination (Josephus problem)
Production Failure Scenarios and Mitigations
Linked lists break in production in ways unit tests rarely catch. These are the failure modes I see most often, and how to stop them.
Null Pointer Dereference in C/C++
When you traverse a singly linked list and reach the end, you expect null. But what happens when a node’s next pointer is corrupted—perhaps from a buffer overflow or memory scribble? The program keeps walking into invalid memory until it segfaults or, worse, reads garbage and makes decisions based on corrupted data.
Mitigation: Use a sentinel node instead of null to mark the end. Sentinel nodes never get corrupted in the same way because they’re part of the list structure itself. In languages without manual memory management, use a safer abstraction like std::forward_list in C++ which handles edge cases, or switch to a GC’d language where this class of bug simply cannot happen.
Memory Leak from Unlinked Nodes
In languages without GC, if you remove a node from a linked list but forget to free it, you have a leak. More insidious: if the node is referenced elsewhere (a stale reference), freeing it creates a use-after-free. The leak compounds over time in long-running services—a leak of 64 bytes per operation running 10,000 times per second means 640KB leaked per second, or about 55GB per day.
Mitigation: Always pair removal with deallocation. Use RAII wrappers or smart pointers that auto-free. In C, establish a clear ownership model: either the list owns nodes and frees them on removal, or callers own them and must free after extraction. Do not mix models.
Race Conditions in Concurrent Access
Two threads modifying the same linked list simultaneously can corrupt it. Thread A removes node X while Thread B is traversing and holding a pointer to X. Thread A modifies X’s next pointer. Thread B tries to read X.next, which now points somewhere unexpected. The list structure can form cycles, lose nodes, or crash.
Mitigation: Use a mutex protecting all list mutations, or employ lock-free data structures designed for concurrency. If you need per-node locking (for very high contention), be careful about ABA problems where a node gets freed and reallocated with different contents between when a pointer is read and when it’s used. Readers-writer locks can help when reads vastly outnumber writes.
O(n) Traversal Causing Latency Spikes
In a real-time system or a latency-sensitive application like network packet processing, an O(n) linked list traversal at the wrong moment causes visible latency spikes. If your audio buffer processing depends on finding the right node in a linked list, a 1000-element list might cause an audible glitch.
Mitigation: If you need fast lookup, don’t use a linked list. Switch to a hash map or tree for O(1) or O(log n) access. If linked lists are required by design (for memory efficiency or the specific insertion semantics), consider adding a secondary index structure for lookups. Profile the actual access patterns—frequent random access is a sign you’re using the wrong data structure.
Trade-Off Table
Choosing between singly, doubly, and circular linked lists means weighing operational complexity against memory overhead and cache performance.
| Operation | Singly Linked | Doubly Linked | Circular |
|---|---|---|---|
| Insert at head | O(1) | O(1) | O(1)* |
| Insert at tail | O(1)** | O(1) | O(n)*** |
| Delete at head | O(1) | O(1) | O(1)* |
| Delete at tail | O(n) | O(1) | O(n) |
| Delete given node | O(n) | O(1) | O(n) |
| Reverse traversal | Not possible | O(n) | O(n) |
| Detect cycle | O(n) | O(n) | O(n) |
| Memory per node | 1 pointer | 2 pointers | 1 pointer |
| Cache friendliness | Poor | Worse | Poor |
| Implementation complexity | Low | Medium | Medium |
*Singly or doubly, if head is known With tail pointer maintained *Must traverse to find node before last
Use singly when: Memory is tight, you only walk forward, and you only manipulate head.
Use doubly when: You need reverse traversal, O(1) deletion from a node reference, or you’re building something like an LRU cache where both ends matter.
Use circular when: You need round-robin scheduling, circular buffers, or games where players eliminate each other in turn.
The cache friendliness row needs context. Linked list nodes scatter across the heap. Array elements sit contiguously, so prefetching works. When you traverse a linked list, each node access is a cache miss. This adds up—on a modern CPU with 100ns RAM latency, 1000 cache misses means 100 microseconds just waiting for memory, before any actual computation.
Observability Checklist
Monitoring linked list operations in production requires tracking both correctness and performance.
Correctness Monitoring
- Memory leak detection: Track heap size over time. Unexpected growth between requests or over long uptimes signals a leak. Tools like Valgrind, AddressSanitizer, or your language’s built-in leak detector help.
- Cycle detection alerts: If your Floyd’s algorithm ever triggers in production, something went wrong—either a bug in insertion logic or memory corruption. Log and alert on cycle detection hits.
- Null pointer dereference rate: Track segfaults and null access exceptions by operation count. A spike during list operations means something is wrong with the list structure.
Performance Monitoring
- Traversal latency percentiles: Instrument list operations to measure P50, P95, and P99 latency. If P99 spikes while P50 stays stable, you have occasional long traversals—possibly from accessing large lists.
- List size tracking: Monitor list sizes in production. A list that grows unbounded without compaction is a memory bomb.
- Operation throughput: Track operations per second. If throughput drops while list size grows, you may be hitting scaling limits of your list implementation.
Concurrency Monitoring
- Lock contention metrics: If using mutexes, track wait time and lock acquisition latency. High contention suggests too many threads fighting over the same list.
- ABA problem detection: Harder to detect, but if you see symptoms of stale data appearing after node reuse, ABA may be the cause.
Security Notes
Linked lists have specific security considerations that developers often miss until something breaks.
Dangling Pointer Exploitation
After freeing a node but before zeroing the pointer, you have a dangling pointer. If an attacker triggers code that reads through this pointer, they may read memory contents from previously freed objects. Sometimes they can place a new object at the same address and control what the dangling pointer reads or writes.
Mitigation: Always zero pointers after freeing the node they reference. In C/C++, use memory sanitizers in development to catch use-after-free. In garbage-collected languages, this attack is not possible because objects are not freed while references exist.
Iterator Invalidation
When you modify a linked list while iterating over it, iterators can become invalid. In C++ STL containers, modifying a list during iteration invokes undefined behavior—the iterator may skip elements, crash, or silently behave incorrectly. An attacker who can trigger specific modification patterns might cause information disclosure or denial of service.
Mitigation: Never modify a list during iteration without using safe patterns. Store modifications to apply after iteration, or use iterators that support safe modification (some list implementations provide this). In Java, iterating with a for-each loop while another thread modifies the list throws ConcurrentModificationException—this is the language protecting you.
Null Pointer Dereference Exploitation
In low-level code, a specially crafted input might cause a null pointer dereference in linked list traversal. If the list head is uninitialized or can be set to null by an attacker, dereferencing it crashes the program. In some environments, this can be used for denial of service attacks.
Mitigation: Validate all list state on entry to public functions. Check for null head before traversal. Use assertions liberally in debug builds to catch these conditions early. In production, fail gracefully rather than crash.
Memory Corruption from Buffer Overflows
While not specific to linked lists, buffer overflows in neighboring heap objects can corrupt next/prev pointers. An attacker overflowing a buffer adjacent to a linked list node might overwrite pointers to control subsequent memory accesses—essentially achieving arbitrary read/write.
Mitigation: Heap exploitation is a real concern. Use memory protection tools (ASLR, stack canaries), harden your allocator (use hardened tcmalloc or jemalloc), and avoid buffer overflows through bounds-checked languages, static analysis, or manual bounds checking.
Quick Recap Checklist
- Linked lists provide O(1) insertion/deletion at known positions
- No random access—O(n) traversal required
- Singly: one pointer, good for stack implementation
- Doubly: two pointers, O(1) deletion if you have node reference
- Circular: last node points to first, good for round-robin
- Floyd’s algorithm detects cycles with two pointers
Interview Questions
To delete a node, you need to update the previous node's pointer to skip the deleted node. In a singly linked list, finding the previous node requires traversal from head—O(n). If you already have a reference to the node to delete, you can copy the next node's data into it and delete the next node instead. But this fails if you're deleting the last node. With doubly linked lists, the previous pointer is stored in each node, so deletion is O(1).
Floyd's algorithm uses two pointers: slow moves one step at a time, fast moves two steps. If there's a cycle, fast will eventually lap slow inside the cycle. Once they meet, reset slow to head and move both one step at a time—they'll meet at the cycle start. The mathematics: if the cycle has length c and the meeting point is λ steps from the start, the distance slow traveled equals λ, and fast traveled λ + m×c for some m. Since fast traveled 2× slow's distance, we can prove the meeting happens.
Use three pointers: prev, current, next_node.
Iterate through the list, flipping each node's next pointer to
point to the previous node. Save next_node before overwriting,
then advance all pointers. After the loop, set head to
prev. This achieves O(n) time and O(1) space. The key is saving
the next pointer before changing the current node's direction.
Choose a linked list when you have frequent insertions and deletions at arbitrary positions and can obtain an iterator to that position in O(1). They're ideal for implementing stacks and queues where you only manipulate head/tail. Also prefer linked lists when element sizes vary widely and you want to avoid internal fragmentation. However, for most general-purpose use cases, dynamic arrays (Python list) perform better due to cache locality— only choose linked lists when the O(1) head/tail operations genuinely matter.
Use the slow and fast pointer technique (tortoise-and-hare):
- Initialize both
slowandfastpointers at head. - Move
slowone step andfasttwo steps per iteration. - When
fastreaches the end (orfast.nextis null),slowpoints to the middle node. - Time complexity: O(n), Space complexity: O(1).
Use the two-pointer technique:
- Move a
fastpointer N steps ahead from head. - Start a
slowpointer at head. - Move both pointers one step until
fastreaches the end. slownow points to the node just before the Nth-from-end node.- Adjust
slow.nextto skip the target node. Handle edge cases where the head itself is removed. - Time complexity: O(n), Space complexity: O(1).
Use a dummy/sentinel node and a two-pointer merge:
- Create a dummy node as the starting point of the result list.
- Maintain a
currentpointer starting at dummy. - Compare the heads of both lists. Append the smaller node to
current.nextand advance that list. - When one list is exhausted, attach the remainder of the other list.
- Return
dummy.nextas the new head. - Time complexity: O(n+m), Space complexity: O(1).
Solve it in O(n) time and O(1) space without modifying the original structure (or restore it):
- Find the middle node using the slow/fast pointer technique.
- Reverse the second half of the list in place.
- Compare the first half with the reversed second half element by element.
- Optionally restore the list by reversing the second half again.
- If all elements match, it is a palindrome.
A sentinel node is a placeholder node that does not contain meaningful data and is used to eliminate edge cases:
- With a sentinel head, you never need to check for an empty list or update the head pointer.
- Operations like insertion, deletion, and merging become uniform—no special cases for the first or last element.
- Common in merge sort, list reversal, and problems where the head might change (e.g., removing nodes).
- The sentinel stays fixed;
sentinel.nextalways points to the first real node.
LRU cache requires O(1) operations for both get and put:
- A doubly linked list maintains the access order of elements.
- Most recently used items stay at the head; least recently used at the tail.
- When an item is accessed, it can be removed from its current position and moved to head in O(1)—possible because each node knows its predecessor.
- When the cache is full, the tail node (LRU) is evicted in O(1).
- A hash map provides O(1) lookup from key to list node, giving the combined O(1) operations.
The Josephus problem involves N people standing in a circle. Every kth person is eliminated until one survivor remains:
- A circular linked list naturally models the circle where the last node points back to the first.
- Traverse k-1 steps from the current position to find the person to eliminate.
- Remove the kth node by updating the previous node's
nextpointer. - Continue until only one node remains—the survivor.
- The circular structure means you never need to manually wrap around; traversal stays within the circle.
Two linked lists intersect if they share a common tail (Y-shaped structure):
- Calculate the lengths of both lists.
- Advance the pointer of the longer list by the length difference.
- Move both pointers in tandem until they point to the same node.
- If they meet, that node is the intersection point. If they reach the end without meeting, there is no intersection.
- Alternatively, traverse both lists simultaneously, switching heads at the end—they will meet at the intersection after at most O(n+m) steps.
- Time complexity: O(n+m), Space complexity: O(1).
A skip list is a probabilistic data structure with multiple linked list layers:
- Bottom layer is a sorted singly linked list with all elements.
- Each higher layer acts as an "express lane" — elements appear in higher layers with probability p (typically 0.5).
- Search starts at the top layer and traverses down, achieving O(log n) average complexity.
- Insertion requires generating a random height for the new node using geometric distribution.
- Unlike sorted arrays, skip lists avoid O(n) insertions/deletions in the middle — no shifting required.
- Unlike balanced trees, skip lists require no rotations — easier to implement correctly.
Shallow copy copies pointers; deep copy allocates new nodes:
- Shallow copy: Creates a new head pointer but nodes are shared. Modifying a node in the copy affects the original. Fast—O(n) time, O(1) space.
- Deep copy: Allocates new nodes for each element. The copy is fully independent. For lists with no cycles, use iteration; for lists with cycles, use hash map to track visited nodes and avoid infinite loops.
- Cycled deep copy requires a visited map to handle the cycle properly—store original-to-new node mapping as you traverse.
- Deep copy of a singly linked list without cycles: iterate, allocate new nodes, copy val, link next pointers.
Detection uses Floyd's algorithm; removal requires finding the cycle start:
- Detection: Use slow/fast pointers. If they meet, a cycle exists.
- Find cycle start: Reset slow to head after detection. Move both one step at a time until they meet again—that node is where the cycle begins.
- Remove cycle: Find the node just before the cycle start (the last node in the cycle). Set its next pointer to null instead of the cycle start.
- To find the node before cycle start efficiently, count the cycle length c, then use two pointers c steps apart.
- Alternative: once slow and fast meet inside the cycle, find all nodes in the cycle. Then find the node whose next points into the cycle.
A random pointer links to a random node (not the next). Cloning requires handling both next and random:
- Naive approach: Two-pass with hash map. First pass creates all nodes. Second pass sets random pointers using the map for O(n) lookup. Time O(n), Space O(n).
- Optimal approach (O(1) space): Interweave original and copy in one pass: for each node, create copy and insert it right after the original. Set copy.random = original.random.next. Then extract the copy list and restore original.next pointers.
- The interweaving method works because copy.random can point to copy.next by using original.random.next.
- Handle edge cases: if original.random is null, copy.random stays null. If original.next is null, skip accordingly.
Intersection point detection uses the difference-in-lengths technique:
- Traverse both lists to get their lengths.
- Move the longer list's pointer ahead by the absolute difference in lengths.
- Now both pointers are equidistant from the intersection point.
- Traverse both simultaneously until pointers match—that's the intersection node.
- Alternative: traverse list A to end, then switch to list B. Traverse list B to end, then switch to list A. Both pointers will travel the same total distance and meet at the intersection.
- Proof: if lists intersect at point P, pointer A travels (a to end) + (b to P), pointer B travels (b to end) + (a to P). Both equal a + b + distance to P.
Use the reverse-half technique without extra data structures:
- Find the middle node using slow/fast pointers.
- Reverse the second half of the list in place by flipping next pointers.
- Compare the first half with the reversed second half node by node.
- If all values match, it is a palindrome.
- Restore the list by reversing the second half again before returning.
- Edge cases: empty list and single-node list are palindromes. For odd-length lists, skip the middle node during comparison.
Concurrency introduces challenges that differ between list types:
- Singly: Easier to reason about—only one direction of pointers. Insertion/deletion only need update one pointer. However, deletion of a node given only its reference requires traversal to find predecessor—holding lock longer or requiring more complex lock-free algorithms.
- Doubly: O(1) deletion with node reference (can update both next and prev without finding predecessor). But each mutation updates two pointers, doubling the chance of consistency issues. The prev pointer introduces ABA problem risk—prev can be freed and reallocated between read and write.
- Lock-free doubly linked lists are notoriously hard—implementations like Michael-Scott queue use compare-and-swap carefully. Singly linked lock-free structures like FCFS queues are simpler.
- For read-heavy workloads, reader-writer locks help. Writes still need exclusive access but reads can proceed concurrently.
A multilevel list has next and child pointers; flatten by expanding child chains:
- Use a dummy head node and a tail pointer tracking the end of the flattened list.
- Traverse original list. When encountering a node with a child pointer, recursively flatten the child list first.
- Attach the flattened child chain to tail.next, then advance tail through the attached chain.
- Continue with current.next until the entire structure is flattened.
- Time complexity: O(n) where n is total nodes. Each node visited once.
- Alternative iterative approach using stack to simulate recursion when recursion depth is a concern.
- Handle unflattening back to original structure if needed by restoring child pointers at appropriate positions.
Further Reading
To deepen your understanding of linked lists and related data structures, explore these resources:
- Arrays: 1D and 2D — Compare linked lists with contiguous memory structures
- Floyd’s Cycle Detection — Mathematical proof and variations of the tortoise-and-hare algorithm for cycle detection
- Skip Lists — A probabilistic alternative to balanced trees built using multiple linked list layers for O(log n) search
- LRU Cache Implementation — Practical use of doubly linked lists combined with hash maps for O(1) cache get/put operations
- Lock-Free Linked Lists — Concurrent linked list implementations using CAS (Compare-And-Swap) for high-performance multi-threaded systems
- Bloom Filters — Space-efficient probabilistic data structures often paired with linked lists for collision resolution in hash tables
Conclusion
Linked lists give you O(1) insertion and deletion at known positions, but you lose O(1) random access. Singly linked lists are simplest; use doubly when you need reverse traversal or O(1) deletion given a node reference. Circular lists are the right tool for round-robin scheduling and similar patterns. Floyd’s cycle detection is elegant and worth memorizing. For more on data structures, see Arrays: 1D and 2D.
Category
Tags
Related Posts
Arrays vs Linked Lists: Understanding the Trade-offs
Compare arrays and linked lists in terms of access time, insertion/deletion efficiency, memory usage, and cache performance.
Arrays: 1D, 2D, and Multi-dimensional Mastery
Master array operations, traversal, search, common patterns like two-pointer and sliding window, and when to use multi-dimensional arrays.
AVL Trees: Self-Balancing Binary Search Trees
Master AVL tree rotations, balance factors, and rebalancing logic. Learn when to use AVL vs Red-Black trees for your use case.