Lecture 9 - Linked Lists: Our First Dynamic Data Structure¶
A linked list is the first data structure we build entirely ourselves out of heap nodes, and it is the foundation for the hash tables coming Wednesday. But first: why do we need it at all? We already have arrays. The answer is that arrays are bad at exactly two things, and a linked list is good at both.
1. Recap and the two problems arrays cannot solve¶
- An array is a contiguous block of elements. That contiguity buys us one great
thing - random access:
a[i]is instant, because elementisits a fixed distance from the start. But it costs us on the two operations we will care about today.
Problem one: deleting (or inserting) in the middle means shifting. To remove
a[k], every later element has to slide down one slot to keep the block
contiguous:
/* delete the element at index k from a[0..n-1]; returns the new length */
int delete_at(int a[], int n, int k) {
for (int i = k; i < n - 1; i++) {
a[i] = a[i + 1]; /* slide everything after k down by one */
}
return n - 1; /* one fewer element */
}
- Removing a single element touches every element after it - that is O(n) work just to delete one value. Inserting in the middle is the same story in reverse: everything from the insertion point on must slide up to open a gap.
- Board aside: "but my elements are huge, so I will store an array of pointers instead, and shifting only moves 8-byte pointers." True - that makes each move cheaper, but you still perform O(n) of them. The cost is not copying the payload; it is the requirement to keep the elements contiguous and in order. As long as we insist on one packed block, a middle insert or delete is O(n).
Problem two: an array has a fixed capacity. You choose the size up front. When it fills, the only way to grow is to allocate a bigger block and copy everything over (this is exactly what a dynamic array does under the hood - recall Lecture 6). Growing by one element can cost O(n).
- So we want a structure where insert and delete cost only a little work and which grows one element at a time with no big recopy. The price we will pay is giving up random access - and, as we will see, that turns out to be a fine trade for a whole class of problems.
In-class demo: run
delete_aton a small array and watch the shifting. Feel that every later element moved. That motion is the thing we are about to eliminate.
2. The self-referential node¶
The idea: stop storing the elements next to each other. Instead, give each element its own little heap struct - a node - that holds its value and the address of the next node. The nodes can live anywhere on the heap; each one knows where the next one is.
struct node {
int data; /* the value this node holds */
struct node *next; /* address of the next node, or NULL at the end */
};
typedef struct node node_t;
- A struct with a pointer member of its own type is called self-referential. This is the shape Lecture 8 promised.
- The struct tag
nodeis required here. Notice we wrotestruct nodetwice - once to open the definition and once for the type ofnext. We could not writenode_t *next;inside the braces, because at that pointnode_tdoes not exist yet (thetypedefon the next line is what creates it). So a self-referential struct must keep its tag. This is the one place our tidy taglesstypedef struct { ... } T;idiom from Lectures 7 and 8 does not work. next == NULLmarks the end of the list. The very last node points at nothing. This is the same "walk until you hit the terminator" idea as the'\0'at the end of a string.- A single pointer names the whole list: the
head.node_t *headholds the address of the first node; from there you can reach every other node by followingnext. An empty list is simplyhead == NULL.
Picture it on the board - three nodes holding 10, 20, 30:
Each box is a heap node: the left half is data, the right half is next. The
arrows are the next pointers. The last node's next is NULL.
3. Building a list by hand, and traversing it¶
Before we automate anything, let us build that three-node list by hand so the
mechanics are concrete. Every node is a separate malloc.
#include <stdlib.h>
#include <stdio.h>
node_t *a = malloc(sizeof(node_t)); /* one node */
node_t *b = malloc(sizeof(node_t));
node_t *c = malloc(sizeof(node_t));
a->data = 10; a->next = b; /* link a -> b */
b->data = 20; b->next = c; /* link b -> c */
c->data = 30; c->next = NULL; /* c is the last node */
node_t *head = a; /* the list is named by its head */
sizeof(node_t)is the size of one node (anintplus a pointer), not the size of the whole list - a list has no single size; it is as many separate blocks as it has nodes.- We reach members through a pointer with
->, exactly as in Lecture 8:a->next = bwritesb's address intoa'snextfield.
To visit every element you walk the next pointers until you reach NULL.
This loop is the single most important idiom in the whole lecture; every operation
below is a variation on it.
void print_list(node_t *head) {
for (node_t *cur = head; cur != NULL; cur = cur->next) {
printf("%d -> ", cur->data);
}
printf("NULL\n"); /* 10 -> 20 -> 30 -> NULL */
}
curstarts atheadand advances withcur = cur->nexteach pass. The loop ends whencurbecomesNULL, which is precisely the last node'snext. An empty list (head == NULL) prints justNULL- the loop body never runs.- Notice what we gave up: there is no
cur[i]. To reach the third node you must walk through the first two. That is the cost of losing contiguity - access is now O(n). In exchange, the next two sections get cheap insert and delete.
In-class exercise: Part B, Exercise B1 (on the computer) - write
print_listand alengthfunction that counts the nodes by walking toNULL.
4. Inserting at the head: push_front¶
The cheapest place to add a node is the front. It takes three steps and touches nothing else in the list, no matter how long the list is - this is the O(1) insert that arrays could not give us.
node_t *push_front(node_t *head, int value) {
node_t *n = malloc(sizeof(node_t));
if (n == NULL) {
return head; /* out of memory: leave the list unchanged */
}
n->data = value;
n->next = head; /* the new node points at the old first node */
return n; /* the new node is the new head */
}
Draw it: to push 5 onto 10 -> 20 -> 30, the new node's next is aimed at the
old head (10), and then the new node becomes the head:
- Why does
push_frontreturn the head? Because it changes which node is first, and the caller'sheadvariable is just a local pointer - passed by value, a function cannot change the caller'sheadany more thanbumpcould change the caller's struct in Lecture 7. So we hand the new head back and the caller reassigns:
node_t *head = NULL; /* start empty */
head = push_front(head, 30); /* 30 */
head = push_front(head, 20); /* 20 -> 30 */
head = push_front(head, 10); /* 10 -> 20 -> 30 */
- Returning the head is the same safe move as returning a struct by value in Lecture
7: we hand back a value (an address) and let the caller store it. Forgetting the
head =is the classic bug - the new node is created, linked, and then leaked because nothing points at it. - Board aside (do not build with it yet): the other idiom is to pass the address of
the head pointer,
void push_front(node_t **head, int value), and write*head = n;inside. That is a pointer to a pointer; it works, but returning the new head is easier to read while linked lists are new. We will stick with the return-the-head style.
In-class exercise: Part B, Exercise B2 (on the computer) - write
push_frontreturning the new head, then build a list by reading numbers in a loop.
5. Deleting a node: pointer surgery¶
Deletion is where the linked list earns its keep. To remove a node we do not
shift anything - we just make the previous node's next skip over it, then free the
removed node. This is O(1) work once we have found the node (plus the walk to find
it).
The trick is that to unlink a node you need a handle on the node before it, so
you can redirect that predecessor's next. So we walk with two ideas in play:
the current node, and the one before it.
node_t *delete_value(node_t *head, int value) {
node_t *prev = NULL;
node_t *cur = head;
while (cur != NULL && cur->data != value) {
prev = cur; /* remember where we were ... */
cur = cur->next; /* ... then step forward */
}
if (cur == NULL) {
return head; /* value not found: list unchanged */
}
if (prev == NULL) {
head = cur->next; /* deleting the first node: new head */
} else {
prev->next = cur->next; /* splice cur out: prev skips over it */
}
free(cur); /* the node is unlinked; now free it */
return head;
}
The splice, on the board - deleting 20 from 10 -> 20 -> 30:
before: [10 | ─]─▶ [20 | ─]─▶ [30 | NULL]
▲cur
▲prev
after: [10 | ─]────────────▶ [30 | NULL] free(20)
prev->next = cur->nextis the whole idea: the node before the victim now points at the node after the victim, so nothing in the list refers tocurany more. Then, and only then,free(cur).- Order matters: unlink first, free last. If you
free(cur)before readingcur->next, you are reading freed memory. - Two edge cases, both handled by the
prev == NULLcheck. If the value is in the first node, there is no predecessor to relink, so the new head iscur->next. If the value is not in the list at all, the loop runs off the end (cur == NULL) and we return the list unchanged. Likepush_front, this function returns the head, because deleting the first node changes it. - Compare this with Section 1: no element moved, nothing was copied down a slot. We changed exactly one pointer and freed one block. That is why we gave up random access.
In-class exercise: Part B, Exercise B3 (on the computer) - write
delete_value, and test it on the head, a middle node, the last node, and a missing value.
6. Freeing the whole list¶
Every node came from its own malloc, so - by the rule that has held since Lecture
6 - every node needs its own free. We walk the list and free each node. There is
one trap, and it is the classic one:
void free_list(node_t *head) {
node_t *cur = head;
while (cur != NULL) {
node_t *next = cur->next; /* SAVE the next pointer first ... */
free(cur); /* ... then it is safe to free cur */
cur = next; /* advance to the saved next */
}
}
- You must read
cur->nextbefore youfree(cur). The tempting version -free(cur); cur = cur->next;- frees the node and then dereferences that same freed node to find the next one. That is a use-after-free: the memory is gone, and readingcur->nextfrom it is undefined behaviour. Savingnextinto a local first is the fix. - This is the ownership rule from Lectures 6 to 8 coming home: the list owns its
nodes, so the list needs a function whose job is to free every one of them. One
freepermalloc, node by node. Run it undervalgrindand you should see zero leaks and zero invalid frees. - After
free_list(head), the caller'sheadstill holds the old (now dangling) address, so the caller should sethead = NULLafterward - just as we nulled a freed pointer in the heap lecture.
In-class exercise: Part B, Exercise B4 (on the computer) - write
free_list, confirm it is valgrind-clean, and sethead = NULLin the caller.
7. Wrap-up¶
- A linked list is a chain of heap nodes; each node holds a value and a
nextpointer to the following node. A self-referential struct (struct node { int data; struct node *next; };) is the node type - and it must keep its tag, because thetypedefname does not exist yet inside the definition. - The list is named by a single
headpointer;NULLmarks the end; an empty list ishead == NULL. You traverse by walkingcur = cur->nextuntilcurisNULL- the one idiom every operation reuses. Access is O(n): the price of giving up contiguity. - Insert at the head is O(1) pointer work (
n->next = head; head = n;); delete is O(1) once found (prev->next = cur->next; free(cur);). Neither shifts a single element - the exact thing arrays could not do. Both return the new head, because both can change which node is first. - Free the list by walking it, always saving
nextbeforefreeing the current node, or you use freed memory. Every node'smallocgets itsfree. - Cliffhanger: a linked list is slow to search - you must walk it. Wednesday we fix that with a hash table: an array of linked lists, where a hash function sends each key to one short list (a "bucket"). Arrays for the fast jump, linked lists to absorb collisions - the two structures, working together.