Overview
A singly linked list (SLL) stores elements in nodes with a value and a pointer to the next node. Insertion and deletion are O(1) when the predecessor is known, because only a small, fixed set of pointers changes. Traversal is O(n) by following next from head until NIL. This note provides robust, language-agnostic pseudocode and the invariants you must preserve when inserting, deleting, and scanning, including common corner cases (empty list, one element, head/tail updates).
Structure Definition
A minimal SLL node:
Node:
value
next // = pointer to Node or NIL
The list container typically tracks at least head, and optionally tail and size:
List:
head : Node or NIL
tail : Node or NIL // optional but recommended
size : integer // optional, for O(1) length
Invariants
-
If
head == NILthentail == NILandsize == 0. -
If
tail != NILthentail.next == NIL. -
Following
nextpointers fromheadvisits each node exactly once and ends atNIL(no cycles in a plain SLL).
Core Operations
Insert at head (O(1))
function PUSH_FRONT(L, x):
node = new Node(x)
node.next = L.head
L.head = node
if L.tail == NIL: // list was empty
L.tail = node
L.size = L.size + 1Insert at tail with tail pointer (O(1))
function PUSH_BACK(L, x):
node = new Node(x)
node.next = NIL
if L.tail != NIL:
L.tail.next = node
L.tail = node
else: // empty list
L.head = node
L.tail = node
L.size = L.size + 1Tip
Without
tail, appending requires a full traversal to the last node (O(n)). Maintaintailfor queues and append-heavy workloads.
Insert after a given node p (O(1))
function INSERT_AFTER(L, p, x):
assert p != NIL
node = new Node(x)
node.next = p.next
p.next = node
if L.tail == p: // appended at the end
L.tail = node
L.size = L.size + 1Delete head (O(1))
function POP_FRONT(L):
if L.head == NIL: return EMPTY
node = L.head
L.head = node.next
if L.head == NIL: // list became empty
L.tail = NIL
L.size = L.size - 1
destroy(node) // free memory / release refDelete after a given node p (O(1))
function ERASE_AFTER(L, p):
assert p != NIL
tgt = p.next
if tgt == NIL: return // nothing to erase
p.next = tgt.next
if L.tail == tgt:
L.tail = p
L.size = L.size - 1
destroy(tgt)Delete first node with key k (O(n))
function ERASE_FIRST(L, k):
prev = NIL
curr = L.head
while curr != NIL and curr.value != k:
prev = curr
curr = curr.next
if curr == NIL: return NOT_FOUND
if prev == NIL:
// deleting head
L.head = curr.next
if L.head == NIL: L.tail = NIL
else:
prev.next = curr.next
if curr == L.tail: L.tail = prev
L.size = L.size - 1
destroy(curr)Traversal (with early exit)
// Apply func to each value; stop early if func returns true
function TRAVERSE_EARLY_EXIT(L, func):
curr = L.head
while curr != NIL:
if func(curr.value) == true:
return STOPPED_EARLY
curr = curr.next
return FINISHEDSearch (first match)
function FIND(L, k):
i = 0
for curr = L.head; curr != NIL; curr = curr.next:
if curr.value == k:
return (curr, i)
i = i + 1
return (NIL, -1)Build from array (preserves order)
function FROM_ARRAY(A):
L = new List()
for x in A:
PUSH_BACK(L, x) // maintains original order
return LClear list
function CLEAR(L):
curr = L.head
while curr != NIL:
nxt = curr.next
destroy(curr)
curr = nxt
L.head = L.tail = NIL
L.size = 0Example (Stepwise)
Start with L = [] (empty: head=tail=NIL).
-
PUSH_FRONT(3)→[3](head=tail=3). -
PUSH_BACK(5)→[3 -> 5](tail=5). -
INSERT_AFTER(node(3), 4)→[3 -> 4 -> 5]. -
POP_FRONT()removes3→[4 -> 5](head=4,tail=5). -
ERASE_FIRST(5)→[4](tail=4). -
TRAVERSE_EARLY_EXIT(x => x == 4)stops when reaching4.
Complexity and Performance
-
Traversal/Search:
O(n)time,O(1)space. -
Insert at head / insert after / delete head / delete after:
O(1)time. -
Insert at tail:
O(1)withtail, otherwiseO(n). -
Delete by value (first match):
O(n)due to search. -
Space usage:
Theta(n)nodes; each node stores pointer overhead plus payload.
When to use an SLL
-
Excellent for queues (with
head/tail), stacks (use head as top), and workloads heavy on prepend/append with minimal random access. -
Poor for random indexing (no
A[i]), and cache locality is worse than contiguous arrays.
Implementation Notes or Trade-offs
-
Memory management. In manual-memory languages, every
newmust pair with adestroy. Deleting a node must not lose the pointer to the rest of the list—savenextfirst if needed. -
Sentinel (dummy) head. Using a dummy head (
headalways non-NIL) removes special cases for deleting/inserting at the start:List.head points to dummy; real list starts at dummy.nextThen
ERASE_FIRSTnever needs to treat “delete head” specially; just start from the dummy. -
Size field. Keeping
sizeupdated givesO(1)length queries and helps with validation/tests. -
Tail correctness. Any operation that may remove or add the last node must update
tail:-
After deleting the last real node, set
tail=NIL. -
After appending, set
tail=new.
-
-
Iterator pattern. Abstract traversal behind an iterator to centralize null checks and make early exits/read-modify-delete loops safer.
-
Thread safety. Updates are not atomic; guard with locks or use single-producer/single-consumer discipline if needed.
Common Pitfalls or Edge Cases
Warning
Forgetting to update
tail. After deleting the last node (or inserting at the end viaINSERT_AFTER(tail, x)), failing to adjusttailleaves it dangling or stale.
Warning
Losing the rest of the list. When deleting
curr, do not overwritecurr.nextbefore saving it if you still need to continue traversal. Prefer the patternnxt = curr.next; destroy(curr); curr = nxt.
Warning
Null dereferences. Check
NILbefore accessingp.next.ERASE_AFTERmust handle the casep.next == NIL.
Warning
Deleting by value without
prev. In an SLL you cannot unlinkcurrwithout its predecessor. Maintain bothprevandcurrduring scans if deletion may occur.
Warning
Inconsistent emptiness invariants. Keep
head==NIL <=> tail==NIL <=> size==0. Violations cause subtle bugs (e.g., infinite loops at tail).
Warning
Freeing the wrong thing. In languages with destructors/GC finalizers, ensure node payload lifetimes are correct. In C/C++, free the node once; do not double-free if payload is shared elsewhere.
Testing & Corner Cases
Create a small, deterministic suite:
-
Empty list
-
POP_FRONT→EMPTY -
ERASE_FIRST(k)on empty →NOT_FOUND -
TRAVERSE_EARLY_EXITruns 0 iterations
-
-
Single element
-
Insert head; delete head; list becomes empty;
tail==NIL,size==0 -
INSERT_AFTER(head, x)updatestail
-
-
Two elements
-
Delete head vs delete tail via
ERASE_AFTER -
Ensure
tailpoints to the second element after deleting the first
-
-
Middle deletions
-
Build
[a->b->c->d], deleteband check wiringa.next==c -
Delete
dvia predecessor; verifytail==c
-
-
Traversal early exit
-
Predicate matches first element
-
Predicate matches last element (worst case)
-
Predicate matches none
-
-
Size accounting
- After each op, assert
sizeequals counted nodes via traversal
- After each op, assert
Tip
Add a
CHECK_INVARIANTS(L)helper for test builds:
If
head==NILthentail==NILandsize==0.Else
tail.next==NILand counting nodes equalssize.
Practical Use Cases
-
Queues: Use SLL with
head/tail;enqueue=PUSH_BACK,dequeue=POP_FRONT. -
Adjacency lists: Store neighbors as SLL nodes when memory is tight and insertions are frequent.
-
Hash table chaining: Buckets can be SLLs when constant-factor overhead should be minimal (see Hash Tables).
Summary
Singly linked lists enable O(1) local updates when the predecessor is known and O(n) traversal/search. The crux is pointer rewiring while maintaining simple invariants (head, tail, size). Prefer tail for O(1) appends, use a dummy head to simplify edge cases, and write tests for empty/one-element operations. With these patterns, insertion, deletion, and traversal are reliable, readable, and safe.