DSA DSA In Depth
0%
lesson dynamic-arraysamortizedmemory-allocator

Dynamic Arrays: Geometric Growth Factors (2× vs 1.5×) & Memory Reuse

7 min read Module 1.2: Contiguous Arrays, Bit Manipulation & Pointer Idioms Updated Oct 23, 2026
📄 Cheat Sheet
What If You Had to Invent a Dynamic Array? (Geometric Resizing & Amortized O(1))
Reducible What If You Had to Invent a Dynamic Array? (Geometric Resizing & Amortized O(1)) Watch on YouTube ↗
0:00 / --:--

Contiguous Representation and Geometric Growth

At the machine level, a dynamic array (such as C++ std::vector<T>, Rust Vec<T>, or Java ArrayList<E>) is a 24-byte header struct stored on the stack or inline within an object: a base pointer T* data, a current element count size_t size, and an allocated slot count size_t capacity. Whenever size < capacity, appending an element writes directly to data[size++] in a single store instruction with zero allocator interaction.

When size == capacity, the container must allocate a new contiguous region of capacity $\lceil k \cdot C \rceil$, move or copy the existing $C$ elements into the new region, destroy the old elements (if non-trivially destructible), and free the old block. If the array grew arithmetically by a constant $c$ (e.g., adding 100 slots each time), appending $N$ elements would copy $c + 2c + 3c + \dots + N = \Theta(N^2)$ elements, yielding $\Theta(N)$ amortized cost per append. Geometric growth by $k > 1$ ensures that reallocation distances grow exponentially, bounding total copies to a geometric series:

$$\sum_{i=0}^{\lfloor \log_k N \rfloor} k^i = \frac{k^{\lfloor \log_k N \rfloor + 1} - 1}{k - 1} \le \frac{k N - 1}{k - 1} = \Theta(N)$$

Dividing by $N$ insertions yields $1 + \frac{k}{k - 1} = O(1)$ amortized work per append. Notice the trade-off embedded in $\frac{k}{k - 1}$: as $k$ decreases toward $1$, wasted slack memory $(k - 1)N$ shrinks, but the copy multiplier $\frac{1}{k - 1}$ explodes (for $k = 2$, at most $2N$ copies occur; for $k = 1.5$, at most $3N$ copies occur; for $k = 1.1$, $11N$ copies occur).

Architectural Invariant: The Golden Ratio Heap Reuse Bound ($k < \varphi$)

Suppose a dynamic array grows consecutively within a contiguous arena without intervening external allocations, freeing each previous block of size $C, kC, k^2C, \dots, k^{m-1}C$ as it allocates $k^m C$. Can the $m$-th allocation ever fit inside the coalesced sum of all previously freed blocks?

For the $m$-th block of size $k^m$ to fit into the coalesced sum of the prior blocks $\sum_{i=0}^{m-1} k^i$, we require:

$$k^m \le \sum_{i=0}^{m-1} k^i = \frac{k^m - 1}{k - 1}$$

For $k = 2$, $2^m > 2^m - 1$ for every $m \ge 1$! A $2\times$ growth factor guarantees that the newly requested block is always strictly larger (by 1 initial unit) than the sum of every block the vector has ever freed in its lifetime. In the asymptotic limit as $m \to \infty$, the critical polynomial is $k^2 - k - 1 \le 0$, whose positive root is the Golden Ratio $\varphi = \frac{1 + \sqrt{5}}{2} \approx 1.618$. Therefore, any growth factor $1 < k < 1.618$ (such as $k = 1.5$) eventually allows a coalescing allocator to satisfy future reallocations purely from the vector's own previously freed address range.

Comparative Growth Strategies Across Runtimes

Runtime / Implementation Growth Factor ($k$) Amortized Element Moves per Push Max Slack Overhead In-Arena Coalesced Reuse
GCC libstdc++ std::vector / Rust Vec $2.0\times$ $1 / (2 - 1) = 1.0$ $50\%$ of capacity Never ($2^m > \sum_{i<m} 2^i$)
MSVC std::vector / Java ArrayList $1.5\times$ (c + (c >> 1)) $1 / (1.5 - 1) = 2.0$ $33.3\%$ of capacity Yes, after 5th resize ($1.5^4 < 1 + 1.5 + 2.25 + 3.375$)
Meta folly::fbvector $1.5\times$ + jemalloc size classes $\approx 2.0$ (zero-copy via xallocx when possible) Aligned to jemalloc bin Yes + in-place expansion via xallocx
Go slice (runtime.growslice, Go 1.18+) $2.0\times$ ($<256$), smoothly decays to $1.25\times$ $1.0 \to 4.0$ for large slices $20\% - 50\%$ Yes for large slices ($k \le 1.25 < \varphi$)

Visualizing $k=2.0$ Heap Crawl vs. $k=1.5$ Coalesced Reuse

k = 2.0: Monotonic Forward Crawl (Freed sum 1+2+4+8 = 15 < Next allocation 16) 1 2 4 8 (Freed Total = 15) 16 (Active Allocation: Cannot fit in 15!) k = 1.5: Coalesced Heap Reuse (Freed blocks 16 + 24 + 36 + 54 = 130 >= Next allocation 122) 16 24 36 54 (Freed = 130) 81 (Current Active) Next Resize (122) Reuses Coalesced Freed Region (130)!

Reallocation Lifecycle & Exception Safety

flowchart LR A["push_back(val)
size == capacity"] --> B["Compute new_cap =
cap + (cap >> 1)"] B --> C["Allocate raw buffer
aligned_alloc(new_cap)"] C --> D{"is_nothrow_move
_constructible?"} D -- "Yes (Fast Path)" --> E["Move-construct elements
or memcpy if trivial"] D -- "No (Strong Guarantee)" --> F["Copy-construct elements
rollback on throw"] E --> G["Destroy old elements
& free old buffer"] F --> G
Hardware Reality: Trivially Relocatable Types, realloc, and Page Table Remapping

In C++, std::vector cannot blindly call C's realloc() on arbitrary objects because C++ types may contain internal self-referential pointers (such as std::string's Small String Optimization buffer pointer in libstdc++). However, if a type is trivially copyable (or marked FollyIsRelocatable), expansion uses vectorized AVX-512 memcpy at L1/DRAM bandwidth speeds. Even better, when a dynamic array exceeds the mmap threshold (typically 128 KB in glibc ptmalloc), calling mremap() on Linux grows the allocation in $O(1)$ physical memory operations by simply rewiring virtual page table entries (PTEs) to a new virtual address range without copying a single byte of physical DRAM!

Dynamic Array Geometric Doubling & Potential Φ = 2n − m
Amortized O(1)

Step through push(x) operations as capacity doubles (1 → 2 → 4 → 8 → 16). Watch Tarjan’s potential function Φ = 2n − m bank 2 credits on cheap inserts to pay for O(n) reallocation copies.

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Φ(D_i) = 2n − m = 2(1) − 1 = 1 ≥ 0 | ĉ_i = c_i + ΔΦ = 1 + (1 − 0) = 2
🔗 Live Pointer & State Transition Graph — Dynamic Array Geometric Doubling & Potential Φ = 2n − m Active Pointer Jump/Transition Matched/Optimal
NEW (+1 10 [0]
Buffer Capacity m = 1 | Size n = 1 | Load Factor α = 100%
[0] 10 NEW (+1)
Size / Cap (n / m)
1 / 1
Actual Cost c_i
1 (1 write)
Potential Φ = 2n−m
1
Amortized Cost ĉ_i
2 (Avg 1.00)
[Step 1] Inserted 10 at index 0. Actual cost = 1, banked +2 potential (Φ = 1), Amortized cost = 2.

Production Implementation: Aligned 1.5× Vector with Strong Exception Guarantee

#include <cstdlib>
#include <cstring>
#include <memory>
#include <new>
#include <type_traits>
#include <utility>

template <typename T>
class FbStyleVector {
  T* data_ = nullptr;
  std::size_t size_ = 0;
  std::size_t cap_ = 0;

  static std::size_t next_capacity(std::size_t current) noexcept {
    if (current == 0) return 4;
    if (current < 64) return current * 2;
    // 1.5x growth (k = 1.5 < phi) enables coalesced heap reuse
    return current + (current >> 1);
  }

  void grow() {
    const std::size_t new_cap = next_capacity(cap_);
    T* new_data = static_cast<T*>(::operator new(new_cap * sizeof(T), std::align_val_t{alignof(T)}));

    if constexpr (std::is_trivially_copyable_v<T>) {
      if (size_ > 0) {
        std::memcpy(new_data, data_, size_ * sizeof(T));
      }
    } else if constexpr (std::is_nothrow_move_constructible_v<T>) {
      std::uninitialized_move_n(data_, size_, new_data);
      std::destroy_n(data_, size_);
    } else {
      // Strong exception guarantee: copy first; if an exception is thrown,
      // destroy already-copied elements in new_data and leave data_ intact.
      std::size_t constructed = 0;
      try {
        for (; constructed < size_; ++constructed) {
          std::construct_at(new_data + constructed, data_[constructed]);
        }
      } catch (...) {
        std::destroy_n(new_data, constructed);
        ::operator delete(new_data, std::align_val_t{alignof(T)});
        throw;
      }
      std::destroy_n(data_, size_);
    }

    if (data_) {
      ::operator delete(data_, std::align_val_t{alignof(T)});
    }
    data_ = new_data;
    cap_ = new_cap;
  }

public:
  void push_back(const T& value) {
    if (size_ == cap_) grow();
    std::construct_at(data_ + size_, value);
    ++size_;
  }

  std::size_t size() const noexcept { return size_; }
  std::size_t capacity() const noexcept { return cap_; }
};
Production Pitfall: Missing noexcept on Move Constructors & Reference Invalidation

Two bugs routinely cripple C++ std::vector performance and safety in production:

  1. Missing noexcept on custom Move Constructors: Because std::vector::push_back guarantees the Strong Exception Guarantee (if reallocation fails partway, the vector remains unchanged), std::move_if_noexcept refuses to call a throwing move constructor during resize—falling back to deep copy construction of every element! Always mark move constructors and move assignment operators noexcept.
  2. Self-Aliasing in v.push_back(v[0]): If size == capacity, reallocating must construct the new element before freeing the old buffer, or passing an element reference from inside the vector itself will read from freed memory (use-after-free).
Check yourself Question 1 of 4
Concept Check Why 2x Growth Prevents In-Arena Coalesced Heap Reuse recall

Consider a dynamic array growing geometrically by a factor of $k = 2.0$ inside a contiguous heap arena without external allocations, freeing each previous block of size $1, 2, 4, \dots, 2^{m-1}$ as it allocates $2^m$. Why can a coalescing allocator never satisfy the $m$-th allocation purely from the sum of previously freed blocks?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Which implementation sequence inside `push_back(const T& val)` prevents a silent Use-After-Free (dangling reference read) when `val` aliases an element inside `vec` itself?

Key Takeaways

Geometric growth by factor k > 1 bounds total copies over N appends to kN/(k - 1), achieving O(1) amortized append time.
A 2x growth factor guarantees that the newly requested allocation is strictly larger than the sum of all previously freed buffers (2^m > 2^m - 1), preventing in-arena coalesced reuse.
Any growth factor k < phi (approx 1.618), such as k = 1.5, allows the allocator to satisfy future expansions by coalescing the vector's own previously freed memory chunks.
In C++, std::vector only moves elements during reallocation if the element's move constructor is marked noexcept; otherwise it falls back to O(N) deep copies to preserve the Strong Exception Guarantee.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue