انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-040-L02

المصفوفات الديناميكية وتغيير الحجم

  • الدرس 2 من 7
  • 10 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

Lesson 1 ended with a problem: an array cannot grow. Yet ArrayList lets you call add as often as you like. This lesson shows the trick behind it. A dynamic array keeps an ordinary array and a size counter, and when the array is full it quietly builds a bigger one and copies the items over.

The picture is moving house. Moving is expensive, so if you move to a place twice as big, you will not need to move again for a long time. The cost of the rare big move is spread over all the ordinary days in between. That spreading is called amortized cost.

Why this matters: ArrayList is the default list in Java backends. Knowing why its add is fast on average, and when it is not, helps you reason about performance and about the data-structure code you will write in the lab.

This lesson builds on Lesson 1 (arrays, length versus size, copying) and on Big O from BEG-039.

Learning Objectives

By the end of this lesson you will be able to:

  • Describe a dynamic array as a plain array plus a size counter.
  • Explain the difference between capacity and size.
  • Write the resize step using Arrays.copyOf.
  • Count the copies a sequence of appends causes under a given growth rule.
  • Explain why doubling makes append amortized O(1) while growing by one makes n appends cost O(n²).
  • Say why a single append can still be O(n) in the worst case.

Written Explanation

Plain array plus a counter

A dynamic array has two pieces of state:

java
private Object[] data = new Object[4];   // the lockers
private int size;                          // how many are really used
  • Capacity is data.length: how many items fit before the array is full.
  • Size is size: how many items are actually stored.

Always size <= capacity. The spare lockers are the room to grow into. Appending a new item has two cases:

  1. size < capacity: write at data[size] and increase size. One write, O(1).
  2. size == capacity: the array is full. Make a bigger one, copy, then write.

The resize step

java
if (size == data.length) {
    data = java.util.Arrays.copyOf(data, data.length * 2);
}
data[size++] = item;

Arrays.copyOf(original, newLength) returns a new array of the given length. It keeps the old items in order and fills any extra slots with defaults, which for an array of objects means null. The old array is no longer referenced by data, so the garbage collector can reclaim it later. Copying size items costs O(n). Everything else in add is O(1).

Why not grow by one?

Imagine a stricter rule: when full, grow to data.length + 1. After the first full array, every append finds the array full, so every append copies everything. If the list is n items long, copying costs roughly 1 + 2 + 3 + ... + n, which is about n²/2: O(n²) in total.

With doubling, you only copy when the capacity is exhausted, and each copy buys you as many free appends as there are items. The total copying over n appends is below about 2n items, so the average cost per append is a constant.

The numbers below come from a real run (start capacity 4):

Appends (n)RuleCopiesItems moved in total
17double328
17+113130
1,000double81,020
1,000+1996499,494
100,000double15131,068
100,000+199,9964,999,949,994

Doubling stays close to n items moved; +1 explodes. This is the "resize by +1 instead of doubling" mistake listed for this module.

Amortized O(1)

Amortized cost means the average cost per operation, taken over a whole sequence of operations, in the worst case for that sequence. It is not a probability and not "usually". Here is the argument in plain words, for doubling from capacity c:

  • Resizes happen at sizes c, 2c, 4c, ... Each copies that many items.
  • Adding those up: c + 2c + 4c + ... + n/2 is less than n, because each term is half of the next.
  • So n appends cost about n writes plus fewer than n copied items: under roughly 2n steps in total.
  • Divide by n appends: a small constant per append.

The Java documentation for ArrayList states this contract for add: it runs in amortized constant time, meaning adding n elements takes O(n) time. The same page says the details of the growth policy are not specified, so you cannot rely on ArrayList growing by exactly 2x. Your own MyArrayList in the lab will double because you choose to.

Worst case is still O(n)

"Amortized O(1)" is a statement about the total, not about every call. The one append that triggers a resize copies every item and is O(n). Over a million appends that happens about 20 times, so the average is tiny, but a single request that hits the resize may be slower than its neighbours. In backend terms: average throughput is great; the occasional slow call is the price. If you know the final size ahead of time, you can avoid the surprise by creating the list with a suitable initial capacity (new ArrayList<>(10_000)) so no resize happens.

Capacity grows, but does it shrink?

In this lesson's simple version, no. After a remove, size goes down, but the array keeps its length. That is a deliberate simplicity; real libraries make their own choices. For your own class, the important rule from the demo lesson is to clear the freed slot (data[size] = null) so the removed object is not kept alive by a stale reference.

Examples

Example 1: watching capacity change (simple)

java
static class IntList {
    private int[] data = new int[2];
    private int size;

    void add(int v) {
        if (size == data.length) {
            System.out.println("full at size " + size
                + ": copy " + size + " items into capacity " + size * 2);
            data = Arrays.copyOf(data, data.length * 2);
        }
        data[size++] = v;
    }
    int size() { return size; }
    int capacity() { return data.length; }
}

Adding 10, 20, ..., 90 and printing after each add produced (real run, shortened to the lines that matter):

text
added 20 -> size=2, capacity=2
full at size 2: copy 2 items into capacity 4
added 30 -> size=3, capacity=4
full at size 4: copy 4 items into capacity 8
full at size 8: copy 8 items into capacity 16
added 90 -> size=9, capacity=16

Nine items ended in capacity 16: spare lockers are normal. Three resizes copied 2 + 4 + 8 = 14 items.

Example 2: counting copies for two growth rules (practical)

The program that produced the table above counts every copy:

java
static int[] grow(int[] data, int newCap) {
    copies++;
    moved += data.length;
    return Arrays.copyOf(data, newCap);
}

// inside the loop that adds n items:
if (size == data.length) {
    data = grow(data, doubling ? data.length * 2 : data.length + 1);
}
data[size++] = i;

For n = 1,000, doubling reached capacity 1,024 with 8 copies; +1 made 996 copies. Notice the difference in leftovers too: doubling leaves 24 spare lockers (memory you paid for but are not using); +1 leaves none. Doubling trades some wasted space for much less time.

Example 3: why preallocating avoids the surprise (realistic)

An export endpoint loads 50,000 rows into a list. It knows the number from a COUNT query.

java
List<String> rows = new ArrayList<>(count);   // reserve the room once

With a correct count there are no resizes during the loop. With the default capacity the list would be copied several times as it grows. The result is the same either way; only the hidden work changes. This is a small optimization worth knowing, not a rule to apply everywhere: measure before changing code for speed.

Backend Insight

Resizing shows up in places other than lists: a HashMap grows its table when it fills, and string builders and I/O buffers grow in similar ways. The pattern "when full, allocate bigger and copy" is everywhere, and the same analysis applies.

Two practical consequences:

  • Capacity hints. If you know roughly how many items are coming, pass the capacity. It is the cheapest tuning step there is.
  • Latency spikes. Average cost can hide a rare slow step. A service that must answer within a strict time limit cares about the worst case, not only the average.

Also remember memory: spare capacity costs RAM. A million-item list may hold more lockers than items.

Common Mistakes

Mistake 1: Growing by +1 (or a small fixed step)

  • Incorrect: Arrays.copyOf(data, data.length + 1).
  • Correct: Arrays.copyOf(data, data.length * 2). A fixed step, even a big one, still gives O(n²) for large n, although it hides the problem for small inputs.

Mistake 2: Confusing capacity with size

Using data.length where you mean size makes get, toString, and loops visit empty lockers.

Mistake 3: Believing "amortized O(1)" means every append is fast

It means the average over a sequence is constant. One call may still copy everything.

Mistake 4: Doubling a capacity of 0

0 * 2 is still 0, so the array never grows and the next write throws. Start with a positive capacity, or use Math.max(1, ...).

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. A dynamic array has size = 5 and data.length = 8. What are its capacity and size? Is the next append going to copy anything?

أظهر الحل A1

A1. Capacity 8, size 5. There is room, so the next append writes into slot 5 and copies nothing.

A2. What does Arrays.copyOf(data, 8) return when data has length 4 and holds objects? What are the last four slots?

أظهر الحل A2

A2. It returns a new array of length 8 holding the same four items in order. The last four slots are null. The original array is unchanged.

A3. In one sentence, what does "amortized" mean?

أظهر الحل A3

A3. The average cost per operation over a whole sequence of operations. A rare expensive step is paid for by many cheap ones.

Level B: Application

B1. Start with capacity 2 and double when full. How many copies happen while adding 10 items, and how many items are moved in total? What is the final capacity?

أظهر الحل B1

B1. Resizes happen when the array is full at sizes 2, 4, and 8. That is 3 copies moving 2 + 4 + 8 = 14 items, final capacity 16 (real run).

B2. Start with capacity 1 and double when full. Count copies and items moved for 9 appends.

أظهر الحل B2

B2. Resizes at sizes 1, 2, 4, 8: 4 copies, 1 + 2 + 4 + 8 = 15 items moved, final capacity 16 (real run).

B3. Start with capacity 4, grow by a fixed 4 slots when full, and add 20 items. Compare copies and items moved with the +1 rule from the lesson table for n = 17 (13 copies, 130 moved). Count for 20 items for both rules.

أظهر الحل B3

B3. Fixed step 4, 20 items: resizes at sizes 4, 8, 12, 16, which is 4 copies moving 4 + 8 + 12 + 16 = 40 items, final capacity 20. The +1 rule for 20 items: 16 copies, 184 items moved (real run). Even with a fixed step of 4, the number of copies grows linearly with n, so for large n the total is still O(n²), just with a smaller constant.

Level C: Challenge

C1. Write static int[] append(int[] data, int size, int value) that returns the array to use afterwards (the same one if there was room, a doubled copy if not). Why must it return the array?

أظهر الحل C1

C1.

java
static int[] append(int[] data, int size, int value) {
    if (size == data.length) {
        data = Arrays.copyOf(data, data.length * 2);
    }
    data[size] = value;
    return data;
}

It must return the array because a copy is a different array. The caller has to switch to the new one; the parameter assignment inside the method does not change the caller's variable. (Callers also track size themselves in this version.) It assumes data.length > 0.

C2. Total items moved for doubling from capacity c while growing to capacity 8c is c + 2c + 4c. Show it is less than 8c, and use that to explain why doubling gives amortized O(1).

أظهر الحل C2

C2. c + 2c + 4c = 7c, which is less than 8c. In general, the sum of all earlier copies is smaller than the final capacity. So n appends cause fewer than about n copied items on top of n writes: under roughly 2n steps in total. Dividing by n gives a constant cost per append, which is amortized O(1).

Mini Challenge

Take the IntList from Example 1 and add a counter resizes. Append the numbers 1 to 100 and print the capacity after each resize. Then change the growth rule to data.length + 3 and run again.

Check your work: with doubling from 2 you see capacities 4, 8, 16, 32, 64, 128 (six resizes); with +3 you see many more. Write one sentence on which rule you would use and why.

Summary & Next Lesson

Key takeaways

  • A dynamic array is an array plus size; capacity is the array length, size is the number of items.
  • When full, it allocates a bigger array and copies (Arrays.copyOf), an O(n) step.
  • Doubling means few copies: total work for n appends is under about 2n, so append is amortized O(1).
  • Growing by +1 (or a fixed step) copies too often and costs O(n²) in total.
  • A single append can still be O(n), and spare capacity costs memory.

Next lesson

Next is "Linked lists". Arrays need one contiguous block and costly shifts; linked lists connect separate nodes with references, trading fast insertion for the loss of instant indexing.

Self-check

  1. What is the difference between capacity and size?
  2. What happens when you append to a full dynamic array?
  3. Why does doubling make append amortized O(1)?
  4. Why is growing by +1 a mistake?
  5. Can a single append be O(n)? When?
أظهر الإجابات
  1. Capacity is the array's length; size is how many items are stored.
  2. A larger array is allocated, items are copied, and the new item is written.
  3. Copies are rare and each one buys as many free appends as there are items, so total work for n appends is under about 2n.
  4. It copies on every append, so n appends cost O(n²) overall.
  5. Yes: the append that finds the array full copies all items.

References

  • Oracle, Java SE 21 API, java.util.ArrayList. Supports: add runs in amortized constant time, size and get are constant time, capacity is the size of the backing array, and the growth policy is not specified.
  • Oracle, Java SE 21 API, java.util.Arrays. Supports: Arrays.copyOf returns a new array truncated or padded (with nulls for objects) to the requested length.
  • All copy counts and capacities were produced by running the code on JDK 21. The claim that HashMap and string builders grow in a similar way is given as a general pattern and was not checked against their documentation here.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.