Skip to content

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 element i sits 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_at on 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 node is required here. Notice we wrote struct node twice - once to open the definition and once for the type of next. We could not write node_t *next; inside the braces, because at that point node_t does not exist yet (the typedef on the next line is what creates it). So a self-referential struct must keep its tag. This is the one place our tidy tagless typedef struct { ... } T; idiom from Lectures 7 and 8 does not work.
  • next == NULL marks 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 *head holds the address of the first node; from there you can reach every other node by following next. An empty list is simply head == NULL.

Picture it on the board - three nodes holding 10, 20, 30:

head ─▶ [10 | ─]─▶ [20 | ─]─▶ [30 | NULL]

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 (an int plus 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 = b writes b's address into a's next field.

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 */
}
  • cur starts at head and advances with cur = cur->next each pass. The loop ends when cur becomes NULL, which is precisely the last node's next. An empty list (head == NULL) prints just NULL - 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_list and a length function that counts the nodes by walking to NULL.


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:

      ┌──────────────────────┐
      ▼                       │
[5 | ─]─▶ [10 | ─]─▶ [20 | ─]─▶ [30 | NULL]
   new head
  • Why does push_front return the head? Because it changes which node is first, and the caller's head variable is just a local pointer - passed by value, a function cannot change the caller's head any more than bump could 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_front returning 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->next is the whole idea: the node before the victim now points at the node after the victim, so nothing in the list refers to cur any more. Then, and only then, free(cur).
  • Order matters: unlink first, free last. If you free(cur) before reading cur->next, you are reading freed memory.
  • Two edge cases, both handled by the prev == NULL check. If the value is in the first node, there is no predecessor to relink, so the new head is cur->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. Like push_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->next before you free(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 reading cur->next from it is undefined behaviour. Saving next into 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 free per malloc, node by node. Run it under valgrind and you should see zero leaks and zero invalid frees.
  • After free_list(head), the caller's head still holds the old (now dangling) address, so the caller should set head = NULL afterward - 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 set head = NULL in the caller.


7. Wrap-up

  • A linked list is a chain of heap nodes; each node holds a value and a next pointer 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 the typedef name does not exist yet inside the definition.
  • The list is named by a single head pointer; NULL marks the end; an empty list is head == NULL. You traverse by walking cur = cur->next until cur is NULL - 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 next before freeing the current node, or you use freed memory. Every node's malloc gets its free.
  • 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.