Dynamic arrays and hash maps
Understand amortized O(1) append and average O(1) hashing, along with their failure modes.
The two data structures behind nearly every program are the dynamic array
(Python's list, C++'s vector) and the hash map (dict, unordered_map)[1].
Both promise constant-time operations under realistic assumptions; both have failure
modes when those assumptions break[2]. Understanding the internals is what lets you
recognize the failure modes when they happen.
Dynamic arrays
A dynamic array stores elements in a contiguous block of memory. The basic operations are:
- Indexed access
a[i]: , a single pointer arithmetic. - Append
a.append(x): amortized via doubling (previous lesson). - Insert/delete at position : , every later element must shift.
Contiguity is the win and the cost. Cache prefetchers love sequential access of an array; the CPU pipelines hundreds of elements ahead. The penalty for inserting in the middle is the same contiguity: there is no empty slot to slide into without moving everyone after it.
Hash maps
A hash map stores key-value pairs and answers "what is the value for this key?" in average . The keys can be anything hashable (strings, tuples, custom objects). The structure has three pieces:
- A hash function that maps every key to an integer.
- An array of buckets, indexed by .
- A collision-resolution strategy when two keys land in the same bucket.
To insert or look up a key, compute and search within that bucket. With a good hash function, keys spread roughly uniformly over buckets, and each bucket holds items on average.
Collisions
Collisions are unavoidable: keys into buckets means some bucket holds at least items. Two standard remedies:
- Chaining. Each bucket is a linked list (or small array) of the items that hash there. Insertion appends to the list; lookup scans it.
- Open addressing. Each bucket holds at most one item; on collision, probe a deterministic sequence of alternative slots ( for linear probing).
In both cases, the average lookup time stays as long as the load factor stays bounded (say below 0.75). The implementation resizes the backing array (doubling and rehashing all keys) when exceeds a threshold, keeping the amortized cost per operation .
When it goes wrong
Hash maps degrade to per operation when:
- The hash function clusters. All keys hash to the same bucket, giving one giant
list. The pathological case is an adversary choosing keys to collide, the basis of
HashDoS attacks. Python's
hash()randomizes its seed each interpreter start precisely to defeat this. - The load factor is too high. Slow lookups even with a good hash, because buckets fill up.
The custom-hash trap: if you override __hash__ on an object, a careless
implementation (always returning 0, or ignoring fields used in equality) destroys
lookup. The invariant is that equal objects must hash to the same value.
Check it on arrays
# Hash map from scratch with chaining.
class HashMap:
def __init__(self, capacity=8):
self.capacity = capacity
self.size = 0
self.buckets = [[] for _ in range(capacity)]
def _bucket(self, key):
return self.buckets[hash(key) % self.capacity]
def get(self, key, default=None):
for k, v in self._bucket(key):
if k == key: return v
return default
def set(self, key, value):
b = self._bucket(key)
for i, (k, _) in enumerate(b):
if k == key: b[i] = (key, value); return
b.append((key, value))
self.size += 1
if self.size > self.capacity * 0.75: # load factor threshold
self._rehash()
def _rehash(self):
old = [(k, v) for b in self.buckets for k, v in b]
self.capacity *= 2
self.buckets = [[] for _ in range(self.capacity)]
self.size = 0
for k, v in old: self.set(k, v)
# Insert lots of items, check distribution and lookups
h = HashMap()
for i in range(2000):
h.set(f"key-{i}", i * i)
print("size:", h.size, " capacity:", h.capacity)
print("lookup key-1234:", h.get("key-1234"))
bucket_sizes = [len(b) for b in h.buckets]
print(f"avg bucket: {sum(bucket_sizes)/len(bucket_sizes):.2f}, "
f"max bucket: {max(bucket_sizes)}")
# Demonstrate degradation: every key collides via a bad hash.
class BadKey:
def __init__(self, n): self.n = n
def __hash__(self): return 0 # always collide!
def __eq__(self, other): return self.n == other.n
bad = HashMap()
for i in range(500): bad.set(BadKey(i), i)
loads = [len(b) for b in bad.buckets]
print(f"bad hash: one bucket has {max(loads)} entries, others have {min(loads)}")
print("lookup is now O(n) instead of O(1)")The chained hash map keeps buckets small on average, the load-factor threshold triggers resizing, and the deliberately bad hash collapses every key into one bucket, showing how a poor hash destroys the constant-time guarantee.
Exercises
Exercise 1. A hash map uses chaining with buckets and the hash , so a key lands in bucket . Insert the keys in that order. Show the contents of each bucket, the load factor, and the worst-case lookup cost (longest chain).
Solution
Step 1. Compute each bucket index : , , , , .
Step 2. Drop the keys into their buckets, appending in insertion order:
- bucket 0: empty
- bucket 1: empty
- bucket 2:
- bucket 3:
Step 3. The load factor is . The longest chain is bucket 3 with 3 entries, so a worst-case lookup scans 3 items: here equals 3 comparisons.
Exercise 2. A program overrides __hash__ to always return 0 while keeping a
correct __eq__. Argue that lookups stay correct but the time per operation
degrades to , and state which hash-map invariant is and is not violated.
Solution
Step 1. The required invariant is that equal objects hash to the same value. A constant hash satisfies it trivially: if two objects are equal they both hash to 0. So lookups remain correct, since every key that could match is in the one bucket being searched.
Step 2. The performance assumption (keys spread roughly uniformly over
buckets) is destroyed. With , all keys land in bucket 0, forming
one chain of length . A lookup must scan that whole chain, so each get and
set is .
Step 3. Resizing does not help: doubling and rehashing still sends every key to bucket . The structure has collapsed to a linked list with hash-map overhead. Correctness invariant holds; the uniform-spread assumption, which the bound rests on, does not.
Exercise 3 (code). Implement open-addressing with linear probing in a fixed array of capacity (no resizing). Insert keys , which all hash to slot , and confirm they occupy slots by probing forward. Then verify a lookup finds each key.
Solution
import numpy as np
m = 8
EMPTY = -1
slots = np.full(m, EMPTY)
def insert(key):
i = key % m
while slots[i] != EMPTY: # linear probe to the next free slot
i = (i + 1) % m
slots[i] = key
return i
def find(key):
i = key % m
while slots[i] != EMPTY:
if slots[i] == key:
return i
i = (i + 1) % m
return -1
placed = [insert(k) for k in (0, 8, 16)]
assert placed == [0, 1, 2] # all collided at 0, probed forward
assert [find(k) for k in (0, 8, 16)] == [0, 1, 2]
print("slots:", slots.tolist())The three colliding keys cascade into the next free slots , and each later lookup re-walks the same probe sequence to land on its key.
The next lesson covers the linear structures: linked lists, stacks, and queues.