Skip to content

Dynamic arrays

An array that doubles its capacity when full copies fewer than 2n elements over n appends, so an append costs O(1) on average. Growing by a fixed amount costs O(n²) copies in all.

Helps to know first
This topic

First, the picture

A block of memory cannot grow where it stands. To make room, take a bigger block and copy everything across. How much bigger? Watch why the answer is twice.

Why a growing array doubles

32 numbers appended to a block that grows when full

static bool grow(struct vec *v, size_t new_cap){    int *p = malloc(new_cap * sizeof *p);    if (p == NULL)        return false;    for (size_t i = 0; i < v->len; i++) {        p[i] = v->data[i];        copies++;    }    free(v->data);    v->data = p;    v->cap = new_cap;    return true;} bool vec_push(struct vec *v, int x){    if (v->len == v->cap) {        size_t new_cap = v->cap == 0 ? 1 : 2 * v->cap;        if (!grow(v, new_cap))            return false;    }    v->data[v->len] = x;    v->len++;    return true;}

main has started.

pushes
0
cap
?
copies
0
Grow by

Output

0.00 / 24.94 s
Describe this picture

Three pictures stacked, with the code of grow and vec_push beside them, the line about to run marked. At the top, the block v.data points at, named with its stand-in address and capacity, the cells past the length greyed: they are spare. Below it, while grow runs, the new block p points at. Each strip keeps room for 32 cells, drawn as faint dashed outlines where its block has no cell, so a block’s size reads against the 32 it will reach. Before the first push the top strip reads “v.data is NULL: no block yet”, and outside grow the lower one reads “p: no new block (only grow makes one)”. At the bottom, a plot of copies so far, 0 to 64, against pushes, 0 to 32, with a dashed line “2n”. The clip takes about 25 seconds. The pushes write 1, 2, 3, … into the next free cell, one dot on the plot per push. Whenever the block is full, a block of twice the capacity opens below, the elements slide across one by one, the old block greys out as “freed”, and v.data takes the new block. The blocks are at 0x1000 (room for 1), 0x1020 (2), 0x1000 again (4: the C library hands back the block freed just before), 0x1040 (8), 0x1070 (16) and 0x10c0 (32). The early growths play slowly and the later copies quickly. The dots climb in a staircase, a step at pushes 2, 3, 5, 9 and 17, and stay under the line; the readouts end at 32 pushes, capacity 32, 31 copies. After the clip, the control “Grow by” switches to “Adding 4”, the program that grows 4 cells at a time: the plot runs to 112, the staircase steps every 4 pushes, crosses the line at push 17, 40 copies against 34, and ends at 112. The transport plays and pauses, steps one line back or forward, and scrubs; the choice and a paused step are kept in the page address as doubling.grow and doubling.step.

Growing a block

A block from malloc has the size it was asked for, and the memory just after it may belong to something else. A list that must keep growing, read from a file of unknown length, say, has to move: allocate a bigger block, copy the elements across, free the old one. The copies are the cost.

How much bigger matters more than it seems. Grow by a fixed amount and the copies pile up as the square of the length. Grow by doubling and they stay below twice the length: each append costs a constant on average, even though a few of them cost a large copy.

The code and its cost

Grow, step by step

struct vec {
    int *data;
    size_t len, cap;
};

static bool grow(struct vec *v, size_t new_cap)
{
    int *p = malloc(new_cap * sizeof *p);
    if (p == NULL)
        return false;
    for (size_t i = 0; i < v->len; i++) {
        p[i] = v->data[i];
        copies++;
    }
    free(v->data);
    v->data = p;
    v->cap = new_cap;
    return true;
}

bool vec_push(struct vec *v, int x)
{
    if (v->len == v->cap) {
        size_t new_cap = v->cap == 0 ? 1 : 2 * v->cap;
        if (!grow(v, new_cap))
            return false;
    }
    v->data[v->len] = x;
    v->len++;
    return true;
}

The vec starts as { NULL, 0, 0 }. On the first push there is no old block: free(NULL) does nothing. Pushing 1 to 32 grows the block at lengths 0, 1, 2, 4, 8 and 16, copying 0 + 1 + 2 + 4 + 8 + 16 = 31 elements, and the program prints 32 pushes, capacity 32, 31 copies.

realloc

int *p = realloc(v->data, new_cap * sizeof *p);

realloc does what grow spells out: if the block can be extended where it stands, it extends it; if not, it allocates a new block, copies the bytes across and frees the old one. vec-realloc.c, the same program with realloc, prints 32 pushes, capacity 32. Either way, the block may have moved, so any pointer into the old block is invalid afterwards, even if this time it happened not to move.

Doubling against adding 4

With doubling, the copies are 1 + 2 + 4 + … up to the last capacity below n, which is less than 2n. So n appends cost O(n) copies in all, and O(1) each on average. That average over a whole run is the amortised cost: most appends cost one write, a few cost a large copy, and the large ones are rare because each comes after twice as many cheap ones as the last.

Growing by a fixed k cells instead copies k + 2k + 3k + … about n²/(2k) elements, O(n²). vec-add-four.c grows by 4 and copies 4 + 8 + … + 28 = 112 elements for the same 32 pushes. Its staircase passes the line 2n at the 17th push, 40 copies against 34, and pulls away. A fixed step wastes less space, but it pays for that in time.

Shrinking

A vec that empties can give memory back by halving its block. Halve when it is a quarter full, not half full. At exactly half, one push grows the block and the next pop shrinks it again, and every step costs a full copy.

Worked example

A thousand pushes. Doubling copies 1 + 2 + 4 + … + 512 = 1,023 elements. Adding 4 copies 4 + 8 + … + 996 = 124,500.

A binary counter. Counting from 0 to 16 in binary flips 31 bits in 16 increments. Half of them flip one bit; the one to 8 flips four and the one to 16 flips five. The average stays under 2, the same pattern as the copies: a few expensive steps paid for by many cheap ones.

A stale pointer.

int *first = &data[0];
int *bigger = realloc(data, 2 * sizeof *bigger);
...
printf("first = %d\n", *first);

stale-pointer.c keeps first across a realloc. In the plain build, realloc happened to grow the block where it stood, and the program printed first = 10. Reading *first is undefined behaviour even so: after realloc the old pointer must not be used, whether or not the block moved, and the 10 was luck. The sanitiser build moves every block, and stops with heap-use-after-free, a read of 4 bytes through first. gcc warns too: first may be used after realloc.

Shrinking at half. Start with capacity 16 and length 16. A push grows the block to 32, copying 16; a pop leaves 16 of 32, half full, and shrinking copies 16 back. Push, pop, push, pop: thrash.c prints 16, 32, 48, 64, 80 and 96 copies after six steps.

Two checks. How many copies for 9 pushes by doubling? (15: growth at lengths 0, 1, 2, 4 and 8.) Right after a doubling, at most how much of the capacity is unused? (Just under half.)

Where it’s used

Standard libraries. The growable lists of most languages are dynamic arrays that grow by a constant factor, often 2 or 1.5.

Reading unknown amounts. A program reading a file or a network stream of unknown length appends to a buffer that doubles as it fills.

Stacks and tables. The array stack of Chapter 8 can drop its fixed capacity this way, and hash tables grow by rehashing into a bigger table on the same principle.

Reference card

Code or ideaMeaning
growallocate, copy len elements, free the old block
realloc(p, bytes)grow in place if it can, else move
after a moveevery pointer into the old block is invalid
doublingunder 2n copies for n pushes: O(1) amortised
fixed step kabout n²/(2k) copies: O(n²)
amortisedthe average cost per operation over a whole run
shrinkto half, when a quarter full

Platform: C11, gcc on x86-64 Linux: int 4 bytes, pointers 8, little-endian. Addresses in the pictures are short stand-ins, by one rule for the whole course; sizes are the real ones.

End of the lesson

Where to go next.

New lessons by email

Are you 13 or over?

Accounts and email updates are for people aged 13 or over. We keep only your answer, never your date of birth.

Phasorium

Account

Sign inWith Google, GitHub or your email

Subjects

Site

Look