Linked Lists
Imagine a playlist in Apple Music. Songs aren’t stored in one continuous block—they’re scattered across your device with each song pointing to the next. Adding a song mid-playlist doesn’t require shifting thousands of items. This is a linked list in action.
In Chapter 6, you encountered linked list nodes as self-referential structures perfect for recursive algorithms. In Chapter 8, we learned that different data structures offer different performance trade-offs. Now it’s time to build a complete linked list implementation—a fundamental data structure that trades random access speed for insertion and deletion flexibility.
A linked list provides similar functionality to an array—the ability to insert, retrieve, update, and remove elements. However, because elements are managed independently (scattered in memory) rather than contiguously (in a single block), linked lists excel when dealing with frequent modifications to large datasets.
Arrays vs linked lists
Before implementing linked lists, understand when to choose them over Swift’s built-in Array.
Memory organization
-
Arrays store elements in contiguous memory—a single continuous block. This enables fast random access (base address + index × size = element location) but requires copying the entire array to a larger block when capacity is exceeded.
-
Linked lists store elements anywhere in memory, with each element pointing to the next (and optionally previous). No resizing needed—just allocate new nodes as needed. The cost is slower access by index since you must traverse from the beginning.
Performance comparison
Operation | Array | Linked List |
---|---|---|
Access by index | O(1) |
O(n) |
Insert at beginning | O(n) |
O(1) |
Insert at end | O(1) * |
O(1) ** |
Remove from beginning | O(n) |
O(1) |
Search for value | O(n) |
O(n) |
* Amortized O(1)
with capacity doubling
** If maintaining a tail pointer
When to use each
Use linked lists when:
- Frequent insertions/deletions at beginning or middle
- Unknown or highly dynamic size
- Sequential-only access patterns
- Building other data structures (Stack, Queue, Hash Table with chaining)
Use arrays when:
- Frequent random access by index
- Mostly reading data, few modifications
- Small datasets (< 100 elements)
- Memory efficiency critical (arrays have lower overhead)
- Working with Swift’s functional methods (map, filter, reduce)
Real-world examples:
- Linked lists: iOS responder chain, browser navigation history, undo/redo systems, music playlists (add/remove songs), workout interval training (modify exercises mid-workout)
- Arrays: SwiftUI List data sources, configuration settings, most app data models, weekly workout summaries, step count history
The node structure
The production implementation uses LLNode<T>
to represent individual nodes in a doubly-linked list:
// Generic linked list node - doubly-linked with previous and next pointers
public class LLNode<T> {
var tvalue: T?
var next: LLNode?
var previous: LLNode?
public init() {}
}
This structure supports both singly-linked lists (using only next
) and doubly-linked lists (using both next
and previous
). The generic type T
allows storing any type, and optional values enable representing empty nodes and list boundaries.
Building the LinkedList class
The LinkedList<T>
class manages a collection of nodes:
// Generic linked list implementation
public class LinkedList<T> {
private var head = LLNode<T>()
private var counter: Int = 0
// Number of elements in list - O(1)
var count: Int {
return counter
}
// Check if list is empty - O(1)
public func isEmpty() -> Bool {
return counter == 0 || head.tvalue == nil
}
}
The head
node represents the beginning of the list. The counter
tracks the number of elements for O(1)
count operations.
Appending elements
Adding elements to the end of a linked list requires traversing to the last node:
// Add element to end of list - O(n)
public func append(_ tvalue: T) {
// Handle empty list
guard head.tvalue != nil else {
head.tvalue = tvalue
counter += 1
return
}
var current: LLNode = head
// Traverse to end of list
while let item = current.next {
current = item
}
// Create and append new node
let childToUse = LLNode<T>()
childToUse.tvalue = tvalue
childToUse.previous = current
current.next = childToUse
counter += 1
}
This operation is O(n)
because we must traverse the entire list to find the end. A production implementation might maintain a tail
pointer to make appending O(1)
.
Finding elements
Accessing elements by index requires linear traversal:
// Find element at specific index - O(n)
public func find(at index: Int) -> LLNode<T>? {
// Validate index
if index < 0 || index > (count - 1) || head.tvalue == nil {
return nil
}
var current: LLNode<T> = head
var x: Int = 0
// Traverse to index
while index != x {
guard let nextNode = current.next else {
return nil
}
current = nextNode
x += 1
}
return current
}
// Subscript support for convenient access
public subscript(index: Int) -> LLNode<T>? {
get {
return find(at: index)
}
}
Unlike arrays where array[5]
is instant, linked lists must walk through nodes 0, 1, 2, 3, 4 to reach index 5. This makes random access inefficient.
Inserting elements
Insertion at a specific position involves adjusting pointer references:
// Insert element at specific index - O(n)
public func insert(_ tvalue: T, at index: Int) {
// Validate and handle empty list
guard index >= 0 && index <= count - 1 else {
print("Index out of bounds")
return
}
guard head.tvalue != nil else {
head.tvalue = tvalue
counter += 1
return
}
var current: LLNode<T>? = head
var trailer: LLNode<T>?
var listIndex: Int = 0
// Find insertion point
while current != nil {
if index == listIndex {
let childToUse = LLNode<T>()
childToUse.tvalue = tvalue
// Link new node into chain
childToUse.next = current
childToUse.previous = trailer
if let linktrailer = trailer {
linktrailer.next = childToUse
}
if let linkCurrent = current {
linkCurrent.previous = childToUse
}
// Update head if inserting at beginning
if index == 0 {
head = childToUse
}
break
}
trailer = current
current = current?.next
listIndex += 1
}
counter += 1
}
Once at the correct position, insertion is O(1)
—just adjust a few pointers. The O(n)
cost comes from finding the position.
Removing elements
Removal requires relinking nodes to skip the deleted element:
// Remove element at specific index - O(n)
public func remove(at index: Int) {
guard head.tvalue != nil else { return }
// Handle removing first element
if index == 0 {
if let item = head.next {
head = item
counter -= 1
} else {
head.tvalue = nil
counter = 0
}
return
}
var current: LLNode<T>? = head
var trailer: LLNode<T>?
var nodeindex: Int = 0
// Find node to remove
while let item = current {
if nodeindex == index {
// Bypass removed node in chain
if let tnode = trailer, let cnode = current {
tnode.next = cnode.next
}
current = nil
break
}
trailer = current
current = item.next
nodeindex += 1
}
counter -= 1
}
Removing from the beginning is O(1)
, but removing from elsewhere requires O(n)
traversal to find the node.
Traversing the list
Processing all elements uses iteration:
// Print all values in list - O(n)
public func printValues() {
var current: LLNode? = head
while current != nil {
if let item = current, let tvalue = item.tvalue {
print("link item is: \(tvalue)")
}
current = current?.next
}
}
This pattern—using a current
pointer that advances through the list—is fundamental to all linked list operations.
Building algorithmic intuition
Linked lists demonstrate fundamental trade-offs between data structures: arrays provide fast index access but expensive insertions, while linked lists provide fast insertions but require linear traversal. Pointer manipulation—following next
pointers, updating references, managing node connections—forms the foundation for more complex structures like stacks and queues (Chapter 10), binary search trees (Chapter 11), and graphs (Chapter 13). Understanding when dynamic structure matters more than random access reveals when linked lists provide the optimal solution.