← All Posts
DSA · Linked Lists · Part 1 of 28

Nodes, Pointers & Memory Layout

Before a single algorithm, you need an accurate mental picture of what a linked list is once the compiler is done with it. Almost every wrong intuition about linked lists — "they're faster for insertion", "they save memory", "they avoid copying" — comes from reasoning about the diagram instead of the machine. This post fixes the picture.

What a Node Actually Is

A node is a struct. Nothing more:

struct Node {
    int   val;
    Node* next;
};

On a typical 64-bit platform, int is 4 bytes and Node* is 8 bytes. But sizeof(Node) is 16, not 12: the pointer must be 8-byte aligned, so the compiler inserts 4 bytes of padding after val. That is a 33% overhead you did not write and cannot see.

// offsetof(Node, val)  == 0
// offsetof(Node, next) == 8   <- 4 bytes of padding at [4, 8)
// sizeof(Node)         == 16

static_assert(sizeof(Node) == 16);
static_assert(alignof(Node) == 8);

So storing one million ints in a linked list costs 16 MB of node payload, versus 4 MB for a std::vector<int>. And that is before the allocator's own bookkeeping.

Reorder your fields. struct Node { Node* next; int val; }; also has sizeof == 16 (trailing padding for array alignment), but a node with { Node* next; int a; int b; } is 16 bytes while { int a; Node* next; int b; } is 24. Put pointers first, then group same-sized scalars.

The Cost You Do Not See: The Allocator

new Node is not free and it is not just sizeof(Node). A general-purpose allocator like glibc's malloc stores a header immediately before each allocation (typically 8 or 16 bytes recording the chunk size and flags) and rounds the request up to a size-class boundary — usually a multiple of 16 bytes with a 32-byte minimum.

CostPer nodeWhy
Payload16 Bint + padding + next.
Allocator header8–16 BChunk size and flags, stored before your pointer.
Size-class rounding0–16 BRequests round up to the allocator's granularity.
Call cost~15–100 nsFree-list search, possible lock, possible mmap/brk.

Realistically, a 4-byte integer in a linked list occupies 32 bytes of resident memory — an 8× expansion. This is why "linked lists save memory" is backwards. They save memory only when the payload is large relative to a pointer, or when you would otherwise have to over-allocate an array.

Pointer Chasing vs. Sequential Scanning

Here is the part that decides real benchmarks. Consider summing every element.

// Array: the address of element i is known before the load issues.
long sum = 0;
for (int i = 0; i < n; ++i) sum += a[i];

// List: the address of element i+1 is *inside* element i.
long sum = 0;
for (Node* p = head; p; p = p->next) sum += p->val;

These look equally cheap. They are not, and the reason is dependency, not instruction count.

In the array loop, the CPU computes a + i with arithmetic. It knows the next several addresses long before it needs them, so the hardware prefetcher streams cache lines ahead of the loop and every load hits L1. A 64-byte cache line holds 16 ints, so you pay one memory access per 16 elements.

In the list loop, p->next must complete before the next iteration's address is even known. This is a serial dependency chain of loads: the prefetcher cannot help, out-of-order execution cannot help, and if the node is not cached you stall for the full latency of the memory hierarchy on every single element.

▶ Sequential Scan vs. Pointer Chase

Both loops read 8 values. Watch how many cache lines each one touches, and notice that the array knows every address up front while the list discovers each address only after the previous load returns.

The practical numbers, on a modern desktop CPU:

AccessLatencyEffect on a 1M-element sum
L1 cache hit~4 cyclesArray: nearly every access after the first per line.
L2 cache hit~12 cyclesBoth, when the working set is a few hundred KB.
L3 cache hit~40 cyclesList, when nodes were allocated in a burst.
DRAM~200–300 cyclesList, once nodes are scattered by a long-running allocator.

An array sum runs at roughly one element per cycle. A scattered linked-list sum can run at one element per 200 cycles. That is the two-orders-of-magnitude gap people find shocking, and it does not show up in Big-O at all — both are O(n).

Big-O hides the constant that matters most. Asymptotics count operations; performance counts memory stalls. When two algorithms share a complexity class, the one with better locality wins — often by 10× or more. Keep this in mind every time you compare O(n) list traversal with O(n) array traversal.

The Locality Illusion

Freshly-built lists benchmark deceptively well. If you allocate a million nodes in a tight loop on a fresh heap, the allocator hands back nearly-contiguous chunks, so traversal order roughly matches address order and the prefetcher partially rescues you.

// Benchmarks great: nodes come out of the allocator in address order.
for (int i = 0; i < n; ++i) push_front(i);

// Benchmarks terribly: the same nodes, visited in a shuffled order,
// after a workload that has fragmented the heap.

Real workloads insert, delete, and interleave allocations from other subsystems. After a few hours, the traversal order of a long-lived list has essentially no relationship to its address order, and every hop is a potential TLB and cache miss. Benchmark lists after fragmentation, not before.

The Ownership Question

Every list implementation must answer one question before it writes a single method: who frees the nodes? There are exactly three answers, and mixing them is where memory bugs come from.

1. The list owns nodes via raw pointers

The classic teaching design. The list allocates in push, frees in pop, and frees everything in the destructor. Simple, fast, and entirely on you to get right — you must also delete the copy constructor and copy assignment, or a copy will double-free.

class List {
    Node* head_ = nullptr;
public:
    List() = default;
    List(const List&)            = delete;   // or implement a deep copy
    List& operator=(const List&) = delete;

    ~List() {
        while (head_) { Node* nx = head_->next; delete head_; head_ = nx; }
    }
};
Note the iterative destructor. A recursive ~Node() { delete next; } is elegant and will blow the stack on a list of a few hundred thousand nodes. This is the single most common crash in student list implementations — see Pitfalls, Leaks & Memory Safety.

2. The list owns nodes via unique_ptr

Correct by construction, and free of leaks. The cost is that unlinking becomes a dance of std::move, and the destructor is still recursive — destroying the head destroys its unique_ptr<Node> next, which destroys the next, and so on. You must break the chain manually.

struct Node {
    int val;
    std::unique_ptr<Node> next;
};

// Still required: an iterative teardown, or deep lists overflow the stack.
~List() {
    while (head) head = std::move(head->next);
}

3. Something else owns the nodes (intrusive)

The list stores no allocations at all: the next pointer lives inside the user's object, and the list is just a chain through objects that already exist. Zero allocation, zero indirection to the payload, and the pattern the Linux kernel uses everywhere. Covered in Unrolled & Intrusive Lists.

struct ListHead { ListHead *next, *prev; };

struct Task {
    int  pid;
    ListHead run_queue;   // the list threads through the object
};

Representing "End of List"

Three conventions exist, and they change every loop condition you will write:

ConventionTerminatorLoop conditionTrade-off
Null-terminatednullptrwhile (p)Simplest; needs null checks everywhere.
Sentinel nodea dummy nodewhile (p != &sentinel)Kills edge cases; costs one node. See Sentinels.
Circularwraps to headdo { } while (p != head)No end at all; easy to loop forever. See Circular Lists.

The standard library uses the sentinel convention: std::list keeps one non-allocating end node so that end() is a real, dereferenceable-adjacent iterator and --end() gives you the last element.

When Lists Genuinely Win

Given everything above, a linked list is still the right structure when:

Notice what is missing from that list: "insertion is O(1)". Insertion into a linked list at a position you must first find is O(n) — the same as an array, but with worse constants. The O(1) applies only once you already hold the node.

Interview answer worth memorising: "A linked list gives O(1) splice and stable references; an array gives O(1) indexing and cache locality. I'd pick the list when I hold node references and mutate structurally, and the array otherwise — which is most of the time."

Check Yourself

Answer before you continue. You are given the situation; pick the correct conclusion.

Practice