Overview
A linked list stores data in nodes where each node contains data and one or more references to other nodes. Unlike arrays, linked lists do not require contiguous memory allocation. Each element (node) exists independently in memory and connects to other nodes through pointers or references.
Three primary variants exist:
Singly Linked Lists: Each node contains data and a reference to the next node. Traversal occurs in one direction from head to tail.
Doubly Linked Lists: Each node contains data and references to both the next and previous nodes. Traversal occurs in both directions.
Circular Linked Lists: The last node points back to the first node, creating a cycle. Both singly and doubly linked variants can be circular.
Linked lists appear throughout computer science: operating system task schedulers use them, memory allocators track free blocks with them, and graph algorithms represent adjacency lists with them. The Linux kernel extensively uses doubly linked lists for data structure management. Most high-level languages hide linked list implementations behind abstraction layers, but understanding their mechanics reveals fundamental trade-offs between memory layout and access patterns.
Ruby's built-in Array class uses contiguous memory (dynamic arrays), not linked lists. However, understanding linked lists clarifies why certain operations perform differently and when custom linked structures offer advantages.
Key Principles
A linked list consists of nodes. Each node holds two components: data and references. The data component stores the actual value. The reference component (called a pointer in languages like C, or a reference in Ruby) stores the memory address or object reference of the next node.
Node Structure
In a singly linked list, a node contains:
- Data field: holds the value
- Next field: references the next node (nil/null if no next node exists)
In a doubly linked list, a node contains:
- Data field: holds the value
- Next field: references the next node
- Previous field: references the previous node
Head and Tail
The head references the first node in the list. Without the head reference, accessing the list becomes impossible. The tail references the last node, though many implementations track only the head and traverse to find the tail when needed.
Traversal Mechanics
Traversal begins at the head. The algorithm accesses the current node's data, then follows the next reference to the subsequent node. This process continues until reaching a node where next equals nil, indicating the list's end.
In doubly linked lists, traversal works in both directions. Moving forward uses next references, while moving backward uses previous references.
Circular References
Circular linked lists eliminate the nil terminator. In a singly circular list, the last node's next field points to the head. In a doubly circular list, the last node's next points to the head, and the head's previous points to the last node.
Circular lists require careful handling to avoid infinite loops. Traversal algorithms must track visited nodes or count iterations to detect when they've completed a full cycle.
Memory Characteristics
Each node exists independently in memory at non-contiguous locations. Ruby's garbage collector automatically reclaims unreferenced nodes, but in manual memory management languages, failing to free deleted nodes causes memory leaks.
The reference fields consume additional memory beyond the data itself. A singly linked list requires one reference per node (next), while a doubly linked list requires two references per node (next and previous).
Ruby Implementation
Ruby's object system simplifies linked list implementation. Creating a Node class encapsulates data and references, while the linked list class manages the overall structure.
Singly Linked List Node
class SinglyNode
attr_accessor :data, :next_node
def initialize(data)
@data = data
@next_node = nil
end
end
Singly Linked List Implementation
class SinglyLinkedList
attr_reader :head
def initialize
@head = nil
end
# Insert at beginning - O(1)
def prepend(data)
new_node = SinglyNode.new(data)
new_node.next_node = @head
@head = new_node
end
# Insert at end - O(n)
def append(data)
new_node = SinglyNode.new(data)
if @head.nil?
@head = new_node
return
end
current = @head
current = current.next_node while current.next_node
current.next_node = new_node
end
# Delete first occurrence - O(n)
def delete(data)
return if @head.nil?
if @head.data == data
@head = @head.next_node
return
end
current = @head
while current.next_node
if current.next_node.data == data
current.next_node = current.next_node.next_node
return
end
current = current.next_node
end
end
# Search - O(n)
def find(data)
current = @head
while current
return current if current.data == data
current = current.next_node
end
nil
end
# Display all elements
def to_a
elements = []
current = @head
while current
elements << current.data
current = current.next_node
end
elements
end
end
# Usage
list = SinglyLinkedList.new
list.prepend(3)
list.prepend(2)
list.append(4)
list.prepend(1)
list.to_a
# => [1, 2, 3, 4]
Doubly Linked List Node
class DoublyNode
attr_accessor :data, :next_node, :prev_node
def initialize(data)
@data = data
@next_node = nil
@prev_node = nil
end
end
Doubly Linked List Implementation
class DoublyLinkedList
attr_reader :head, :tail
def initialize
@head = nil
@tail = nil
end
# Insert at beginning - O(1)
def prepend(data)
new_node = DoublyNode.new(data)
if @head.nil?
@head = new_node
@tail = new_node
return
end
new_node.next_node = @head
@head.prev_node = new_node
@head = new_node
end
# Insert at end - O(1) with tail pointer
def append(data)
new_node = DoublyNode.new(data)
if @tail.nil?
@head = new_node
@tail = new_node
return
end
new_node.prev_node = @tail
@tail.next_node = new_node
@tail = new_node
end
# Delete node - O(1) with node reference
def delete_node(node)
return if node.nil?
if node == @head
@head = node.next_node
@head.prev_node = nil if @head
elsif node == @tail
@tail = node.prev_node
@tail.next_node = nil if @tail
else
node.prev_node.next_node = node.next_node
node.next_node.prev_node = node.prev_node
end
end
# Traverse forward
def to_a_forward
elements = []
current = @head
while current
elements << current.data
current = current.next_node
end
elements
end
# Traverse backward
def to_a_backward
elements = []
current = @tail
while current
elements << current.data
current = current.prev_node
end
elements
end
end
# Usage
dlist = DoublyLinkedList.new
dlist.append(1)
dlist.append(2)
dlist.append(3)
dlist.to_a_forward
# => [1, 2, 3]
dlist.to_a_backward
# => [3, 2, 1]
Circular Singly Linked List
class CircularSinglyLinkedList
attr_reader :head
def initialize
@head = nil
end
def append(data)
new_node = SinglyNode.new(data)
if @head.nil?
@head = new_node
new_node.next_node = @head
return
end
current = @head
current = current.next_node while current.next_node != @head
current.next_node = new_node
new_node.next_node = @head
end
# Traverse with limit to avoid infinite loop
def to_a(max_iterations = 100)
return [] if @head.nil?
elements = []
current = @head
iterations = 0
loop do
elements << current.data
current = current.next_node
iterations += 1
break if current == @head || iterations >= max_iterations
end
elements
end
def size
return 0 if @head.nil?
count = 1
current = @head.next_node
while current != @head
count += 1
current = current.next_node
end
count
end
end
# Usage
clist = CircularSinglyLinkedList.new
clist.append(1)
clist.append(2)
clist.append(3)
clist.to_a
# => [1, 2, 3]
clist.size
# => 3
Circular Doubly Linked List
class CircularDoublyLinkedList
attr_reader :head
def initialize
@head = nil
end
def append(data)
new_node = DoublyNode.new(data)
if @head.nil?
@head = new_node
new_node.next_node = new_node
new_node.prev_node = new_node
return
end
tail = @head.prev_node
tail.next_node = new_node
new_node.prev_node = tail
new_node.next_node = @head
@head.prev_node = new_node
end
def rotate_forward
@head = @head.next_node if @head
end
def rotate_backward
@head = @head.prev_node if @head
end
def to_a(max_iterations = 100)
return [] if @head.nil?
elements = []
current = @head
iterations = 0
loop do
elements << current.data
current = current.next_node
iterations += 1
break if current == @head || iterations >= max_iterations
end
elements
end
end
# Usage
cdlist = CircularDoublyLinkedList.new
cdlist.append('A')
cdlist.append('B')
cdlist.append('C')
cdlist.to_a
# => ['A', 'B', 'C']
cdlist.rotate_forward
cdlist.to_a
# => ['B', 'C', 'A']
Practical Examples
Browser History Navigation
A doubly linked list naturally models browser history. Each page becomes a node. The back button traverses to the previous node, while the forward button moves to the next node.
class BrowserHistory
def initialize(homepage)
@current = DoublyNode.new(homepage)
@current.next_node = nil
@current.prev_node = nil
end
def visit(url)
new_page = DoublyNode.new(url)
new_page.prev_node = @current
@current.next_node = new_page
@current = new_page
end
def back(steps)
steps.times do
return @current.data if @current.prev_node.nil?
@current = @current.prev_node
end
@current.data
end
def forward(steps)
steps.times do
return @current.data if @current.next_node.nil?
@current = @current.next_node
end
@current.data
end
def current_url
@current.data
end
end
history = BrowserHistory.new("homepage.com")
history.visit("page1.com")
history.visit("page2.com")
history.current_url
# => "page2.com"
history.back(1)
# => "page1.com"
history.forward(1)
# => "page2.com"
Music Playlist with Repeat
A circular linked list implements a music playlist that loops automatically. When the last song finishes, playback continues from the first song.
class MusicPlaylist
def initialize
@playlist = CircularSinglyLinkedList.new
@current = nil
end
def add_song(title)
@playlist.append(title)
@current = @playlist.head if @current.nil?
end
def play_next
return nil if @current.nil?
song = @current.data
@current = @current.next_node
song
end
def current_song
@current&.data
end
end
playlist = MusicPlaylist.new
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
playlist.play_next
# => "Song A"
playlist.play_next
# => "Song B"
playlist.play_next
# => "Song C"
playlist.play_next
# => "Song A" (loops back)
LRU Cache
An LRU (Least Recently Used) cache uses a doubly linked list combined with a hash map. The doubly linked list maintains access order, while the hash map provides O(1) lookups.
class LRUCache
def initialize(capacity)
@capacity = capacity
@cache = {}
@list = DoublyLinkedList.new
end
def get(key)
return nil unless @cache.key?(key)
node = @cache[key]
# Move to front (most recently used)
@list.delete_node(node)
@list.prepend(node.data)
@cache[key] = @list.head
node.data[:value]
end
def put(key, value)
if @cache.key?(key)
node = @cache[key]
@list.delete_node(node)
@cache.delete(key)
end
if @cache.size >= @capacity
# Remove least recently used (tail)
lru_key = @list.tail.data[:key]
@list.delete_node(@list.tail)
@cache.delete(lru_key)
end
@list.prepend({key: key, value: value})
@cache[key] = @list.head
end
end
cache = LRUCache.new(2)
cache.put(1, 'A')
cache.put(2, 'B')
cache.get(1)
# => 'A'
cache.put(3, 'C') # Evicts key 2
cache.get(2)
# => nil
Implementing Undo/Redo
A doubly linked list tracks editor states for undo and redo operations. Each edit creates a new node. Moving backward implements undo, while moving forward implements redo.
class EditorHistory
def initialize
@history = DoublyNode.new("")
@current = @history
end
def add_state(content)
new_state = DoublyNode.new(content)
new_state.prev_node = @current
@current.next_node = new_state
@current = new_state
end
def undo
return @current.data if @current.prev_node.nil?
@current = @current.prev_node
@current.data
end
def redo
return @current.data if @current.next_node.nil?
@current = @current.next_node
@current.data
end
def current_content
@current.data
end
end
editor = EditorHistory.new
editor.add_state("Hello")
editor.add_state("Hello World")
editor.add_state("Hello World!")
editor.current_content
# => "Hello World!"
editor.undo
# => "Hello World"
editor.undo
# => "Hello"
editor.redo
# => "Hello World"
Design Considerations
Arrays vs Linked Lists
Arrays store elements in contiguous memory locations, enabling constant-time random access through indexing. Linked lists store elements in dispersed memory locations, requiring sequential traversal for access.
Choose arrays when:
- Random access patterns dominate usage
- Memory locality matters for cache performance
- Fixed or rarely changing size
- Memory overhead per element must stay minimal
Choose linked lists when:
- Frequent insertions and deletions at arbitrary positions
- Unknown or highly variable size
- No need for random access
- Middle insertion/deletion must perform efficiently
Ruby's Array class uses dynamic arrays, not linked lists. Arrays in Ruby provide better performance for most use cases due to CPU cache optimization and lower per-element overhead.
Singly vs Doubly Linked Lists
Singly linked lists consume less memory per node (one reference instead of two) but only support forward traversal. Deleting a node requires traversing from the head to find the previous node.
Doubly linked lists enable bidirectional traversal and constant-time deletion when holding a node reference. The additional reference per node increases memory consumption.
Choose singly linked lists when:
- Memory constraints exist
- Only forward traversal occurs
- Deleting nodes with prior traversal (already have previous node)
Choose doubly linked lists when:
- Backward traversal required
- Node deletion without prior traversal
- Implementing deques or LRU caches
- Need to remove node with only node reference
Circular vs Linear Linked Lists
Circular linked lists connect the last node back to the first, creating a cycle. This structure suits round-robin scheduling, playlists, and buffer implementations.
Circular lists complicate termination detection. Traversal algorithms must track starting points or iteration counts to avoid infinite loops.
Choose circular linked lists when:
- Cyclic data access patterns
- Round-robin or rotating structures
- No natural beginning or end
- Ring buffer implementations
Linear linked lists have clear termination points (nil references), simplifying traversal logic but requiring explicit handling of boundary conditions.
When to Build Custom Implementations
Ruby's built-in collections (Array, Hash, Set) handle most use cases efficiently. Custom linked list implementations make sense when:
- Studying data structure concepts
- Implementing specific algorithms requiring linked structures (graph adjacency lists)
- Performance profiling reveals array operations as bottlenecks
- Building data structures not in standard library (skip lists, XOR linked lists)
Third-party gems rarely provide linked list implementations because Ruby's dynamic arrays perform better for typical usage patterns.
Performance Considerations
Time Complexity Analysis
| Operation | Singly Linked | Doubly Linked | Array |
|---|---|---|---|
| Access by index | O(n) | O(n) | O(1) |
| Search | O(n) | O(n) | O(n) |
| Insert at head | O(1) | O(1) | O(n) |
| Insert at tail | O(n) or O(1)* | O(1)** | O(1)*** |
| Insert at middle | O(n) | O(n) | O(n) |
| Delete at head | O(1) | O(1) | O(n) |
| Delete at tail | O(n) | O(1)** | O(1) |
| Delete at middle | O(n) | O(1)**** | O(n) |
* O(1) if maintaining tail pointer
** Assuming tail pointer maintained
*** Amortized O(1) with dynamic array resizing
**** O(1) with direct node reference, O(n) to find node
Space Complexity
Singly linked lists require one reference per node beyond data storage. For n elements storing integers, memory usage approximates n * (integer_size + reference_size).
Doubly linked lists require two references per node, roughly doubling the overhead compared to singly linked lists.
Arrays require no per-element overhead beyond the data itself, though dynamic arrays allocate extra capacity for growth.
In Ruby, object references typically consume 8 bytes on 64-bit systems. The overhead becomes significant for small data types. Storing 1000 integers in a doubly linked list requires approximately 16KB additional memory for references alone.
Cache Performance
Modern CPUs fetch memory in cache lines (typically 64 bytes). Arrays benefit from spatial locality—accessing array[i] often loads array[i+1] into cache automatically.
Linked lists scatter nodes throughout memory. Traversing a linked list triggers cache misses frequently, as each node likely resides in a different cache line or memory page.
Benchmarks show arrays outperform linked lists for sequential access by factors of 10x or more, primarily due to cache effects rather than algorithmic complexity.
Memory Allocation
Creating linked list nodes individually triggers multiple memory allocation operations. Each allocation incurs overhead from the memory allocator.
Arrays allocate one contiguous block, reducing allocation overhead. Dynamic array growth requires reallocation, but amortized analysis shows this remains efficient.
Ruby's garbage collector must track linked list nodes separately, potentially increasing GC pressure compared to single array objects.
Optimization Techniques
Memory pooling allocates multiple nodes in a single block, reducing allocation overhead. The application manages node reuse rather than repeatedly allocating and freeing.
class NodePool
def initialize(size)
@pool = Array.new(size) { SinglyNode.new(nil) }
@available = @pool.dup
end
def allocate(data)
node = @available.pop
return SinglyNode.new(data) if node.nil?
node.data = data
node.next_node = nil
node
end
def release(node)
@available.push(node)
end
end
Skip lists add multiple "express lane" pointers to nodes, enabling O(log n) search in linked structures. This trades memory for improved search performance.
XOR linked lists reduce doubly linked list memory overhead by XORing previous and next addresses into a single field. This technique works in languages with pointer arithmetic but not in managed languages like Ruby.
Common Pitfalls
Off-by-One Errors in Traversal
Traversing to a specific index requires careful boundary checks. A common mistake iterates one too many or too few times.
# Incorrect - misses the target index
def get_at_index_wrong(index)
current = @head
index.times do
return nil if current.nil?
current = current.next_node
end
current&.data
end
# Correct implementation
def get_at_index(index)
return nil if index < 0
current = @head
index.times do
return nil if current.nil?
current = current.next_node
end
current&.data
end
Losing References During Deletion
Deleting nodes requires updating references before losing access to nodes. A common error overwrites the reference needed to complete the operation.
# Incorrect - loses reference to rest of list
def delete_wrong(data)
if @head.data == data
@head = @head.next_node # Correct for head
return
end
current = @head
while current.next_node
if current.next_node.data == data
# BUG: This loses everything after the deleted node
current.next_node = nil
return
end
current = current.next_node
end
end
# Correct - maintains chain
def delete(data)
if @head.data == data
@head = @head.next_node
return
end
current = @head
while current.next_node
if current.next_node.data == data
current.next_node = current.next_node.next_node
return
end
current = current.next_node
end
end
Infinite Loops in Circular Lists
Circular lists require termination conditions. Forgetting to check for cycle completion causes infinite loops.
# Dangerous - infinite loop potential
def print_all_wrong
current = @head
while current
puts current.data
current = current.next_node
end
end
# Safe - tracks starting point
def print_all
return if @head.nil?
current = @head
loop do
puts current.data
current = current.next_node
break if current == @head
end
end
Nil Pointer Dereferences
Accessing nil nodes crashes the program. Defensive checks prevent errors, especially at list boundaries.
# Unsafe - crashes on empty list
def get_second_wrong
@head.next_node.data
end
# Safe - checks for nil
def get_second
return nil if @head.nil? || @head.next_node.nil?
@head.next_node.data
end
Incorrect Doubly Linked List Updates
Doubly linked lists require updating both next and previous references. Missing either update corrupts the list structure.
# Incorrect - only updates forward links
def insert_after_wrong(node, data)
new_node = DoublyNode.new(data)
new_node.next_node = node.next_node
node.next_node = new_node
# Missing: new_node.prev_node and next's prev_node updates
end
# Correct - updates all references
def insert_after(node, data)
new_node = DoublyNode.new(data)
new_node.next_node = node.next_node
new_node.prev_node = node
node.next_node.prev_node = new_node if node.next_node
node.next_node = new_node
end
Memory Leaks in Manual Languages
Ruby's garbage collector automatically frees unreferenced nodes. Languages like C and C++ require explicit memory deallocation. Forgetting to free deleted nodes leaks memory.
Comparing References Instead of Values
Searching nodes requires comparing data values, not node object identities.
# Wrong - compares node objects, not data
def contains_wrong?(data)
current = @head
search_node = SinglyNode.new(data)
while current
return true if current == search_node # Always false
current = current.next_node
end
false
end
# Correct - compares data values
def contains?(data)
current = @head
while current
return true if current.data == data
current = current.next_node
end
false
end
Reference
Linked List Type Comparison
| Feature | Singly | Doubly | Circular Singly | Circular Doubly |
|---|---|---|---|---|
| Forward traversal | Yes | Yes | Yes | Yes |
| Backward traversal | No | Yes | No | Yes |
| Memory per node | Data + 1 ref | Data + 2 refs | Data + 1 ref | Data + 2 refs |
| Insert at head | O(1) | O(1) | O(1) | O(1) |
| Insert at tail | O(n) or O(1)* | O(1) | O(n) or O(1)* | O(1) |
| Delete with ref | O(n) | O(1) | O(n) | O(1) |
| Has terminator | Yes (nil) | Yes (nil) | No | No |
| Cyclic structure | No | No | Yes | Yes |
* O(1) when maintaining tail pointer
Common Operation Complexity
| Operation | Singly | Doubly | Ruby Array |
|---|---|---|---|
| Access first | O(1) | O(1) | O(1) |
| Access last | O(n) or O(1)* | O(1) | O(1) |
| Access at index | O(n) | O(n) | O(1) |
| Insert first | O(1) | O(1) | O(n) |
| Insert last | O(n) or O(1)* | O(1) | O(1)** |
| Insert at index | O(n) | O(n) | O(n) |
| Delete first | O(1) | O(1) | O(n) |
| Delete last | O(n) | O(1) | O(1) |
| Delete at index | O(n) | O(n) | O(n) |
| Search | O(n) | O(n) | O(n) |
| Reverse | O(n) | O(n) | O(n) |
* With tail pointer
** Amortized
Ruby Implementation Checklist
| Component | Description |
|---|---|
| Node class | Contains data and reference fields |
| attr_accessor | Provides read/write access to node fields |
| initialize | Sets up empty list with nil head |
| head | Reference to first node |
| tail | Optional reference to last node |
| prepend | Insert at beginning (O(1)) |
| append | Insert at end |
| insert_at | Insert at specific position |
| delete | Remove first occurrence of value |
| find | Search for value, return node |
| to_a | Convert to array for inspection |
| size | Count nodes via traversal |
| empty? | Check if head is nil |
| clear | Set head and tail to nil |
Node Structure Reference
Singly Linked Node:
data: any value
next_node: reference to next node or nil
Doubly Linked Node:
data: any value
next_node: reference to next node or nil
prev_node: reference to previous node or nil
Use Case Selection Guide
| Use Case | Recommended Type | Reason |
|---|---|---|
| Stack operations | Singly linked | Only need head access |
| Queue operations | Singly with tail | Efficient enqueue/dequeue |
| Deque operations | Doubly linked | Need both ends |
| Browser history | Doubly linked | Bidirectional navigation |
| Music playlist loop | Circular singly | Automatic wraparound |
| LRU cache | Doubly linked | Fast removal from middle |
| Undo/redo | Doubly linked | Move forward/backward |
| Round-robin scheduler | Circular singly | Cyclic access pattern |
| Memory allocator | Doubly linked | Coalesce adjacent blocks |
| Hash table chaining | Singly linked | Forward-only traversal |