// STUHUB · C++ DATA STRUCTURES

Linked List vs Array: Memory, Access Cost, and Insert/Delete

A C++ student's guide to arrays vs linked lists: how each lays out memory, why indexing is O(1) in one and O(n) in the other, the real cost of insert and delete, and working code for head/tail insertion, delete-by-value with a previous pointer, and correct new/delete discipline.

Introduction

Arrays and linked lists store the same thing — an ordered sequence of elements — and almost every difference between them follows from a single decision: are the elements neighbours in memory, or are they scattered and stitched together by pointers?

Get that one picture right and you can derive every complexity in the table below instead of memorising it. You will also stop making the classic pointer mistakes, because most of them are just "I rewired the links in the wrong order" or "I forgot who owns this memory."

This page builds both structures in plain C++ (raw new/delete, so the mechanics are visible), explains the cost of each operation and why it costs that, and ends with three exercises plus full solutions. Every code sample here was compiled with -Wall -Wextra and run under AddressSanitizer.

One sequence, two memory layouts

An array is one block. You ask the allocator for room for capacity elements and get a single contiguous run of bytes. Element i lives at base + i * sizeof(T). Nothing stores "where the next element is" because the next element is always right there.

array:   [ 10 ][ 20 ][ 30 ][ 40 ][  ?  ][  ?  ]
          ^base                    ^size=4, capacity=6

A linked list is many small blocks. Each element gets its own node, allocated separately, holding the value plus a pointer to the next node. The nodes can be anywhere in the heap, in any order. The only thing keeping the sequence together is the chain of pointers, and the only entry point is head.

head ──> [10|•]──> [20|•]──> [30|•]──> [40|nullptr]
         (0x6a10)  (0x71c0)  (0x6a40)  (0x8330)   <- unrelated addresses

Two consequences fall straight out of the pictures:

  1. In the array, the address of element i is computable. One multiply, one add. That is O(1) random access, and it is why a[999] costs the same as a[0].
  2. In the list, the address of node i is only discoverable by starting at head and following next i times. That is O(n) access, and no amount of clever coding removes it — the information simply is not stored anywhere.

The flip side is what it costs to make room. Inserting into the middle of an array means physically moving every later element one slot over, because the layout is the ordering. Inserting into a list means allocating one node and changing two pointers; nothing else in memory moves, because position is expressed by links, not by addresses.

The contrast table (and why each row is what it is)

n is the number of elements. "Known position" means you already hold the node pointer (list) or the index (array) — no searching included.

OperationArray (contiguous)Singly linked list
Read/write element iO(1) — address arithmeticO(n) — traverse from head
Search for a value (unsorted)O(n)O(n)
Binary search (sorted)O(log n)O(n) — no jumping to the middle
Insert at frontO(n) — shift everything rightO(1) — new node, repoint head
Insert at backO(1) amortised (spare capacity)O(1) with a tail pointer; O(n) without
Insert after a known positionO(n) — shift the suffixO(1) — two pointer writes
Delete at a known positionO(n) — shift the suffix leftO(1) if you hold the previous node; otherwise O(n) to find it
Delete by valueO(n)O(n)
Grow past capacityO(n) copy (amortised O(1) if you double)Never happens
Memory per elementsizeof(T), plus unused capacity slacksizeof(T) + sizeof(pointer) + per-allocation heap overhead
Traversal speed in practiceFast — sequential, prefetcher-friendlySlower — a potential cache miss per node

Three rows deserve a second look, because they are where marks and bugs are lost.

"O(1) insert" is a claim about rewiring, not about arriving. A linked list inserts in constant time once you are standing at the right node. Inserting at index 5000 of a list is O(n) to walk there plus O(1) to rewire — asymptotically the same O(n) as the array's shifting. The list only truly wins when you already have the pointer: at the head, at a cursor you kept while iterating, or at the tail if you maintain one.

Deleting from a singly linked list needs the previous node, not the victim. To unlink node X you must write prev->next = X->next, and a singly linked node offers no way back. Given only a pointer to X you must re-walk from head to find its predecessor — O(n). A doubly linked list stores prev and makes this genuinely O(1), at the cost of a second pointer per node.

Per-node overhead is bigger than students expect. A list of int on a 64-bit machine spends 4 bytes on data and 8 on the pointer, and each new typically rounds up and adds bookkeeping — so 32 bytes of heap for 4 bytes of payload is normal. An array of the same 4-byte ints costs 4 bytes each. Linked lists are not the memory-efficient option; they are the don't-copy-and-don't-need-a-contiguous-block option.

The array side: index arithmetic, shifting, and growth

Here is a minimal dynamic array so the three array costs are visible in code: constant-time indexing, linear-time shifting, and the copy that happens when capacity runs out.

Things to notice:

  • insert_at copies backwards, from the end towards pos. Copying forwards would overwrite each element with the one before it and smear a single value across the tail. Direction matters whenever ranges overlap.
  • The loop guard in erase_at is i + 1 < size_, not i < size_ - 1. With std::size_t (unsigned), size_ - 1 when size_ == 0 wraps around to a huge number — a classic off-by-one that turns into an out-of-bounds walk. Prefer adding to the left side over subtracting from the right when the type is unsigned.
  • grow doubles rather than adding a constant. Doubling makes the copies rare enough that the average push_back is O(1) (amortised): growing to n elements copies about n elements in total. Growing by +1 each time would copy about n²/2 elements overall — the difference between a fast program and a slow one.
  • Allocation with new int[cap] must be released with delete[], and the destructor is the place to do it.
cpp
#include <cstddef>

class IntArray {
public:
    explicit IntArray(std::size_t capacity)
        : data_(new int[capacity]), size_(0), capacity_(capacity) {}

    ~IntArray() { delete[] data_; }          // array form: delete[]

    IntArray(const IntArray&) = delete;      // owning raw memory: no accidental
    IntArray& operator=(const IntArray&) = delete;  // shallow copies

    std::size_t size() const { return size_; }

    // O(1): the address of element i is computed, not searched for.
    int& operator[](std::size_t i) { return data_[i]; }

    // O(n): every element from pos onwards physically moves right.
    void insert_at(std::size_t pos, int value) {
        if (pos > size_) return;             // pos == size_ is legal (append)
        if (size_ == capacity_) grow();
        for (std::size_t i = size_; i > pos; --i) {
            data_[i] = data_[i - 1];         // copy BACKWARDS
        }
        data_[pos] = value;
        ++size_;
    }

    void push_back(int value) { insert_at(size_, value); }

    // O(n): close the hole by sliding the suffix left.
    void erase_at(std::size_t pos) {
        if (pos >= size_) return;
        for (std::size_t i = pos; i + 1 < size_; ++i) {
            data_[i] = data_[i + 1];
        }
        --size_;
    }

private:
    void grow() {
        std::size_t new_cap = (capacity_ == 0) ? 1 : capacity_ * 2;
        int* bigger = new int[new_cap];
        for (std::size_t i = 0; i < size_; ++i) bigger[i] = data_[i];
        delete[] data_;                      // free the old block...
        data_ = bigger;                      // ...only after copying out of it
        capacity_ = new_cap;
    }

    int* data_;
    std::size_t size_;      // elements in use
    std::size_t capacity_;  // slots allocated
};

The list side: nodes, head, tail, and the order of pointer writes

A node is a value plus a link. The list object owns head, and — because "append" is such a common operation — also a tail so push_back does not have to walk the whole chain.

The two insertion cases people mix up:

Insert at head. Point the new node at the old head first, then move head. If you do it the other way round (head = fresh; fresh->next = head;) you have made the node point at itself and leaked the entire rest of the list. Writing it as new Node(v, head_) makes the correct order impossible to get wrong.

Insert at tail. Two distinct situations: the list is empty (head and tail both become the new node) or it is not (tail_->next = fresh; tail_ = fresh;). Forgetting the empty case is the single most common linked-list bug — you either dereference a null tail_ and crash, or you set tail_ while leaving head_ null so the list looks permanently empty. Any operation that can empty or un-empty the list must keep head_ and tail_ consistent with each other.

Insert after a node is the O(1) case that justifies linked lists: one allocation, one link read, two link writes, no traversal. Note that it must also update tail_ when inserting after the last node.

Every new node's next must end up either pointing at a real node or at nullptr. A node whose next was never assigned holds garbage, and traversal will happily follow it off a cliff.

cpp
#include <cstddef>

struct Node {
    int value;
    Node* next;
    Node(int v, Node* n) : value(v), next(n) {}
};

class IntList {
public:
    IntList() : head_(nullptr), tail_(nullptr), size_(0) {}
    ~IntList() { clear(); }

    IntList(const IntList&) = delete;
    IntList& operator=(const IntList&) = delete;

    std::size_t size() const { return size_; }
    bool empty()      const { return head_ == nullptr; }
    Node* head()      const { return head_; }
    Node* tail()      const { return tail_; }

    // O(1) always.
    void push_front(int v) {
        Node* fresh = new Node(v, head_);   // link first...
        head_ = fresh;                      // ...then move head
        if (tail_ == nullptr) tail_ = fresh;  // list was empty
        ++size_;
    }

    // O(1) because we keep tail_. Without it this would be O(n).
    void push_back(int v) {
        Node* fresh = new Node(v, nullptr); // new last node terminates the list
        if (tail_ == nullptr) {
            head_ = tail_ = fresh;          // empty-list case
        } else {
            tail_->next = fresh;
            tail_ = fresh;
        }
        ++size_;
    }

    // O(1): the operation linked lists exist for.
    // p == nullptr is treated as "insert before the first element".
    void insert_after(Node* p, int v) {
        if (p == nullptr) { push_front(v); return; }
        p->next = new Node(v, p->next);     // splice in
        if (p == tail_) tail_ = p->next;    // appended at the end
        ++size_;
    }

    // O(n): there is no shortcut to the i-th node.
    Node* find(int v) const {
        for (Node* cur = head_; cur != nullptr; cur = cur->next) {
            if (cur->value == v) return cur;
        }
        return nullptr;
    }

private:
    Node* head_;
    Node* tail_;
    std::size_t size_;
};

Ownership: every new needs one delete, and delete[] is not delete

Manual data structures are exercises in ownership. The rule to internalise: whoever allocates a node decides who is responsible for freeing it, and that responsibility must be discharged exactly once.

For a linked list, the owner is the list object, and the destructor is where the debt is paid. clear() must cache next before each delete, otherwise it reads a pointer out of a node it has already destroyed.

The array/scalar distinction is a hard rule of the language, not a style preference:

Allocated withMust be freed with
new Node(...)delete p
new int[n]delete[] p

Mixing them (delete on an array, or delete[] on a single object) is undefined behaviour. It typically leaks the elements after the first, corrupts the heap's bookkeeping, or crashes on an unrelated later allocation — the bug never points at the line that caused it. The two forms are not interchangeable because new[] may store an element count that only delete[] knows how to consume.

Two more habits that prevent whole categories of pain:

  • Delete the copy constructor and copy assignment (or write them properly). A class holding a raw owning pointer gets a compiler-generated copy that duplicates the pointer, not the data. Two objects then free the same memory — a double free — and modifications through one are visible through the other. = delete turns a runtime disaster into a compile error.
  • Do not read a pointer after freeing what it pointed to. Any other pointer aimed at that node — a saved cursor, a stale tail_, a variable in the caller — is now dangling. Setting a pointer to nullptr after deleting is a cheap way to make later misuse crash loudly and immediately instead of quietly corrupting data. Freeing nullptr is explicitly safe, so delete p; needs no null check.

In real code you would reach for std::vector and std::list/std::forward_list, or std::unique_ptr for the nodes, and none of this bookkeeping would be yours. Writing it by hand once is how you learn what those types are doing for you.

cpp
void IntList::clear() {
    Node* cur = head_;
    while (cur != nullptr) {
        Node* nxt = cur->next;  // save the link BEFORE destroying the node
        delete cur;             // scalar new -> scalar delete
        cur = nxt;
    }
    head_ = tail_ = nullptr;    // no dangling handles left behind
    size_ = 0;
}

IntList::~IntList() { clear(); }

// The array/scalar rule, side by side:
Node* one  = new Node(7, nullptr);
int*  many = new int[16];

delete   one;    // correct
delete[] many;   // correct
// delete[] one;  // UB
// delete   many; // UB (silently "works" often enough to be a trap)

Mistakes people actually make

Almost every linked-list bug is one of these, and almost every array bug is one of the last three.

Pointer rewiring

  • Reassigning `head` before linking the new node, losing the rest of the list. Build the node with its next already set.
  • Ignoring the empty-list case in push_back, or the single-element case in delete. Test with 0, 1, and 2 elements — that is where the special cases live.
  • Forgetting to update `tail_` after deleting the last node, leaving tail_ dangling into freed memory.
  • Leaving the new last node's `next` unset after a deletion or a split, so traversal runs into garbage. Every chain must end in an explicit nullptr.
  • Deleting the node, then reading `node->next`. Save the link first.
  • Losing the only pointer to a node before freeing it (e.g. cur = cur->next; on a node you meant to remove) — an immediate, unrecoverable leak.
  • Comparing values when you meant nodes, or writing if (cur->next->value == v) without first checking cur->next != nullptr.

Ownership

  • Double free via an accidental shallow copy — pass containers by reference, or delete the copy operations.
  • `delete` vs `delete[]` mismatch.
  • Freeing a node that another part of the program still points at. Decide who owns each node, and keep to it.
  • Non-null but garbage: an uninitialised Node* p; is not nullptr. Initialise every pointer at declaration.

Array and buffer

  • Off-by-one on the last index: valid indices are 0 .. size-1; a[size] is out of bounds. In erase_at, prefer i + 1 < size over i < size - 1 so an empty container cannot underflow an unsigned type.
  • Shifting in the wrong direction: insert copies from the back forwards; erase copies from the front backwards.
  • Circular-buffer full-vs-empty ambiguity: with only head and tail indices, head == tail means both "empty" and "full". Resolve it by storing a count (used in Exercise 3) or by deliberately leaving one slot unused. And wrap with if (i == capacity) i = 0; or %, never by letting the index run past the end.
  • Holding a pointer or reference into an array across a resize. After grow() reallocates, every old pointer into the buffer is dangling — the same hazard as std::vector iterator invalidation.

Which one should you reach for?

Choose an array (or std::vector) by default. It wins on indexing, on binary search, on memory per element, and — for anything you scan repeatedly — on raw speed, because contiguous memory is what caches and prefetchers are built for. Chasing pointers across the heap can cost a cache miss per node, and a miss is worth roughly a hundred arithmetic operations. This is why a std::vector frequently beats a std::list even on workloads with mid-sequence insertions: shifting a few thousand contiguous bytes is often cheaper than one scattered allocation plus the misses that follow it.

Choose a linked list when the specific things it is good at are the things you do:

  • You constantly insert or remove at the front (stacks, queues, LRU eviction).
  • You are already holding a cursor at the point of modification while iterating, and you modify a lot.
  • Existing pointers or references to elements must stay valid across insertions and deletions — an array's reallocation invalidates them all, whereas a list node never moves.
  • You need to splice whole runs between sequences in constant time.
  • You cannot afford a large contiguous block, or cannot tolerate the latency spike of an O(n) copy at an unpredictable moment (a concern in real-time systems).

The honest summary for an essay answer: arrays trade flexible structure for computed addresses; linked lists trade computed addresses for flexible structure. The array pays at modification time by moving data; the list pays at access time by walking links and at storage time by carrying a pointer per element. Neither is "faster" — they charge you in different currencies, and the right choice is whichever currency your program spends least.

Exercises with solutions

Work through each question before opening the solution below it.

Exercise 1

Insert at an arbitrary position. Add bool insert_at(std::size_t pos, int v) to IntList. Position 0 means "new first element", position size() means "append". Return false and change nothing if pos > size(). Keep head_, tail_ and size_ correct, and make the traversal do the minimum work. State the complexity.

Solution

The trick is to stop at the node before the target — you cannot splice in front of a node you are standing on in a singly linked list. Delegate the two boundary cases to the functions that already handle them correctly, then walk to index pos - 1 for the general case.

cpp
bool IntList::insert_at(std::size_t pos, int v) {
    if (pos > size_) return false;        // pos == size_ IS valid (append)
    if (pos == 0)     { push_front(v); return true; }   // fixes head_
    if (pos == size_) { push_back(v);  return true; }   // fixes tail_

    Node* prev = head_;                   // prev is at index 0
    for (std::size_t step = 1; step < pos; ++step) {
        prev = prev->next;                // ends at index pos - 1
    }
    prev->next = new Node(v, prev->next); // splice: 2 pointer writes
    ++size_;
    return true;
}

Why the loop is written that way. prev starts at index 0, so it needs pos - 1 steps to reach index pos - 1; the loop runs for step = 1 .. pos-1, which is exactly pos - 1 iterations. Writing for (std::size_t i = 0; i < pos - 1; ++i) would be equivalent here but is fragile: if the guard pos == 0 were ever removed, pos - 1 on an unsigned type wraps to a colossal number and the loop runs off the end of the list.

Why boundaries are delegated. In the general branch prev is guaranteed non-null and is guaranteed not to be the tail (since pos < size_), so no head_/tail_ fix-up is needed there. Handling pos == 0 inline would need a head_ update, and pos == size_ would need a tail_ update — the two cases people forget. Pushing them into push_front/push_back means the invariants are maintained in one place.

Complexity: O(1) for the two boundary cases; O(pos) — so O(n) worst case — for the general case, entirely due to the walk. The rewiring itself is always O(1). Compare with the array: insert_at there is O(n - pos), cheap near the end and expensive at the front, the exact mirror image of the list.

Test with an empty list, pos == 0, pos == size(), one in the middle, and an out-of-range pos.

Exercise 2

Delete every occurrence of a value. Add std::size_t remove_all(int v) to IntList that unlinks and frees every node holding v, returns how many were removed, and leaves head_, tail_, size_ and the null terminator correct. It must survive consecutive matches, matches at the head, matches at the tail, and a list that becomes empty.

Solution

Calling remove_first in a loop would be O(k·n) and re-scan the list every time. One pass with a trailing prev handles everything in O(n).

cpp
std::size_t IntList::remove_all(int v) {
    std::size_t removed = 0;
    Node* prev = nullptr;
    Node* cur  = head_;

    while (cur != nullptr) {
        Node* nxt = cur->next;              // save the link BEFORE any delete
        if (cur->value == v) {
            if (prev == nullptr) head_ = nxt;      // removing the head
            else                 prev->next = nxt; // unlink
            if (cur == tail_) tail_ = prev;        // removing the tail
            delete cur;
            --size_;
            ++removed;
            // prev deliberately NOT advanced: it must keep trailing a *live* node
        } else {
            prev = cur;                     // advance prev only on survivors
        }
        cur = nxt;
    }
    return removed;
}

The four things that make it correct.

  1. `nxt` is captured before the delete. Advancing with cur = cur->next after delete cur is a use-after-free.
  2. `prev` only advances past nodes that survive. Advance it unconditionally and after deleting node X, prev points at freed memory — then the next deletion writes prev->next into the heap's free list. This is the bug that makes consecutive duplicates (5, 5, 5) crash while a single occurrence works fine.
  3. `tail_` is repaired inside the loop. If the last node matches, the new tail is prev, the last surviving node — or nullptr if everything was removed. Null-termination comes for free: prev->next = nxt where nxt is nullptr when the removed node was last.
  4. The empty-list end state is consistent. Remove every node and head_ ends up nullptr (set on the first iteration, then re-set each time) and tail_ ends up nullptr (because prev never advanced). A later push_back therefore takes its empty branch and works. Leaving a stale tail_ here is the classic latent crash: the deletion appears to succeed and the next append blows up.

Complexity: O(n) time, one traversal regardless of how many nodes match, and O(1) extra space. Contrast with an array, where the equivalent "remove-if" is also O(n) but is done by compacting in place — a read index and a write index copying survivors forward — rather than by freeing anything.

Exercise 3

Fixed-capacity circular buffer. Implement a FIFO queue over a single new int[capacity] block: push returns false when full, pop(int& out) returns false when empty, and the buffer must keep working indefinitely as indices wrap around. Explain how you distinguish full from empty, and why that is the classic bug here.

Solution

cpp
#include <cstddef>

class RingBuffer {
public:
    explicit RingBuffer(std::size_t capacity)
        : data_(new int[capacity]), capacity_(capacity), head_(0), count_(0) {}
    ~RingBuffer() { delete[] data_; }          // array form

    RingBuffer(const RingBuffer&) = delete;    // owning raw memory
    RingBuffer& operator=(const RingBuffer&) = delete;

    bool empty() const { return count_ == 0; }
    bool full()  const { return count_ == capacity_; }
    std::size_t size() const { return count_; }

    bool push(int v) {
        if (full()) return false;
        std::size_t tail = head_ + count_;      // one past the last element
        if (tail >= capacity_) tail -= capacity_;  // wrap
        data_[tail] = v;
        ++count_;
        return true;
    }

    bool pop(int& out) {
        if (empty()) return false;
        out = data_[head_];
        ++head_;
        if (head_ == capacity_) head_ = 0;      // wrap
        --count_;
        return true;
    }

private:
    int* data_;
    std::size_t capacity_;
    std::size_t head_;   // index of the front element
    std::size_t count_;  // number of elements in use
};

Full vs empty. Store head and count rather than head and tail. With two indices, both an empty buffer and a completely full one satisfy head == tail, so the state is ambiguous and the usual symptoms are a queue that silently drops the oldest element when full, or one that reports empty when it holds capacity items. count removes the ambiguity outright and gives size() for free. The alternative fix — keep head/tail but never use the last slot, so full means (tail + 1) % capacity == head — also works but wastes a slot and is easier to get wrong.

Why head_ + count_ is safe. Both are less than capacity_ and count_ <= capacity_, so the sum is at most 2 * capacity_ - 1; a single conditional subtraction is enough to bring it back into range — no % needed, and no risk of a negative intermediate (which is what makes hand-rolled wrapping on unsigned types go wrong: head_ - 1 when head_ == 0 wraps to a huge value, so always test before subtracting).

Complexity: push and pop are both O(1) worst case, with zero allocation after construction and perfect cache locality. That combination is exactly why a ring buffer, not a linked list, is the standard choice for a bounded queue — it gets the list's O(1) ends while keeping the array's contiguity. What it gives up is unbounded growth: past capacity it refuses work, which for producer/consumer pipelines and real-time systems is a feature, not a limitation.

Test it by filling to capacity, popping one, pushing one (forcing a wrap), then draining — and check that pop on the empty buffer returns false instead of handing back a stale value.

Published and updated 2026-08-09 · DUOCODE TECHNOLOGY