DSA Linked Structures
Linked Lists
Why does inserting after node B lose the rest of the playlist if you write B.next = new before saving the old suffix? The chain is reachability through links, not a block of slots.
A linked list stores each value in a reachable from . Indexing walks links; splicing a known edge can be constant-time if you practice .
Algorithm journey
Reachability
The list is the finite path from head by following next.
Save the suffix before publishing a new edge.
Repair /
Empty, front, and last-node transitions update metadata together.
Optional sentinel so front edits match interior edits.
What actually happens
Naive idea: treat a list like an array. “Insert at index i is O(1)” because you only change a pointer. That hides the Θ(i) walk to find the predecessor, and it ignores the crash when you overwrite next without saving it.
Better idea: separate find from splice. Once the predecessor handle is known, insert is a fixed number of pointer writes under . A removes the special case when the edit touches the real front.
The improved design still fails with a stale , accidental cycles, foreign node handles, or choosing a list when you mostly need random access and dense scans (a dynamic array often wins there).
Invariant (pointer safety / reachability)
After every public mutation, following next from head reaches each live data node exactly once (acyclic linear case), ends at the closed tail (or sentinel boundary), and never drops a still-needed suffix. Saves happen before overwrites; head, tail, and size agree with that reachable chain.
How to choose a linked list
1. Do you already hold node handles? Splicing around known nodes is the list's strength. Searching by index first often erases the asymptotic win.
2. Do you need stable node identity? Hash maps keyed by node, intrusive structures, and some free lists care that nodes stay put while links change.
3. Is the workload scan- and index-heavy? Prefer a dynamic array for compact storage and Θ(1) random access unless the list composition (queue, LRU, arena) is deliberate.
Quantitative check
Reaching index i always costs Θ(i) link follows. Inserting at i without a handle is Θ(i) find + Θ(1) splice. On n = 10⁵, walking to the middle each time for 10³ inserts is about 5×10⁷ link hops before any payload work. An array paying Θ(n) shifts can still win on locality for the same edits.
What the representation promises
The links define logical order; allocation addresses define physical placement. Those are independent. A node at a numerically smaller address can appear later in the list, and an allocator may sometimes place nearby nodes together. “Linked” means the algorithm follows references, not that every node must be physically far apart.
A position is a walk
To obtain node i, begin at head and take i next-edges. Accessing the middle is Θ(n) in the worst case even if the list tracks its size and tail.
An edit is an edge change
Given the correct predecessor, inserting into a singly linked list needs a fixed number of pointer writes. The lookup and the splice are separate costs; hiding the lookup causes the common “all insertions are O(1)” myth.
Interactive: walk a chain, then splice one
The addresses are deliberately out of logical order. Step through traversal, then insert 29 after node B and notice why the new node must remember the old suffix before B's link changes.
References, reachability, and cost
Follow a scattered chain, then splice a node after a predecessor you already hold.
Step 1 / 4
Start from head
The head reference gives the first node. No index calculation can jump directly to value 42.
Start from head. 0 of 3 nodes processed; current node A, value 18.
Pointer fields now
Heap allocations (address order is unrelated to list order)
A minimal, maintainable representation
These implementations store head, tail, and size. Tail and size are conveniences, not properties every linked list must maintain; each adds invariants that every mutation must preserve.
Python
class Node:
def __init__(self, value, next_node=None):
self.value = value
self.next = next_node
class SinglyLinkedList:
def __init__(self):
self.head = None
self.tail = None
self.size = 0
def prepend(self, value):
node = Node(value, self.head)
self.head = node
if self.tail is None: # empty -> one node
self.tail = node
self.size += 1
def append(self, value):
node = Node(value)
if self.tail is None:
self.head = self.tail = node
else:
self.tail.next = node
self.tail = node
self.size += 1
def insert_after(self, predecessor, value):
# Precondition: predecessor is a node in this list.
if predecessor is None:
raise ValueError("a predecessor handle is required")
node = Node(value, predecessor.next) # preserve suffix first
predecessor.next = node
if self.tail is predecessor:
self.tail = node
self.size += 1
return nodeJava
final class SinglyLinkedList<T> {
static final class Node<T> {
final T value;
Node<T> next;
Node(T value, Node<T> next) {
this.value = value;
this.next = next;
}
}
private Node<T> head;
private Node<T> tail;
private int size;
void prepend(T value) {
Node<T> node = new Node<>(value, head);
head = node;
if (tail == null) tail = node; // empty -> one node
size++;
}
void append(T value) {
Node<T> node = new Node<>(value, null);
if (tail == null) {
head = tail = node;
} else {
tail.next = node;
tail = node;
}
size++;
}
Node<T> insertAfter(Node<T> predecessor, T value) {
// Precondition: predecessor is a node in this list.
if (predecessor == null) {
throw new IllegalArgumentException("a predecessor handle is required");
}
Node<T> node = new Node<>(value, predecessor.next); // preserve suffix
predecessor.next = node;
if (tail == predecessor) tail = node;
size++;
return node;
}
}Invariants: the mutation checklist
An invariant is true before and after every public operation. For an acyclic singly linked list with head, tail, and size, these statements form a useful executable specification.
Empty agreement
head is null if and only if tail is null; then size is 0.
Closed tail
When non-empty, tail is reachable from head and tail.next is null.
One finite chain
Following next from head reaches each member once: no accidental cycle.
Count agreement
Exactly size nodes are reachable, and the last reachable node is tail.
Representation matters: a circular list intentionally violates tail.next is null; a sentinel-based list may never have a null head. State invariants for the design you actually implement.
Traversal is a small correctness proof
Python
def find(head, target):
current = head
while current is not None:
if current.value == target:
return current # return a handle, not an index
current = current.next
return NoneJava
import java.util.Objects;
final class LinkedSearch {
static final class Node<T> {
final T value;
Node<T> next;
Node(T value) { this.value = value; }
}
static <T> Node<T> find(Node<T> head, T target) {
Node<T> current = head;
while (current != null) {
if (Objects.equals(current.value, target)) {
return current; // return a handle, not an index
}
current = current.next;
}
return null;
}
}Initialization
current = head, so no reachable node appears before the unprocessed suffix.
Maintenance
Process current once, then advance to its successor. The processed prefix grows by one node.
Termination
A match returns its node; null means the finite reachable chain was exhausted.
Time is Θ(k) when the algorithm examines k nodes. Worst-case search is Θ(n); extra working space is Θ(1) iteratively. A recursive traversal uses O(n) call-stack space in the worst case.
Operation costs: include the lookup
| Operation | Singly linked cost | Condition behind the bound |
|---|---|---|
| Read or update index i | Θ(i + 1), worst Θ(n) | Start at head and follow i links; there is no address formula. |
| Search by value | O(n), worst Θ(n) | May stop early; an unsuccessful search examines every node. |
| Prepend | Θ(1) | Update new.next and head; initialize tail when the list was empty. |
| Append | Θ(1) with tail | Without a maintained tail reference, finding the last node is Θ(n). |
| Insert after a node | Θ(1) after lookup | The predecessor handle must already be known. Finding it may cost Θ(n). |
| Delete after a node | Θ(1) after lookup | Relink predecessor.next and repair tail if the removed node was last. |
| Remove the tail | Θ(n) | A singly linked list must find the predecessor; a doubly linked list can avoid that walk. |
Memory reality: more than one pointer
Layout overhead
A pointer is commonly 8 bytes in a 64-bit process, but node size also depends on field order, alignment, runtime object headers, and allocator metadata. A 4-byte payload plus an 8-byte link therefore does not justify one universal overhead percentage.
Pointer chasing
The address of the next load is discovered from the current node. This dependency can limit prefetching and memory-level parallelism; the actual penalty varies with allocation pattern, cache state, hardware, and runtime.
Allocation behavior
Growing one node at a time avoids relocating every existing element and does not require one large contiguous region. It still depends on allocator capacity and can incur fragmentation and allocation costs.
Linked list versus dynamic array
| Concern | Dynamic array | Linked list |
|---|---|---|
| Random access | Θ(1) by base + index × width | Θ(n) worst case by traversal |
| Insert at a known interior location | Θ(n) shifts in the usual contiguous representation | Θ(1) relinking when the needed handle(s) are known |
| Append | Amortized Θ(1) for a geometric-growth dynamic array | Θ(1) with a maintained tail; usually one node allocation |
| Traversal locality | Contiguous storage is friendly to caches and prefetching | Dependent pointer loads often have weaker locality |
| Per-element storage | Mostly payload plus spare capacity | Payload, link field(s), alignment, and often allocator/object overhead |
| Reference stability | Growth may invalidate pointers/iterators; shifting changes which element an index denotes | Other nodes can stay put, but removing a node invalidates handles to that node |
Big-O alone misses constants and locality. For compact values and scan-heavy workloads, a dynamic array often wins even when a list advertises a better asymptotic insertion bound, especially if the list first traverses to find the position.
Ownership, identity, and aliasing
Pointer rewiring changes which nodes are reachable; it does not automatically settle who owns or may mutate them. In garbage-collected languages, references keep objects alive. In manual-memory languages, the program must define who frees a removed node and ensure no remaining reference dereferences it.
Shared suffixes
Two heads can point into the same chain. Mutating a shared node affects both views, and naïvely freeing through both heads can double-free.
External handles
A caller may retain a node reference. Removing or recycling that node can make the handle stale even though the rest of the list remains valid.
Accidental cycles
One wrong assignment can make traversal never reach null. Cycle detection helps diagnose structure; clear mutation contracts help prevent it.
Sentinel nodes remove boundary branches
Dummy head
A permanent node before the first data node gives every data node a predecessor. Insertions and removals at the front can use the same relinking logic as interior edits. The sentinel is structural metadata, not an element.
Cost: one extra node and a convention that APIs must not expose as user data. A dummy tail can help in some designs, but it does not by itself reveal a predecessor in a singly linked list.
Python
def remove_first(head, target):
dummy = Node(None, head)
previous = dummy
while previous.next is not None:
if previous.next.value == target:
previous.next = previous.next.next
break
previous = previous.next
return dummy.next # works even when the old head was removedJava
import java.util.Objects;
final class RemoveFirst {
static final class Node<T> {
final T value;
Node<T> next;
Node(T value, Node<T> next) {
this.value = value;
this.next = next;
}
}
static <T> Node<T> removeFirst(Node<T> head, T target) {
Node<T> dummy = new Node<>(null, head);
Node<T> previous = dummy;
while (previous.next != null) {
if (Objects.equals(previous.next.value, target)) {
previous.next = previous.next.next;
break;
}
previous = previous.next;
}
return dummy.next; // works even when the old head was removed
}
}Patterns built from the same links
Fast and slow
Invariant
Fast moves two edges while slow moves one.
Find a midpoint, detect a cycle, or locate a cycle entrance without storing every visited node.
In-place reversal
Invariant
previous is the reversed processed prefix; current begins the untouched suffix.
Reverse a chain in Θ(n) time and Θ(1) extra space, carefully saving next before overwriting it.
Merge by relinking
Invariant
The output tail follows the smallest unmerged value.
Merge two sorted lists in Θ(n + m) time; reuse nodes when ownership permits, or copy when inputs must remain intact.
Python
def reverse(head):
previous = None
current = head
while current is not None:
following = current.next # save the suffix
current.next = previous # reverse one edge
previous = current
current = following
return previousJava
final class ReverseList {
static final class Node<T> {
final T value;
Node<T> next;
Node(T value) { this.value = value; }
}
static <T> Node<T> reverse(Node<T> head) {
Node<T> previous = null;
Node<T> current = head;
while (current != null) {
Node<T> following = current.next; // save the suffix
current.next = previous; // reverse one edge
previous = current;
current = following;
}
return previous;
}
}Choose the representation deliberately
Singly linked
next
Forward scans, stacks, simple queues, and free lists.
Doubly linked
previous + next
O(1) removal from a known node and bidirectional iteration; higher update and storage cost.
Circular
tail connects to head
Repeated rotation or round-robin traversal when a null terminator is undesirable.
Intrusive
hooks inside payload
Systems code that needs explicit allocation control or one object participating in multiple structures.
Skip list
probabilistic levels
Expected O(log n) search and update in an ordered linked structure, with extra forward pointers.
XOR list
encoded prev XOR next
A historical space trick with serious caveats: unsafe or unsupported in many managed, moving-GC, capability, and sanitizer-heavy environments.
Common practical compositions matter more than novelty: a queue pairs a singly linked list with head and tail; an LRU cache typically pairs a hash map with a doubly linked list; allocator free lists and intrusive lists appear in lower-level systems where object movement and lifetime are tightly controlled.
Failure modes worth rehearsing
Losing the suffix
Save old next before overwriting it: new.next = predecessor.next, then predecessor.next = new.
Stale tail
Repair tail when appending, deleting the last node, clearing, or transitioning between empty and non-empty.
Head special-case bugs
Handle the empty/front transition explicitly or use a sentinel whose contract is consistent.
Infinite traversal
Advance the cursor on every non-returning path, and do not assume null termination for a possibly circular structure.
Aliased mutation
Decide whether operations consume, copy, or share input nodes; document ownership and handle invalidation.
Complexity without context
Say whether a node/predecessor handle is supplied and whether head, tail, previous links, and size are stored.
Simple rule of thumb
Prefer a linked list when the workload is dominated by splicing around already-known node handles, stable node identity matters, and you accept link/allocation overhead. Prefer a dynamic array when you need indexing, compact storage, fast full scans, or simple ownership. If the reason is only “the size changes,” start with a dynamic array. It changes size too.
Exercises
- Nodes A→B→C. Insert X after B with the wrong order of assignments. Which nodes become unreachable, and what is the safe two-line splice under ?
- Delete the first occurrence of a value that may be the head. Rewrite the loop with a and show why the empty and front cases share one branch.
- A list maintains head, tail, and size. Give a sequence of append and delete-last operations that leaves a stale tail if you forget one repair. What breaks on the next append?
Primary sources
- Cormen et al., Introduction to Algorithms (CLRS): linked-list representations and elementary operations.
- Knuth, The Art of Computer Programming, Vol. 1: sequential allocation vs linked allocation.
- Sedgewick & Wayne, Algorithms: singly and doubly linked lists as bag, stack, and queue backends.
Takeaway
A linked list is a reachability path. Constant-time splices exist only after the right handles are known, and only when every overwrite preserves the suffix you still need.