← DSA Fundamentals
Foundations

Linked Lists

Watch: "Introduction to Linked Lists (Data Structures & Algorithms #5)" — CS Dojo

A linked list is a chain of nodes where each node holds a value and a pointer to the next node — nothing more. There is no contiguous block of memory backing it, no fixed address you can jump to by doing arithmetic. That single design choice flips arrays' trade-off on its head: you lose O(1) random access, but once you're holding a node, inserting or deleting next to it costs O(1), because you're only rewriting a couple of pointers, not shifting an entire block of memory. Interviewers reach for linked lists constantly because the pointer-rewiring itself is where bugs live — dangling references, lost sublists, off-by-one hops — so a linked-list question tests whether you can reason precisely about state that changes one step at a time. The concept also underlies real systems: a browser's back/forward history, an LRU cache's eviction order, the internal structure of a hash table bucket using chaining, and — less obviously — the call stack itself is conceptually a linked list of frames.

Deep Dive: A Node Is Just Value + Pointer

Code, line by line
class Node:
    def __init__(self, val, nxt=None):
        self.val = val
        self.next = nxt

n3 = Node(30, None)
n2 = Node(20, n3)
n1 = Node(10, n2)
head = n1
  1. class Node is the whole data structure. It has two fields: val (the data) and next (a reference to another Node, or None).
  2. nxt=None is a default, so a node created without a successor is automatically a tail.
  3. We build back to front: n3 first, because n2 needs a reference to n3 at the moment it is created.
  4. Node(20, n3) stores the address of n3 in n2.next, not a copy of it. That stored address is the arrow in the diagram.
  5. head = n1 is the only handle the program keeps. Lose head and the whole list is unreachable.
One node
A single node: a value cell and a next-pointer cell10valnextone node = a value + the address of the next node
Three nodes linked: 10 → 20 → 30
Chain head to 10 to 20 to 30 to None102030Nonehead / n1n2n3 (tail)
Each dot is a stored address. Follow the arrows from head; the last node's next is None.

There is exactly one thing that makes a structure a linked list: a node does not know where the *next* node lives in memory in any predictable way — it only knows because a previous node explicitly told it, by storing that address. This is the opposite of an array, where element i's address is computable from the base address and i alone. The practical consequence is that you cannot ask "give me the 5th element" without starting at head and following .next five times — there is no shortcut, so indexed access is O(n).

What you get in exchange is that a node's neighbors are just references, and references are cheap to rewrite. If you already hold a reference to a node, splicing a new node next to it, or removing it, touches only that node and its immediate neighbors — a constant number of pointer writes regardless of how long the list is. That's the entire trade the structure is built around: pay per-access cost to gain near-free insert/delete once you're positioned.

Deep Dive: Insert and Delete — the Actual Pointer Rewiring

Insert at head
new_node = Node(5)
new_node.next = head
head = new_node
  1. Create the node. It is not in the list yet; nothing points to it.
  2. new_node.next = head: the new node points at the current first node (10). The blue arrow in the diagram.
  3. head = new_node: move the list's entry point to the new node. Doing step 3 before step 2 would make head point at a node whose next is None, and 10 → 20 → 30 would be lost.
Before
Before insert: head points to 10102030Noneold head
After new_node.next = head then head = new_node
After insert at head: head points to new node 5, which points to 105102030Nonehead
Only two pointer writes, whatever the length of the list, so it is O(1).
new node / new linkunchanged
Insert at position k
prev = head
for _ in range(k - 1):     # walk to index k-1
    prev = prev.next
new_node.next = prev.next  # 25 -> 30
prev.next = new_node       # 20 -> 25
  1. Start at head; there is no way to jump straight to index k.
  2. Hop k − 1 times. We stop one node before the insert point because that node owns the pointer we must change.
  3. new_node.next = prev.next: the new node first grabs the rest of the list (30 onward).
  4. prev.next = new_node: now 20 points to 25. Same rule as insert-at-head: link the new node forward first, then redirect the old pointer.
Insert 25 after 20: 20 points to 25, 25 points to 3010202530Noneprevnew
Walk to prev (index k−1), then splice. The walk is O(k); the splice is O(1).
prev (found by walking)new node / new links
Delete a node
prev = node_holding_10
prev.next = prev.next.next
  1. You need the node before the one being deleted, because prev.next is the pointer that has to change.
  2. prev.next.next is 30. Assigning it to prev.next makes 10 skip over 20. That one assignment is the whole delete.
Delete 20: 10's next now skips to 30102030Noneprevdeleted
prev.next = prev.next.next redirects 10 straight to 30. Nothing points to 20 any more, so it gets garbage-collected.
prevnew linkunreachable

Every one of these operations is "cheap" only in the pointer-rewiring step itself; the O(n) or O(k) cost, when it exists, comes entirely from the walk needed to *find* the right node, not from the rewiring. That distinction matters in interviews: insert-at-head is O(1) full stop, because you already hold head. Insert-at-position-k is O(k) because reaching the (k-1)th node requires k-1 hops — the rewiring at the end is still O(1). Compare this to an array insert at position k, which is O(n) even once you've *found* position k (trivially, in O(1), via indexing) — because inserting means shifting every element after k one slot to the right. The two structures spend their O(n) in different places: array insertion pays for the shift, linked-list insertion pays for the walk.

Deletion is the mirror image, and the classic trap is deleting the head or deleting when you only hold a reference to the node itself (not its predecessor). To delete a node you need the node *before* it, because that's the only place holding the pointer that must be rewritten — this is exactly why doubly linked lists exist (see below): they let you go backward without a separate walk.

Deep Dive: Reversing a Linked List — Why Three Pointers

Code, line by line
def reverse(head):
    prev = None
    curr = head
    while curr:
        nxt = curr.next    # save
        curr.next = prev   # flip
        prev = curr        # step prev
        curr = nxt         # step curr
    return prev
  1. prev = None: the old head will become the new tail, and a tail points to None, so None is what the first node should point back to.
  2. curr = head: the node whose link we are about to flip.
  3. while curr: keep going until we walk off the end of the original list.
  4. nxt = curr.next: save the rest of the list first. The next line overwrites curr.next, and this saved reference is then the only way to reach 30, 40, …
  5. curr.next = prev: flip the arrow so this node points backward.
  6. prev = curr: the reversed part now starts at curr.
  7. curr = nxt: move on to the saved remainder.
  8. return prev: when curr is None, prev sits on the old last node, which is the new head.
Start: prev = None, curr = head
Reverse, after step 0None10203040Noneprevcurr
After loop 1: 10 now points back to None
Reverse, after step 1None10203040Noneprevcurr
After loop 2: 20 → 10
Reverse, after step 2None10203040Noneprevcurr
After loop 3: 30 → 20
Reverse, after step 3None10203040Noneprevcurr
After loop 4: curr is None, loop ends, return prev (40)
Reverse, after step 4None10203040prevcurr = None
Curved arrows are links already flipped; straight arrows are still in their original direction.
prev (the newest flipped link)currother nodes

You need three pointers — prev, curr, and a temporary nxt — because reversing a link means overwriting curr.next, and once you overwrite it, the only way to reach the rest of the original list is through whatever you saved beforehand. If you update curr.next = prev before saving the original curr.next somewhere, you have permanently lost the pointer to the remainder of the list — everything after curr becomes unreachable garbage. That's the single most common bug on this problem: doing the rewire before the save. The order in the code above is non-negotiable: save, rewire, advance, advance — in exactly that sequence.

Two pointers are not enough because at every step you need to simultaneously know three things: where you're advancing from (curr), where you already reversed to (prev, to point curr backward at), and where you're advancing to next (nxt, saved before it's destroyed). Drop any one of the three and you either lose the rest of the list or lose the ability to link backward.

Deep Dive: Singly vs Doubly vs Circular

Singly: one pointer, forward only
Singly linked list102030None
Doubly: prev + next, walk both ways, O(1) delete given the node
Doubly linked list: each node has prev and nextNone102030Noneprevnext
Circular: the tail's next is the head, never None
Circular linked list: tail points back to head102030
Same three values, three different pointer layouts.

A doubly linked list's extra prev pointer is worth its memory cost exactly when you need to move backward or delete a node given only a reference to that node (not its predecessor) — e.g. an LRU cache, where you hold a hash map of key to node and must unlink that node in O(1) on every access. Without prev, "delete this node" secretly costs O(n), because you'd have to walk from head to find its predecessor first. The cost is real, though: every node carries a second pointer (meaningful memory overhead at scale), and every insert/delete must keep two pointers consistent instead of one — twice the chances of a rewiring bug.

Circular lists solve a different problem: "there is no natural end" scenarios like a round-robin CPU scheduler cycling through processes or a music player set to repeat. The termination condition changes from "next is None" to "next is back to where I started," which is itself a common interview trap if you forget to guard against an infinite loop.

Key Pointers

How It Works

  1. To search for a value: start at head, compare, follow .next, repeat until found or None — O(n) worst case, no shortcuts.
  2. To insert at head: point the new node's next at the current head, then move head to the new node — in that order, or you overwrite the only reference to the old list.
  3. To insert at tail: walk to the last node (O(n)) unless you maintain a tail pointer (O(1)), then set tail.next to the new node and move tail.
  4. To insert at position k: walk to the node at position k-1, splice the new node in between it and its current next.
  5. To delete a node: get a reference to its predecessor, then set predecessor.next = node.next, skipping the target node so nothing points to it anymore.
  6. To reverse: iterate with prev/curr/nxt as shown above, ending when curr is None; return prev as the new head.
  7. To detect a cycle: advance a slow pointer one step and a fast pointer two steps per iteration; if they ever point to the same node, there's a cycle, because the fast pointer closes the gap by one node every step and cannot skip past the slow pointer inside a loop.
  8. To find the cycle's *start* (Floyd's extension): after slow and fast meet, reset one pointer to head and advance both one step at a time — they meet again exactly at the cycle's entry node, a consequence of the distances working out from where the first meeting happens.
List with a cycle from node 5 back to node 312345cycle starts hereslow +1, fast +2
Once both pointers are inside the loop, fast gains exactly one node per step on slow, so the gap hits zero and they meet.

Try It Yourself

Building a list, inserting at a position, reversing it with the three-pointer technique, and detecting a cycle with Floyd's algorithm.

class Node:
    def __init__(self, val, nxt=None):
        self.val = val
        self.next = nxt

def to_list(head):
    out = []
    while head:
        out.append(head.val)
        head = head.next
    return out

def reverse(head):
    prev = None
    curr = head
    while curr:
        nxt = curr.next   # save before we overwrite curr.next
        curr.next = prev  # rewire
        prev = curr       # advance prev
        curr = nxt         # advance curr
    return prev

def insert_at_position(head, pos, val):
    if pos == 0:
        return Node(val, head)
    node = head
    for _ in range(pos - 1):
        node = node.next
    node.next = Node(val, node.next)
    return head

def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow is fast:
            return True
    return False

# build 10 -> 20 -> 30 -> 40
head = Node(10, Node(20, Node(30, Node(40))))
print("original          :", to_list(head))
head = insert_at_position(head, 2, 25)
print("after insert(2,25):", to_list(head))
head = reverse(head)
print("reversed          :", to_list(head))

# cycle test
a = Node(1); b = Node(2); c = Node(3)
a.next = b; b.next = c; c.next = a  # cycle back to a
print("has_cycle (cyclic):", has_cycle(a))

x = Node(1, Node(2, Node(3)))
print("has_cycle (acyclic):", has_cycle(x))

# Output:
# original          : [10, 20, 30, 40]
# after insert(2,25): [10, 20, 25, 30, 40]
# reversed          : [40, 30, 25, 20, 10]
# has_cycle (cyclic): True
# has_cycle (acyclic): False
  1. Node: the same two-field class as above.
  2. to_list(head): walks from head following .next until None, collecting values. It is only there so we can print the list; it is O(n).
  3. reverse(head): the save → flip → step → step loop from the diagram above.
  4. insert_at_position(head, pos, val): pos == 0 is special because the head itself changes, so we return the new node as the new head. Otherwise walk pos − 1 hops and splice with Node(val, node.next), which links the new node forward and redirects node.next in one line.
  5. has_cycle(head): Floyd's algorithm. while fast and fast.next guards both hops of the fast pointer; if fast reaches None there is no loop. slow is fast compares node identity, not values.
  6. The driver builds 10 → 20 → 30 → 40, inserts 25 at index 2, reverses, then tests one looped list (1 → 2 → 3 → back to 1) and one normal list. The printed output is at the bottom of the block.

Common Mistakes & Interview Traps

Interview Q&A

Q: Why is inserting at the head of a linked list O(1) but inserting at the head of an array O(n)?

A: A linked-list head-insert only rewrites two pointers: the new node's next and the list's head reference — a constant amount of work no matter how long the list is. An array has no spare slot at index 0; inserting there means shifting every one of the n existing elements one position to the right first, which is O(n) work by definition.

Q: Walk through reversing a singly linked list step by step.

A: Keep three pointers: prev (starts None), curr (starts at head), and a temporary nxt. Each iteration: save nxt = curr.next, set curr.next = prev to flip the link backward, then advance prev = curr and curr = nxt. Repeat until curr is None; prev is now the new head, and every link in the list points the opposite direction from before.

Q: Why do you need three pointers (not two) to reverse a list safely?

A: You need to know where you came from (prev, to point backward to), where you are (curr, being rewired), and where you're going (nxt) simultaneously. Two pointers can't hold all three pieces of information at once — the moment you overwrite curr.next you need the original value already saved somewhere, which is exactly what the third pointer is for.

Q: What breaks if you update next before saving it?

A: If you run curr.next = prev before capturing nxt = curr.next, the reference to the rest of the original list is gone — nothing in your program points to it anymore, so it becomes unreachable. The algorithm silently produces a reversed fragment of just the nodes visited so far instead of the full reversed list.

Q: Singly vs doubly linked list — what's the real trade-off?

A: A doubly linked list adds a prev pointer to every node, which costs extra memory per node and extra bookkeeping on every insert/delete (two pointers to keep consistent instead of one), but in exchange you get O(1) backward traversal and O(1) deletion given only a reference to the node itself — no need to walk from head to find its predecessor. It's worth it specifically when your access pattern needs backward movement or node-handle deletion, like an LRU cache; otherwise a singly linked list is strictly cheaper.

Q: How would you detect a cycle in a linked list, and why does Floyd's algorithm work?

A: Run two pointers from head, a slow one advancing one node per step and a fast one advancing two. If there's no cycle, the fast pointer simply reaches None first. If there is a cycle, both pointers are eventually confined inside the loop, and because the fast pointer gains exactly one node of distance on the slow pointer every step, that gap must eventually shrink to zero — they collide inside the loop. It runs in O(n) time and O(1) space, unlike a hash-set approach which is O(n) time but O(n) space.

Follow-up: Once you know there's a cycle, how do you find where it starts?

A: After slow and fast meet inside the cycle, reset one pointer to head and leave the other at the meeting point, then advance both one step at a time. They meet again exactly at the cycle's entry node — this falls out of the distance math (the distance from head to the entry equals the distance from the meeting point to the entry, going around the loop).

Follow-up: Could you detect a cycle without Floyd's algorithm?

A: Yes — walk the list and put each visited node's identity (not value, since values can repeat) into a hash set, checking membership before each insert; a repeat means a cycle. That's O(n) time like Floyd's, but O(n) extra space instead of O(1), which is the trade-off interviewers are usually checking you can articulate.

Follow-up: Why can't you use node *values* to detect a cycle instead of node identity?

A: Distinct nodes can legitimately hold the same value (a list of [5, 3, 5, 3] is perfectly acyclic), so matching on value would produce false positives. You must track the actual object/memory identity of each node, not its data.

Trade-offs

A linked list is the wrong choice whenever your workload is dominated by random access or by iterating repeatedly with cache-friendly locality — an array (or dynamic array) wins there because contiguous memory means the CPU can prefetch ahead, while a linked list scatters nodes across the heap and forces a pointer chase on every hop, which is slow in practice even though both are "O(n) to traverse" on paper. A linked list also costs extra memory per element for the pointer(s) themselves, which matters when storing many small values. It earns its place specifically when you need frequent insertion/deletion at known positions (especially the front, or given a node handle in a doubly linked list) and don't need random access — the internal chaining of a hash table bucket, an LRU cache's eviction list, or an undo history that only ever removes from one end are all good fits. When in doubt for general-purpose storage, default to a dynamic array; reach for a linked list only when its specific insert/delete pattern is the actual bottleneck.

PracticeSolve related problems in the 150 →