Singly Linked Lists
A singly linked list is a linear data structure made of individual nodes, where each node holds a value and a single pointer to the next node in the sequence. Unlike a Python list, which stores its elements in one contiguous block of memory, a linked list’s nodes can live anywhere in memory — they are connected only by these pointers. This makes certain operations, like inserting at the front, extremely cheap, while others, like jumping to the 500th element, become slow. Understanding linked lists is essential in DSA because they are the foundation for stacks, queues, and more advanced structures, and they appear constantly in coding interviews.
Overview: How Singly Linked Lists Work
Picture a scavenger hunt where each clue tells you where to find the next clue, but you have no way of jumping ahead — you must follow the trail one step at a time. That is exactly how a singly linked list works. Each node is a small object with two pieces of data: the value it stores, and a reference called next that points to the following node (or to None if it is the last node). The list itself only needs to remember one thing: a reference to the first node, called the head. Everything else is reachable by following next pointers, one hop at a time.
This design has a direct consequence: a singly linked list has no random access. To reach the 5th element, you must start at the head and follow four next pointers, because there is no arithmetic shortcut the way there is with an array (where element i lives at a computable memory offset). This is the central tradeoff of linked lists versus Python’s built-in list (which is implemented as a dynamic array): you give up O(1) indexing in exchange for O(1) insertion and deletion at the front, and no need to shift elements when the structure grows or shrinks in the middle.
Because a node only knows about the node after it (not before), a singly linked list can only be traversed in one direction — forward. This is the key difference from a doubly linked list, which adds a prev pointer at the cost of extra memory per node. If you never need to walk backward or delete an arbitrary node in O(1) given only a reference to it, a singly linked list is simpler and uses less memory.
Time and Space Complexity
The complexity of every operation comes down to one question: does it require a traversal from the head, or can it be done with a fixed number of pointer updates? Operations that only touch the head (or a node you already have a reference to) are O(1). Operations that require searching for a value, an index, or the tail (without a cached tail pointer) are O(n), because in the worst case you must visit every node.
| Operation | Time Complexity | Why |
|---|---|---|
| Access by index / search by value | O(n) | No random access — must walk from head, following next pointers one at a time |
| Insert at head (prepend) | O(1) | Just create a node and repoint head — no traversal needed |
| Insert at tail (no tail pointer) | O(n) | Must traverse to the last node before attaching the new one |
| Insert at tail (tail pointer maintained) | O(1) | Direct reference to the last node avoids traversal |
| Insert after a known node | O(1) | Only two pointer reassignments, regardless of list size |
| Delete at head | O(1) | head is simply repointed to head.next |
| Delete by value | O(n) | Must search for the node (and the node before it) first |
| Space (overall structure) | O(n) | One Node object per element, each with an extra pointer field |
The space complexity of the list itself is O(n) for n elements, but note that each node also carries the overhead of the next pointer — something a plain array-backed list does not pay per element. Most linked-list operations use O(1) extra space beyond the list itself, since they only need a small, fixed number of temporary variables (like previous and current) no matter how large the list is.
Examples
Example 1: Building a Linked List and Printing It
This example defines a minimal Node class and a SinglyLinkedList wrapper with an append method that adds to the end, plus a to_list helper that converts the linked list into a Python list so we can easily print and verify it.
from __future__ import annotations
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next
class SinglyLinkedList:
def __init__(self) -> None:
self.head: Node | None = None
def append(self, value: int) -> None:
new_node = Node(value)
if self.head is None:
self.head = new_node
return
current = self.head
while current.next is not None:
current = current.next
current.next = new_node
def to_list(self) -> list[int]:
result = []
current = self.head
while current is not None:
result.append(current.value)
current = current.next
return result
linked_list = SinglyLinkedList()
linked_list.append(10)
linked_list.append(20)
linked_list.append(30)
print(linked_list.to_list())
Output:
[10, 20, 30]
Each call to append walks from head until it finds a node whose next is None (the current last node), then attaches the new node there. After three appends, the chain is 10 -> 20 -> 30 -> None. Notice this append is O(n) per call because it re-traverses the whole list every time — a production implementation would cache a tail reference to make appends O(1), which is discussed in Best Practices.
Example 2: Inserting at the Head and Deleting by Value
This example shows the cheap O(1) operation (prepend) alongside the more expensive O(n) operation (delete, which must search for the target value).
from __future__ import annotations
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next
class SinglyLinkedList:
def __init__(self) -> None:
self.head: Node | None = None
def prepend(self, value: int) -> None:
self.head = Node(value, self.head)
def delete(self, value: int) -> bool:
previous = None
current = self.head
while current is not None:
if current.value == value:
if previous is None:
self.head = current.next
else:
previous.next = current.next
return True
previous = current
current = current.next
return False
def to_list(self) -> list[int]:
result = []
current = self.head
while current is not None:
result.append(current.value)
current = current.next
return result
linked_list = SinglyLinkedList()
for value in [3, 2, 1]:
linked_list.prepend(value)
print("Before delete:", linked_list.to_list())
linked_list.delete(2)
print("After delete:", linked_list.to_list())
Output:
Before delete: [1, 2, 3]
After delete: [1, 3]
prepend creates a new node whose next already points at the old head, then makes that new node the head — a single O(1) operation regardless of list length. Prepending 3, then 2, then 1 produces 1 -> 2 -> 3. Deleting 2 requires delete to walk the list tracking both previous and current; when it finds the node holding 2, it splices it out by pointing previous.next directly at current.next, leaving 1 -> 3.
Example 3: Reversing a Singly Linked List
Reversing a linked list in place is one of the most common interview questions, and it is a great way to see why saving a pointer before overwriting it matters.
from __future__ import annotations
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next
def build_list(values: list[int]) -> Node | None:
head: Node | None = None
tail: Node | None = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def reverse_list(head: Node | None) -> Node | None:
previous = None
current = head
while current is not None:
next_node = current.next
current.next = previous
previous = current
current = next_node
return previous
def to_list(head: Node | None) -> list[int]:
result = []
current = head
while current is not None:
result.append(current.value)
current = current.next
return result
original_head = build_list([1, 2, 3, 4, 5])
print("Original:", to_list(original_head))
reversed_head = reverse_list(original_head)
print("Reversed:", to_list(reversed_head))
Output:
Original: [1, 2, 3, 4, 5]
Reversed: [5, 4, 3, 2, 1]
reverse_list walks the list once, and at each node it flips the direction of the next pointer to point backward instead of forward. It uses three variables: previous (the reversed portion built so far), current (the node being processed), and a temporary next_node that saves the rest of the original list before it gets overwritten. This is an O(n) time, O(1) extra space algorithm — no new nodes are allocated, only pointers are rewired.
How It Works Step by Step
Let’s trace reverse_list on the list 1 -> 2 -> 3 -> None in detail, since watching the three pointers move is the best way to internalize the algorithm.
| Step | current (before) | next_node saved | current.next set to | previous (after) | current (after) |
|---|---|---|---|---|---|
| Start | — | — | — | None | 1 |
| 1 | 1 | 2 | None (was previous) | 1 | 2 |
| 2 | 2 | 3 | 1 (was previous) | 2 | 3 |
| 3 | 3 | None | 2 (was previous) | 3 | None |
Once current becomes None, the loop condition current is not None is false, so the loop exits and the function returns previous, which is now node 3 — the new head. Following its next pointers gives 3 -> 2 -> 1 -> None, exactly the reversed list. The key insight is that next_node must be captured before current.next is reassigned, or the rest of the original chain would be lost — the next section shows exactly what happens when that step is skipped.
Common Mistakes
Mistake 1: Overwriting a pointer before saving what it pointed to
A very common bug when reversing (or otherwise rewiring) a linked list is forgetting to save current.next before overwriting it:
def reverse_list_broken(head):
previous = None
current = head
while current is not None:
current.next = previous
previous = current
current = current.next
return previous
This looks almost identical to the correct version, but the last line, current = current.next, reads current.next after it was just overwritten to point at previous on the line above. So current becomes whatever previous was, not the next node in the original list. For a list of length 1 this happens to work by accident, but for longer lists the traversal effectively collapses after one or two iterations, and the rest of the original list becomes unreachable garbage. The fix, shown in Example 3’s reverse_list, is to capture next_node = current.next into its own variable before touching current.next at all, so the rest of the list is never lost.
def reverse_list(head):
previous = None
current = head
while current is not None:
next_node = current.next # save the rest of the list first
current.next = previous
previous = current
current = next_node
return previous
Mistake 2: Forgetting to advance the traversal pointer
Another classic bug is forgetting to move current forward inside a while current is not None loop:
def contains_broken(head, target):
current = head
while current is not None:
if current.value == target:
return True
return False
If target is never found, current never changes, so current is not None stays true forever — an infinite loop instead of a clean False. This is easy to miss because the code runs fine (and returns the right answer) whenever the target happens to be near the head, which can make the bug slip past a quick test. The fix is to always advance the pointer on every iteration, even in the failure branch:
from __future__ import annotations
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next
def contains(head: Node | None, target: int) -> bool:
current = head
while current is not None:
if current.value == target:
return True
current = current.next
return False
node_c = Node(30)
node_b = Node(20, node_c)
node_a = Node(10, node_b)
print(contains(node_a, 20))
print(contains(node_a, 99))
Output:
True
False
Here the list is 10 -> 20 -> 30. Searching for 20 finds it on the second node and returns True immediately. Searching for 99 walks all three nodes, never matches, current becomes None after node 30, the loop ends, and the function correctly returns False instead of hanging.
Best Practices
| Operation | Python list (array-backed) |
Singly Linked List |
|---|---|---|
Index access x[i] |
O(1) | O(n) |
| Insert/delete at front | O(n) — must shift elements | O(1) |
| Insert/delete at end | O(1) amortized | O(1) with tail pointer, else O(n) |
- If you need frequent insertions or deletions at the front of a sequence, prefer a singly linked list (or
collections.deque, which supports O(1) append/pop from both ends) over a Pythonlist, which needs O(n) shifting to insert at index 0. - If you need random access by index, or you mostly append to the end and rarely touch the front, a Python
listis almost always the better and simpler choice — it has lower constant-factor overhead and cache-friendly contiguous memory. - Maintain a cached
tailpointer if your list needs frequent O(1) appends; otherwise every append degrades to an O(n) traversal, as seen in Example 1. - When deleting a node, always keep a
previousreference during traversal — a singly linked list cannot look backward, so withoutpreviousyou cannot repoint the node before the one you want to remove. - Never use a mutable default argument (like
def build(values, node=Node(0))) when writing helper functions that build or accumulate linked structures; the default is created once and shared across every call, silently corrupting later calls. UseNoneand initialize inside the function body instead. - For real-world production code, reach for
collections.dequeunless you specifically need custom per-node behavior (like O(1) insertion in the middle given a node reference) — it is implemented in C and outperforms a hand-rolled linked list for typical queue/stack use cases.
Practice Exercises
- Find the middle node. Write a function
find_middle(head)that returns the middle node of a singly linked list in a single pass (hint: use two pointers, one moving twice as fast as the other — when the fast pointer reaches the end, the slow pointer is at the middle). - Detect a cycle. Write
has_cycle(head)that returnsTrueif the list loops back on itself instead of ending inNone(hint: Floyd’s cycle detection uses a slow and a fast pointer — if they ever meet, there is a cycle). - Remove duplicates from an unsorted list. Write
remove_duplicates(head)that removes duplicate values from a linked list while preserving the order of first occurrence, using asetto track values already seen. For input1 -> 2 -> 1 -> 3 -> 2, the expected result is1 -> 2 -> 3.
Summary
- A singly linked list is a chain of
Nodeobjects, each holding a value and anextpointer; the list only tracks thehead. - Access and search are O(n) because there is no random access — every lookup requires traversal from the head.
- Insertion and deletion at the head are O(1); insertion/deletion elsewhere (or by value) is O(n) because finding the position requires traversal.
- Space is O(n) for
nnodes, with O(1) extra space for typical pointer-manipulation algorithms like reversal. - Reversing a list is a classic O(n) time, O(1) space algorithm built on three pointers:
previous,current, and a temporarynext_nodethat must be saved beforecurrent.nextis overwritten. - Common bugs include overwriting a pointer before saving it (losing the rest of the list) and forgetting to advance the traversal pointer (causing an infinite loop).
- Prefer a linked list (or
collections.deque) when front insertions/deletions dominate; prefer a Pythonlistwhen index access dominates.
