Why this matters
A dynamic array keeps elements side by side, so inserting in the middle means shifting everything after it, and growing may move the whole block. A linked list makes the opposite trade: each element lives in its own small heap allocation and points to the next one. Inserting or removing anywhere you already have a pointer is two pointer assignments, and elements never move. Linked lists appear throughout systems code: the Linux kernel's process list, free lists inside malloc, hash table buckets (next-but-one lesson), and LRU caches. More importantly for you, they are the best exercise there is in pointer manipulation. If you can write list code correctly, pointers no longer scare you.
Nodes and the head pointer
A list is built from nodes. Each node holds a value and a pointer to the next node; the last node's pointer is NULL:
typedef struct Node {
int value;
struct Node *next;
} Node;
The struct refers to itself: next is a pointer to another struct Node. Inside the definition the typedef name Node does not exist yet, which is why the member is written struct Node *. A struct cannot contain itself (it would be infinitely large), but it can contain a pointer to its own type, which is just 8 bytes.
The list as a whole is represented by a single pointer to the first node, the head. An empty list is a head of NULL.
head
+---+ +-------+---+ +-------+---+ +-------+------+
| o-+---> | 40 | o-+---> | 30 | o-+---> | 20 | NULL |
+---+ +-------+---+ +-------+---+ +-------+------+
(stack) node (heap) node (heap) node (heap)
The nodes are separate malloc blocks, anywhere in the heap. The only thing connecting them is the next pointers.
Building, walking and freeing
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int value;
struct Node *next;
} Node;
/* Add a node at the front. Returns the new head, or NULL if out of memory. */
Node *push_front(Node *head, int value)
{
Node *n = malloc(sizeof *n);
if (n == NULL)
return NULL;
n->value = value;
n->next = head;
return n;
}
void print_list(const Node *head)
{
for (const Node *p = head; p != NULL; p = p->next)
printf("%d -> ", p->value);
printf("NULL\n");
}
void free_list(Node *head)
{
while (head != NULL) {
Node *next = head->next; /* save it before freeing */
free(head);
head = next;
}
}
push_front is O(1): the new node points at the old head and becomes the new head. Draw it:
before: head -> [30] -> [20] -> NULL
n = malloc, n->value = 40, n->next = head:
n -> [40] -> [30] -> [20] -> NULL
return n; caller stores it in head
Traversal is the loop for (p = head; p != NULL; p = p->next), which you will write constantly. Read it as "start at the head, stop at the end, step along the links".
free_list must save head->next before freeing head, because after free the node's contents may not be read. Every node was a separate malloc, so every node needs its own free.
Changing the head: pointers to pointers
push_front returns the new head, and the caller must remember to store it. Functions that may change the head (removing the first node, appending to an empty list) can instead take the address of the caller's head pointer, a Node **:
/* Remove the first node holding value. Returns 1 if one was removed. */
int remove_value(Node **headp, int value)
{
for (Node **pp = headp; *pp != NULL; pp = &(*pp)->next) {
if ((*pp)->value == value) {
Node *victim = *pp;
*pp = victim->next; /* unlink: whoever pointed at victim now skips it */
free(victim);
return 1;
}
}
return 0;
}
This is worth slowing down for. pp does not point at nodes; it points at the pointer that points at the current node. At first that is the caller's head variable itself; after one step it is the first node's next field; and so on.
pp starts at &head:
head node A node B node C
+---+ +----+---+ +----+---+ +----+------+
| o-+--------> | 40 | o-+-----> | 30 | o-+-----> | 20 | NULL |
+---+ +----+---+ +----+---+ +----+------+
^ ^
pp (first) pp (after one step) = &A->next
removing node B when pp == &A->next:
victim = *pp (B)
*pp = victim->next (A->next now points at C)
free(victim)
Because removing the head is just "the pointer that points at the head is head itself", the same three lines handle the first node, a middle node and the last node. No special case. Linus Torvalds has called this the mark of someone who understands pointers.
Using everything together:
int main(void)
{
Node *head = NULL;
for (int i = 1; i <= 4; i++) {
Node *n = push_front(head, i * 10);
if (n == NULL) {
free_list(head);
return 1;
}
head = n;
}
print_list(head);
remove_value(&head, 40); /* the head */
remove_value(&head, 20); /* the tail */
print_list(head);
free_list(head);
return 0;
}
40 -> 30 -> 20 -> 10 -> NULL
30 -> 10 -> NULL
valgrind reports no errors and no leaks.
Variations
- Tail pointer. Appending to a list with only a head is O(n): you must walk to the end. Keep a second pointer to the last node (often in a small
Liststruct withhead,tailandlen) to make appends O(1). That is how queues are built in the next lesson. - Doubly linked list. Each node also has
prev. Removal of a node you hold becomes O(1) without needing its predecessor, at the cost of another pointer per node and more links to keep consistent. - Intrusive lists. The Linux kernel embeds a
struct list_head { next, prev }inside its own structs instead of allocating separate nodes, so one object can sit in several lists without extra allocations.
Common mistakes
- Using a node after freeing it, most often in the free loop itself. valgrind:
Invalid read of size 8 ... Address ... is 8 bytes inside a block of size 16 free'd. - Losing the rest of the list. Overwriting
head->nextbefore saving it orphans every node after it: a leak valgrind reports as "indirectly lost". - Forgetting to update the caller's head.
push_front(head, 5);withouthead = ...leaks the new node and the list is unchanged. PassingNode **avoids the problem. - Not handling the empty list. Code that does
head->nextwithout checkinghead != NULLcrashes with a segmentation fault on empty input. - Uninitialized
next. A node frommallochas a garbagenext; always set it, usually toNULLor the old head.
Recap
- A node holds a value and a
nextpointer; the list is a head pointer,NULLwhen empty. - Traverse with
for (p = head; p != NULL; p = p->next). - Free node by node, saving
nextbefore eachfree. - Functions that may change the head take
Node **; walking aNode **pphandles every position uniformly. - Lists give O(1) splicing and stable node addresses; arrays give O(1) indexing and cache-friendly traversal.
Going further: implement a doubly linked list with insert_after and remove_node, and test it under valgrind. Then look up Floyd's "tortoise and hare" algorithm for detecting a cycle in a list using two pointers moving at different speeds.