Journey into Data Structures · Part 1 of 1
Linked Lists
Required knowledge
- Arrays
- Pointers
What are Linked Lists?
Linked Lists are a data structure that feel similar to a normal array. You can
loop from start to end. In a Doubly Linked List, you could even loop in reverse
order. They are 0(n) for a complete traversal. Instead of using pointer math to
find the next item, it uses references to other items in the data structure. So
each item (or Node) has a next and/or prev reference for the next/prev nodes
in the list and traversal is essentially just following the breadcrumb trails
each Node provides.
Why do Linked Lists have a bad reputation?
It all comes down to speed and performance. They just do not compete with arrays and other data structures. We’ll dive deeper into why later (hint: it has to do with caching). For now lets look at how to build and use them. Later we’ll touch more on why we should avoid them but the techniques you’ll learn building and using Linked Lists will come in handy for future data structures.
Key Technical Advantages
Fast Insertions and Deletions:
Adding or removing a node requires only updating pointer references. This takes O(1) time if you already have a reference to that position.
Cheap List Splitting and Merging:
You can split one list into two, or glue two lists together, in O(1) constant time. This is heavily utilized in complex systems like the Linux kernel and Redis.
Memory Fragmentation Resistance:
They can utilize scattered, non-contiguous pockets of free memory. This makes them highly effective in low-level systems or embedded environments.
Linked List demonstration
Before we jump into the code, here is fun little visual illustration on how linked lists work.
Add or remove a node to see the pointers change.
- head → 1, tail → 3, size = 3
- node 1: prev → NULL, next → 2
- node 2: prev → 1, next → 3
- node 3: prev → 2, next → NULL
The Code
The structs are pretty simple. Each node will track what is next and previous of
its location in the list so we can traverse forward and backwards. I also added
an enum LinkedListError for specific errors that could occur that I want to
report to the caller.
linked-list.h
typedef enum {
LINKED_LIST_OK,
LINKED_LIST_ERROR_NO_MEMORY,
LINKED_LIST_ERROR_OUT_OF_RANGE,
LINKED_LIST_ERROR_NULL_ARG,
LINKED_LIST_ERROR_NOT_FOUND,
} LinkedListError;
typedef struct LLNode {
void *item;
struct LLNode *next; // for forward traversal
struct LLNode *prev; // for reverse traversal
} LLNode;
typedef struct LinkedList {
LLNode *head; // for the first item in the list
LLNode *tail; // for the last item in the list
int size; // total number of nodes
} LinkedList;
Lets declare some functions in our header file.
linked-list.h
LinkedList *ll_create(void);
void ll_destroy(LinkedList *list);
LinkedListError ll_prepend(LinkedList *list, void *item);
LinkedListError ll_append(LinkedList *list, void *item);
LinkedListError ll_remove_front(LinkedList *list, void **out_item);
LinkedListError ll_remove_back(LinkedList *list, void **out_item);
LinkedListError ll_remove_at(LinkedList *list, int index, void **out_item);
LinkedListError ll_get(LinkedList *list, int index, void **out_item);
Creating a list is pretty simple. We are creating memory for our list and initializing all bits to zero, then returning the pointer to that list.
linked-list.c
LinkedList *ll_create(void) {
LinkedList *list = calloc(1, sizeof *list);
return list;
}
The next thing we want to do is add a node item to our list. I have 3 functions for this:
node_create(static)ll_prependll_append
node_create just creates the node struct with no references to the next or
previous node. We’ll let ll_prepend and ll_append manage that part.
linked-list.c
static LLNode *node_create(void *item) {
LLNode *node = malloc(sizeof *node);
if (node == NULL) {
return NULL;
}
node->item = item;
node->next = NULL;
node->prev = NULL;
return node;
}
For appending an item to the list, we need to reference the lists head and tail
references. By default head and tail should be NULL for an empty list. If
tail is NULL we can assume the list is empty because there should always be
a tail if one or more items exist in list. If thats the case, just set the
head and tail pointers to the node you are appending. Since its the first
item in the list, that item will be both the start and end of the list.
If there is a tail then we know the list is not empty and since we are
appending to the list, we need to update the references at the tail end. One
thing to keep in mind for how we update the list is one node references another
going in one direction with its next pointer. If we want to go in the opposite
direction, we need to update a prev pointer as well. Since this list is going
to support both forward and backward traversal we need to update both
directions. So the update goes in this order:
- Current
tail’snextreference, which should beNULL, will point to our new node - Our new node will have its
prevpoint to the currenttailnode. - Update the
tailto be our new node - …and of course, increase the
sizeto keep track of how many items are in the list
linked-list.c
LinkedListError ll_append(LinkedList *list, void *item) {
if (list == NULL) {
return LINKED_LIST_ERROR_NULL_ARG;
}
LLNode *node = node_create(item);
if (node == NULL) {
return LINKED_LIST_ERROR_NO_MEMORY;
}
if (list->tail == NULL) {
list->head = node;
list->tail = node;
} else {
node->prev = list->tail;
list->tail->next = node;
list->tail = node;
}
list->size++;
return LINKED_LIST_OK;
}
For adding at the beginning of the list, its a similar concept as appending:
- Current
head’sprevreference, which should beNULL, will now point to our new node - Our new node’s
nextreference will point to the currenthead - The
headwill now point to our new node - increase the
size
linked-list.c
LinkedListError ll_prepend(LinkedList *list, void *item) {
if (list == NULL) {
return LINKED_LIST_ERROR_NULL_ARG;
}
LLNode *node = node_create(item);
if (node == NULL) {
return LINKED_LIST_ERROR_NO_MEMORY;
}
node->next = list->head;
if (list->head != NULL) {
list->head->prev = node;
} else {
list->tail = node;
}
list->head = node;
list->size++;
return LINKED_LIST_OK;
}
Ok, so thats appending and prepending. So, what if we need to get a node in
the list at a specific index? Lets think about that for a second. If its the
first or last index, thats easy. Just return the head or the tail. No need
to add a fancy function for that scenario. Getting anything in the middle is not
so simple. Well it is, but its not exactly as performant as an array. You will
have to traverse the array until you get to the index you are looking for.
YIKES! Some of the warts of a linked list are starting to show. Lets add a naive
implementation for getting a node by its index. I’m using a static helper called
node_get that hands back the node itself instead of the item. That will pay
off in a minute when we need to remove from the middle of the list:
linked-list.c
static LinkedListError node_get(LinkedList *list, int index, LLNode **out_node) {
if (index < 0 || index >= list->size) {
return LINKED_LIST_ERROR_OUT_OF_RANGE;
}
LLNode *current = list->head;
for (int i = 0; i < index; i++) {
current = current->next;
}
*out_node = current;
return LINKED_LIST_OK;
}
As you can see, getting an item by its index is pretty costly in a linked list.
It is O(n) whereas an Array is O(1). A pretty simple loop so far but we can
optimize this a bit better. Since we can traverse forwards and backwards, we can
determine if the index falls in the first half of the size or the second half.
So if a list has 10 items, and you need the third item. It would be faster to
start from the head position. However, if you were to try get the seventh
item, you may want to start from the tail and work your way backwards.
Lets update our code to do this a little better:
linked-list.c
static LinkedListError node_get(LinkedList *list, int index, LLNode **out_node) {
if (index < 0 || index >= list->size) {
return LINKED_LIST_ERROR_OUT_OF_RANGE;
}
LLNode *current;
if (index < list->size / 2) {
current = list->head;
for (int i = 0; i < index; i++) {
current = current->next;
}
} else {
current = list->tail;
for (int i = list->size - 1; i > index; i--) {
current = current->prev;
}
}
*out_node = current;
return LINKED_LIST_OK;
}
There, much better. Still slow, but less slow now.
The public ll_get is just a thin wrapper around node_get. Notice the item
comes back through an out_item parameter instead of the return value. The
return value is reserved for the LinkedListError. Why? Because NULL is a
perfectly valid item to store in a list. If ll_get returned the item directly,
you could never tell the difference between “the item is NULL” and “that index
doesn’t exist”.
linked-list.c
LinkedListError ll_get(LinkedList *list, int index, void **out_item) {
if (list == NULL || out_item == NULL) {
return LINKED_LIST_ERROR_NULL_ARG;
}
LLNode *node = NULL;
LinkedListError err = node_get(list, index, &node);
if (err != LINKED_LIST_OK) {
return err;
}
*out_item = node->item;
return LINKED_LIST_OK;
}
Next, we need to be able to remove items from our list. We have 3 functions we can start to implement now:
ll_remove_frontll_remove_backll_remove_at
Removing an item from the front or back is pretty simple. Some things to think about when removing anything in a linked list:
- Whether its from the front/back, the
head/tailwill need to be updated - You need to decrease the
sizeafter removal - Its good practice to return the
itemin case you need a reference to it since we are removing the reference in the linked list. If we didn’t, then we most likely could be introducing a memory leak. We are going to leave the decision to free the memory for theitemon the caller so returning the item is crucial for this step.
There is one more rule about ownership worth stating clearly: the list owns the
nodes, the caller owns the items. Removing something from the list frees
the node but never the item. That’s why every remove function takes a
void **out_item. The removed item gets written to *out_item so you can
decide what to do with it (use it, free it, put it in another list). If you
don’t care about it, pass NULL and the item is simply discarded. If something
goes wrong, *out_item is left untouched.
Here is the interesting part. Removing from the front, the back, or the middle
are all the same operation: take a node, and stitch its neighbours together so
nothing points at it anymore. So instead of writing that three times, we write
it once in a static helper called node_unlink.
There are 4 things it needs to handle:
- If the node has no
prev, it was thehead, so theheadbecomes the node’snext. Otherwise, the previous node’snextskips over our node. - If the node has no
next, it was thetail, so thetailbecomes the node’sprev. Otherwise, the next node’sprevskips over our node. - Hand the
itemto the caller (if they asked for it) - Free the node and decrease the
size
linked-list.c
static void node_unlink(LinkedList *list, LLNode *node, void **out_item) {
if (node->prev == NULL) {
list->head = node->next;
} else {
node->prev->next = node->next;
}
if (node->next == NULL) {
list->tail = node->prev;
} else {
node->next->prev = node->prev;
}
if (out_item != NULL) {
*out_item = node->item;
}
free(node);
list->size--;
}
Notice how this handles the edge cases for free. Removing the only node in the
list? It has no prev and no next, so both head and tail become NULL,
which is exactly what an empty list looks like. No special case needed.
With node_unlink doing the heavy lifting, ll_remove_front and
ll_remove_back are just a couple of checks. A NULL list is a
LINKED_LIST_ERROR_NULL_ARG and an empty list has nothing to remove, so we
report LINKED_LIST_ERROR_NOT_FOUND.
linked-list.c
LinkedListError ll_remove_front(LinkedList *list, void **out_item) {
if (list == NULL) {
return LINKED_LIST_ERROR_NULL_ARG;
}
if (list->head == NULL) {
return LINKED_LIST_ERROR_NOT_FOUND;
}
node_unlink(list, list->head, out_item);
return LINKED_LIST_OK;
}
LinkedListError ll_remove_back(LinkedList *list, void **out_item) {
if (list == NULL) {
return LINKED_LIST_ERROR_NULL_ARG;
}
if (list->tail == NULL) {
return LINKED_LIST_ERROR_NOT_FOUND;
}
node_unlink(list, list->tail, out_item);
return LINKED_LIST_OK;
}
Now ll_remove_at pays off our earlier decision to split out node_get. Find
the node, then unlink it. Two different error codes are possible here: an empty
list is NOT_FOUND (there is nothing to find), while a bad index on a list that
has items is OUT_OF_RANGE.
linked-list.c
LinkedListError ll_remove_at(LinkedList *list, int index, void **out_item) {
if (list == NULL) {
return LINKED_LIST_ERROR_NULL_ARG;
}
if (list->head == NULL) {
return LINKED_LIST_ERROR_NOT_FOUND;
}
LLNode *node = NULL;
LinkedListError err = node_get(list, index, &node);
if (err != LINKED_LIST_OK) {
return err;
}
node_unlink(list, node, out_item);
return LINKED_LIST_OK;
}
The last function is ll_destroy. Since we allocated every node on the heap, we
need to give them back. Walk the list from the head and free each node. The
important detail: grab the next pointer before freeing the current node,
otherwise you’ve just freed the very thing you needed to find the next node.
Finally free the list itself. Remember, this only frees the nodes and not the
items. If your items were allocated on the heap, pop them off with the remove
functions and free them first.
linked-list.c
void ll_destroy(LinkedList *list) {
if (list == NULL) {
return;
}
LLNode *current = list->head;
while (current != NULL) {
LLNode *next = current->next;
free(current);
current = next;
}
free(list);
}
Testing it
Linked lists are easy to get subtly wrong. A single prev pointer that doesn’t
get updated will go unnoticed until you traverse backwards. So the tests I wrote
lean on one helper that checks everything at once. assert_list_matches walks
the list forward and backward, comparing each item against what we expect,
and verifies that every next/prev pair agrees with each other. It also
checks that an empty list has a NULL head and tail.
linked-list.test.c
static void assert_list_matches(LinkedList *list, int **expected, int n) {
assert(list->size == n);
if (n == 0) {
assert(list->head == NULL);
assert(list->tail == NULL);
return;
}
assert(list->head->prev == NULL);
assert(list->tail->next == NULL);
int i = 0;
for (LLNode *node = list->head; node != NULL; node = node->next, i++) {
assert(i < n);
assert(node->item == expected[i]);
if (node->next != NULL) {
assert(node->next->prev == node);
}
}
assert(i == n);
i = n - 1;
for (LLNode *node = list->tail; node != NULL; node = node->prev, i--) {
assert(i >= 0);
assert(node->item == expected[i]);
}
assert(i == -1);
}
With that in place, each test is short. Build a list, do something to it, and assert the result:
linked-list.test.c
static void test_ll_mixed_prepend_and_append(void) {
LinkedList *list = ll_create();
int a = 1, b = 2, c = 3, d = 4;
ll_append(list, &b);
ll_prepend(list, &a);
ll_append(list, &c);
ll_prepend(list, &d);
int *expected[] = {&d, &a, &b, &c};
assert_list_matches(list, expected, 4);
ll_destroy(list);
}
static void test_ll_remove_at_middle(void) {
int items[5];
LinkedList *list = make_list(items, 5);
void *out = NULL;
assert(ll_remove_at(list, 2, &out) == LINKED_LIST_OK);
assert(out == &items[2]);
int *expected[] = {&items[0], &items[1], &items[3], &items[4]};
assert_list_matches(list, expected, 4);
ll_destroy(list);
}
A few of the edge cases I made sure to cover, since this is where linked list bugs love to hide:
- Removing the only element, which must leave both
headandtailNULL - Removing the head and tail through
ll_remove_at, not just the dedicated functions - Tail-side traversal, picking an index in the second half of the list so
node_getwalks backwards - Errors leave things alone: a bad index must not modify the list or touch
*out_item - A stored
NULLis a valid item, andll_getcan tell it apart from an error - The caller owns the item: after a remove, a heap allocated item is still valid and can be freed by the caller
- Reusing a drained list, since stale
head/tailpointers would show up here
Wrapping up
That’s a complete doubly linked list. The pointer wiring is the whole trick:
every insertion or removal is a handful of pointer updates, and the only
expensive operation is finding a node by index. That is a cost of walking the
breadcrumb trail, which is why ll_get and ll_remove_at are O(n) while the
front and back operations are O(1).
The habits from this one carry over to nearly every pointer-based structure we will build later: update pointers in the right order, handle the empty and single-element cases, and be explicit about who owns the memory.
So if a linked list is this straightforward, why do people steer clear of them? Next up, we’ll compare them against arrays and see exactly where the time goes.