Why Dynamic Arrays and Hash Tables Double in Size: Amortized Analysis, Bitwise Modulo, and Shrinking

Arpit Bhayani

Arpit Bhayani

Jul 27, 2022 • 10 min read

Play

Why Dynamic Arrays and Hash Tables Double in Size: Amortized Analysis, Bitwise Modulo, and Shrinking

Dynamic arrays (such as ArrayList in Java, std::vector in C++) and hash tables (such as Python’s dict or Java’s HashMap) share a fundamental design pattern: when their internal storage fills up, their capacity is typically scaled geometrically—most often doubled. Furthermore, their capacities are almost always constrained to powers of two (2,4,8,16,32,2, 4, 8, 16, 32, \dots).

While this pattern is ubiquitous across programming languages and runtimes, its engineering rationale stems from fundamental algorithmic performance constraints, CPU instruction mechanics, and cache and memory allocation dynamics.


1. The Load Factor and the Need for Resizing

A hash table stores entries in an underlying contiguous array of size mm. Its performance is governed by its Load Factor (denoted as α\alpha):

α=nm\alpha = \frac{n}{m}

Where:

  • nn = Number of elements currently stored in the table.
  • mm = Total number of available slots in the underlying array.

As keys are inserted, collisions inevitably increase. In open addressing, higher load factors cause search probe lengths to spike. In separate chaining, linked lists or trees grow longer. When α\alpha approaches 1.01.0, insertion and lookup operations degrade from expected O(1)\mathcal{O}(1) performance toward linear O(n)\mathcal{O}(n) time.

Load Factor (α) = Keys (n) / Slots (m)

Low Load Factor (α = 0.25):     High Load Factor (α = 0.90):
[ K1 |    |    | K2 ]           [ K1 | K2 | K3 | K4 ]
Fast lookups, empty space       Frequent collisions, long probe chains

To maintain near-constant-time efficiency, the hash table must allocate a larger underlying array and migrate existing elements once α\alpha reaches an upper threshold. Common implementations choose an expansion threshold such as α=0.5\alpha = 0.5 or α=0.75\alpha = 0.75.


2. Why Resizing is Expensive (and Why realloc Fails)

Resizing an array-backed structure is not a zero-cost operation:

  1. Memory Allocation: The operating system and runtime allocator must locate a contiguous chunk of memory of size mnewm_{\text{new}}.
  2. Rehashing Elements: For a standard dynamic array (like std::vector), elements can simply be copied in memory via memcpy. In a hash table, however, slot placement depends on the array capacity:

Index=h(key)(modm)\text{Index} = h(key) \pmod{m}

When mm changes from moldm_{\text{old}} to mnewm_{\text{new}}, the modulo mapping shifts for nearly every key. Consequently, elements cannot simply be transferred as a contiguous block using system calls like C’s realloc(). Each key must be individually re-evaluated against the new capacity and mapped to its new location. 3. Deallocation: The old array memory must be reclaimed or garbage-collected.

Because the migration cost scales linearly with the number of keys—taking O(n)\mathcal{O}(n) time—how frequently we trigger resizes dictates the overall performance of the data structure.


3. Mathematical Proof: Incremental Growth vs. Doubling

To understand why doubling (growth factor =2= 2) is preferred, compare it to the conservative alternative: expanding capacity incrementally.

Strategy A: Add 1 Slot on Each Insert (Incremental Growth)
Cost: 1 + 2 + 3 + ... + N = O(N²)

Strategy B: Double Capacity When Full (Geometric Growth)
Cost: Amortized O(1) per insert across all operations

Strategy A: Incremental Growth (+1+1 each time)

Suppose the array starts with capacity 11 and increases by +1+1 slot on every single insertion:

  • Insert 1: Allocate size 1, write 1 key \rightarrow 1 op
  • Insert 2: Allocate size 2, copy 1 key, write 1 key \rightarrow 2 ops
  • Insert 3: Allocate size 3, copy 2 keys, write 1 key \rightarrow 3 ops
  • Insert nn: Allocate size nn, copy n1n-1 keys, write 1 key \rightarrow nn ops

The total work required to perform nn insertions is the sum of an arithmetic progression:

T(n)=i=1ni=n(n+1)2=O(n2)T(n) = \sum_{i=1}^{n} i = \frac{n(n + 1)}{2} = \mathcal{O}(n^2)

Dividing by nn operations yields an amortized insertion cost of O(n)\mathcal{O}(n) per element. Under this strategy, dynamic arrays and hash tables would be completely impractical for large datasets.

Strategy B: Geometric Growth (Doubling)

Now suppose the array capacity doubles whenever it is filled (1248n1 \rightarrow 2 \rightarrow 4 \rightarrow 8 \rightarrow \dots \rightarrow n):

Consider growing the array from capacity nn to 2n2n:

  • The array currently holds n/2n/2 elements from previous operations.
  • The next n/21n/2 - 1 insertions are guaranteed to find free slots immediately without resizing, requiring O(1)\mathcal{O}(1) time each.
  • Upon inserting the nn-th element, the table fills up, triggering a resize:
    1. Allocate new memory of size 2n2n.
    2. Rehash and migrate all nn elements into the new array.
    3. Insert the incoming element.

Let’s quantify the operations executed during this interval:

Work=(n21)×1Free slot insertions+1Triggering insert+2nAllocation overhead+nRehashing/Copying=7n2=O(n)\text{Work} = \underbrace{\left(\frac{n}{2} - 1\right) \times 1}_{\text{Free slot insertions}} + \underbrace{1}_{\text{Triggering insert}} + \underbrace{2n}_{\text{Allocation overhead}} + \underbrace{n}_{\text{Rehashing/Copying}} = \frac{7n}{2} = \mathcal{O}(n)

Total operations over n/2n/2 insertions evaluate to O(n)\mathcal{O}(n). Therefore, the amortized cost per insertion is:

Amortized Cost=O(n)n/2=O(1)\text{Amortized Cost} = \frac{\mathcal{O}(n)}{n/2} = \mathcal{O}(1)

Although an occasional individual insertion incurs an O(n)\mathcal{O}(n) resizing latency spike, the vast majority execute in O(1)\mathcal{O}(1) time. Over any sequence of operations, the average cost per insert remains strictly constant.

Note: This mathematical guarantee holds for any growth factor g>1g > 1 (such as 1.5×1.5\times or 2.0×2.0\times). Doubling (g=2g = 2) is the most widely adopted balance between memory overhead and resize frequency.


4. The Power-of-Two Bitwise Optimization

Most high-performance hash table implementations ensure that their capacity mm is always a power of two (m=2km = 2^k for some integer kk). This design choice exists purely for CPU-level instruction efficiency.

The Problem with Integer Division

Mapping a 32-bit or 64-bit hash integer to a slot index within [0,m1][0, m - 1] requires a modulo operation:

Index=hash(modm)\text{Index} = \text{hash} \pmod{m}

At the silicon level, integer division (idiv on x86) is one of the slowest CPU arithmetic instructions, frequently requiring 10 to 40+ CPU clock cycles depending on processor architecture.

The Solution: Bitwise AND Masking

When the modulus mm is an exact power of two (m=2km = 2^k), the modulo operation can be replaced by a single-cycle bitwise AND operation:

hash(modm)hash & (m1)\text{hash} \pmod{m} \equiv \text{hash} \ \& \ (m - 1)

Why This Works Mechanically

Consider m=4=22m = 4 = 2^2. Then m1=3m - 1 = 3:

  • In binary: 310=0000001123_{10} = 0000\,0011_2

Because m1m - 1 contains binary 11s exclusively in the lowest kk bit positions and 00s everywhere else, performing a bitwise AND acts as a hardware mask, filtering out all higher-order bits and preserving only the remainder:

Input HashBinary RepresentationMask (m1=3m-1 = 3)Bitwise AND ResultStandard Modulo (h(mod4)h \pmod 4)
10000 00010000 00110000 0001 (1)1
20000 00100000 00110000 0010 (2)2
30000 00110000 00110000 0011 (3)3
40000 01000000 00110000 0000 (0)0
50000 01010000 00110000 0001 (1)1
60000 01100000 00110000 0010 (2)2

A bitwise AND (AND instruction) executes in a single clock cycle. Replacing an instruction that takes 20–40 cycles with a 1-cycle instruction on every single hash table lookup, insertion, and deletion results in significant compound speedups.

graph LR
    A[Incoming Hash] --> B[Bitwise AND Mask: m - 1]
    B --> C[Slot Index in Range 0 to m-1]
    style B fill:#f9f,stroke:#333,stroke-width:2px

5. Shrinking and Hysteresis: Avoiding the Thrashing Trap

If a hash table handles millions of insertions and subsequently millions of deletions, leaving the underlying array at its peak capacity wastes substantial memory. However, shrinking the array naively introduces a pathological performance failure known as resizing thrash.

The Ping-Pong Thrashing Problem

Consider an expansion threshold of α=0.5\alpha = 0.5 (50%50\% full). What happens if we shrink the array by half as soon as the load factor drops below 0.50.5?

  1. Suppose capacity m=16m = 16. Inserting the 8th key hits α=0.5\alpha = 0.5, expanding the table to m=32m = 32.
  2. Deleting 1 key leaves 7 elements out of 32 slots. If we shrink when α\alpha drops, the table drops back to m=16m = 16.
  3. A user alternating between single insert and delete operations will trigger an O(n)\mathcal{O}(n) allocation and rehash on every single operation.
Insert key -> Table doubles to 32 (Cost: O(n))
Delete key -> Table halves to 16  (Cost: O(n))
Insert key -> Table doubles to 32 (Cost: O(n))
Delete key -> Table halves to 16  (Cost: O(n))
==> Amortized cost degrades from O(1) to O(n)!

Why Shrink at α=0.25\alpha = 0.25 is Still Risky

What if we shrink when the table is a quarter full (α=0.25\alpha = 0.25)?

  • At m=16m = 16, having 44 elements means α=0.25\alpha = 0.25.
  • Shrinking capacity by half yields m=8m = 8 with 44 elements.
  • Now, 4/8=0.54 / 8 = 0.5, placing the table directly on the verge of its expansion threshold.
  • A single subsequent insertion immediately triggers an expansion back to 1616.

The Hysteresis Buffer: Shrink at α=0.125\alpha = 0.125 (1/81/8)

To decouple expansion and contraction thresholds and preserve amortized bounds, systems employ hysteresis (adding inertia to state changes):

  • Grow: When α0.5\alpha \ge 0.5 (Double capacity: m2mm \rightarrow 2m).
  • Shrink: When α0.125\alpha \le 0.125 (Halve capacity: mm/2m \rightarrow m / 2).
Capacity: 16 slots
0 --------- 2 elements (α=1/8) ---------------- 8 elements (α=1/2) --------- 16
               │                                      │
          Trigger SHRINK                         Trigger GROW
          (Halve to 8)                           (Double to 32)

When a table with 16 slots drops to 2 elements (2/16=0.1252/16 = 0.125) and halves its capacity to 8 slots:

  • The new load factor becomes 2/8=0.252 / 8 = 0.25.
  • To trigger an expansion back to 16 slots, the client must insert 2 more elements (reaching 4/8=0.54/8 = 0.5).
  • To trigger another shrink down to 4 slots, the client must delete elements until reaching 1/81/8 capacity.

This gap guarantees that after any resize, a proportional sequence of operations (Ω(n)\Omega(n)) must occur before another resize can be triggered, mathematically guaranteeing amortized O(1)\mathcal{O}(1) time across mixed workloads.


6. Summary of Core Takeaways

MechanismImplementation DetailPrimary Purpose / Benefit
Geometric DoublingCapacity scales m2mm \rightarrow 2mGuarantees amortized O(1)\mathcal{O}(1) insertions instead of O(n2)\mathcal{O}(n^2) cumulative penalty.
Power of Two Sizesm=2km = 2^kReplaces division-based modulo (hash % m) with bitwise AND (hash & (m - 1)), cutting instruction latency from 20+ cycles to 1 cycle.
RehashingElements recalculate index on resizePreserves uniform key distribution across expanded address spaces (cannot use raw realloc).
Hysteresis ShrinkingGrow at α=0.5\alpha = 0.5, Shrink at α=0.125\alpha = 0.125Prevents pathological resizing thrashing when workloads alternate around capacity boundaries.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses