Understand dynamic arrays from the ground up — how automatic resizing works, why append is O(1) amortized, how Python lists and Java ArrayLists are implemented, amortized analysis with the potential method, and how to implement your own dynamic array from scratch.
Introduction
Every time you use a Python list or a Java ArrayList, you are using a dynamic array — a data structure that looks like a simple array but automatically resizes itself as you add elements.
The fact that append() in Python is O(1) amortized despite occasionally requiring O(n) copying might seem like magic. It is not magic — it is a beautifully designed algorithm based on the doubling strategy, and understanding it gives you a deep intuition for amortized analysis that applies far beyond just arrays.
This lesson dissects dynamic arrays completely: the problem they solve, the doubling solution, the formal amortized analysis, their implementation in major languages, and how to build one yourself.
The Doubling Strategy
Rule: When the internal array is full, allocate a new array with twice the current capacity.
| Start | capacity=1, size=0, array=[_] |
| append(1) | size=1, capacity=1, array=[1] |
| append(2): FULL | allocate capacity=2, copy [1], add 2 → [1,2] |
| append(3): FULL | allocate capacity=4, copy [1,2], add 3 → [1,2,3,_] |
| append(4) | size=3, capacity=4, array=[1,2,3,4] |
| append(5): FULL | allocate capacity=8, copy [1,2,3,4], add 5 → [1,2,3,4,5,_,_,_] |
| append(6) | size=5, [1,2,3,4,5,6,_,_] |
| append(7) | size=6, [1,2,3,4,5,6,7,_] |
| append(8) | size=7, [1,2,3,4,5,6,7,8] |
| append(9): FULL | allocate capacity=16, copy 8 elements, add 9 → [1..9,_,_,_,_,_,_,_] |
Resizing (expensive copies) happens at sizes 1, 2, 4, 8, 16, 32, ... (powers of 2).
Amortized Analysis — Why Append is O(1)
Aggregate Method
Total cost of n appends = (cost of actual appends) + (total copy cost during all resizes)
Copy costs occur at: sizes 1, 2, 4, 8, 16, ..., n/2 (last resize before n elements)
Total copies = 1 + 2 + 4 + 8 + ... + n/2
This is a geometric series: Σ 2^i from i=0 to log₂(n)-1
Sum = 2^(log₂(n)) - 1 = n - 1
Total cost of n appends:
- n regular appends: O(n)
- Total copies across all resizes: O(n)
- Grand total: O(2n) = O(n)
Average cost per append: O(n) / n = O(1)
This is the amortized O(1) — averaged over a sequence of operations, each append costs O(1).
Potential Method (More Rigorous)
Define the potential function Φ = 2 × size - capacity
Φ represents "credits stored" — work we have pre-paid for future expensive operations.
When we insert into a non-full array:
Actual cost = 1
ΔΦ = 2×(size+1) - capacity - (2×size - capacity) = 2
Amortized cost = actual + ΔΦ = 1 + 2 = 3 = O(1)
When we insert and resize (array was full, capacity = size):
Before: Φ_before = 2×n - n = n (capacity and size were both n)
After: new capacity = 2n, new size = n+1
Φ_after = 2×(n+1) - 2n = 2
Actual cost = n+1 (copy n elements + 1 append)
ΔΦ = 2 - n
Amortized cost = (n+1) + (2-n) = 3 = O(1)
Every operation has amortized cost O(1), confirming that the total cost over n operations is O(n).
Python List Internals
Python's list is a dynamic array implemented in C (CPython). Key details:
Growth Factor
Python does not exactly double. It uses the formula:
new_capacity = old_capacity + old_capacity // 8 + (6 if old_capacity < 9 else 3)
This gives a growth factor slightly less than double (approximately 1.125×) to avoid over-allocation. The sequence of capacities for a Python list starting empty:
0, 4, 8, 16, 25, 35, 46, 58, 72, 88, 106, ...
Time Complexities
lst = []
# append() — O(1) amortized
lst.append(x)
# pop() from end — O(1)
lst.pop()
# pop(i) from position i — O(n-i)
lst.pop(0) # O(n) — worst case
# insert(i, x) — O(n-i)
lst.insert(0, x) # O(n)
lst.insert(len(lst), x) # O(1) amortized (same as append)
# len() — O(1) — stored as attribute
len(lst)
# in operator (list) — O(n)
x in lst # O(n) — linear search
# indexing — O(1)
lst[i]
# slicing — O(k) where k = slice size
lst[a:b] # O(b-a)
Shrinking Behavior
Python also shrinks the array when elements are removed, but uses a more conservative threshold (shrinks when size drops below capacity/4 or similar) to avoid thrashing.
Java ArrayList and C++ std::vector
Java ArrayList
import java.util.ArrayList;
ArrayList<Integer> list = new ArrayList<>(); // Initial capacity = 10
list.add(5); // O(1) amortized
list.add(0, 5); // O(n) — insert at front
list.remove(0); // O(n) — remove from front
list.get(2); // O(1) — random access
list.size(); // O(1)
// Initial capacity hint (avoid early resizes)
ArrayList<Integer> list = new ArrayList<>(1000);
Java's ArrayList uses growth factor 1.5× (adds 50% each resize): new_capacity = old_capacity + (old_capacity >> 1) = old_capacity * 1.5
C++ std::vector
#include <vector>
std::vector<int> v;
v.push_back(5); // O(1) amortized — like append
v.pop_back(); // O(1)
v[2]; // O(1) random access
v.size(); // O(1)
v.capacity(); // Check current allocated capacity
// Reserve to avoid resizes
v.reserve(1000); // Pre-allocate for 1000 elements
// Shrink to fit
v.shrink_to_fit(); // Release excess memory
C++ std::vector uses growth factor 2× (doubles each resize) per most implementations.
Building Your Own Dynamic Array
class DynamicArray:
"""
Implementation of a dynamic array from scratch.
Demonstrates the doubling strategy and all core operations.
"""
def __init__(self, initial_capacity=1):
self._capacity = initial_capacity
self._size = 0
self._data = [None] * self._capacity
def __len__(self):
return self._size
def __getitem__(self, index):
if not (-self._size <= index < self._size):
raise IndexError(f"Index {index} out of range")
if index < 0:
index += self._size
return self._data[index]
def __setitem__(self, index, value):
if not (-self._size <= index < self._size):
raise IndexError(f"Index {index} out of range")
if index < 0:
index += self._size
self._data[index] = value
def append(self, value):
"""Add element at end. O(1) amortized."""
if self._size == self._capacity:
self._resize(2 * self._capacity) # Double capacity
self._data[self._size] = value
self._size += 1
def insert(self, index, value):
"""Insert at position. O(n)."""
if self._size == self._capacity:
self._resize(2 * self._capacity)
# Shift elements right from index to size-1
for i in range(self._size, index, -1):
self._data[i] = self._data[i - 1]
self._data[index] = value
self._size += 1
def remove_at(self, index):
"""Remove element at index. O(n)."""
if not (0 <= index < self._size):
raise IndexError(f"Index {index} out of range")
removed = self._data[index]
# Shift elements left
for i in range(index, self._size - 1):
self._data[i] = self._data[i + 1]
self._data[self._size - 1] = None # Help garbage collection
self._size -= 1
# Shrink if size drops below 1/4 of capacity (avoid thrashing)
if self._size < self._capacity // 4 and self._capacity > 1:
self._resize(max(1, self._capacity // 2))
return removed
def _resize(self, new_capacity):
"""Allocate new array and copy elements. O(n)."""
new_data = [None] * new_capacity
for i in range(self._size):
new_data[i] = self._data[i]
self._data = new_data
self._capacity = new_capacity
print(f" [Resize: capacity → {new_capacity}]") # For demonstration
def __str__(self):
return "[" + ", ".join(str(self._data[i]) for i in range(self._size)) + "]"
def __repr__(self):
return f"DynamicArray(size={self._size}, capacity={self._capacity})"
Testing our implementation:
da = DynamicArray(initial_capacity=2)
print("Appending elements:")
for i in range(1, 9):
da.append(i)
print(f" append({i}): {da}, capacity={da._capacity}")
# Output:
# append(1): [1], capacity=2
# append(2): [1, 2], capacity=2
# [Resize: capacity → 4]
# append(3): [1, 2, 3], capacity=4
# append(4): [1, 2, 3, 4], capacity=4
# [Resize: capacity → 8]
# append(5): [1, 2, 3, 4, 5], capacity=8
# ...
print("\nAccess:", da[3]) # 4
print("Update:", end=" ")
da[3] = 99
print(da) # [1, 2, 3, 99, 5, 6, 7, 8]
print("Remove at 3:", da.remove_at(3)) # 99
print("After removal:", da) # [1, 2, 3, 5, 6, 7, 8]
Memory Usage Analysis
import sys
# Python list memory usage
lst = []
for n in [0, 10, 100, 1000]:
lst = list(range(n))
print(f"n={n}: {sys.getsizeof(lst)} bytes")
# Output (approximate, depends on Python version):
# n=0: 56 bytes (empty list header)
# n=10: 184 bytes (~12 bytes/element overhead + 56 header)
# n=100: 920 bytes
# n=1000: 8056 bytes
# This includes capacity > size due to doubling strategy
# The "wasted" space is part of the amortized O(1) append guarantee
Summary
- Static arrays have fixed size — resizing requires manual allocation and copying
- Dynamic arrays resize automatically using the doubling strategy
- Doubling: When full, allocate 2× capacity, copy all elements
- Amortized analysis: Total copy cost over n appends = O(n), so average per append = O(1)
- Python list: Growth factor ≈ 1.125, O(1) append/pop from end, O(n) insert/remove from middle
- Java ArrayList: Growth factor 1.5×
- C++ std::vector: Growth factor 2×
- The same amortized O(1) trick appears in: hash table resizing, stack using array, queue using circular buffer
*Next Lesson: Multidimensional Arrays — 2D and 3D arrays, matrix operations, and the row-major vs. column-major memory layout.*