1. What It Is and Why It Exists
A stores key→value pairs in an array of "buckets," using a hash function to map each key to a bucket index. Lookup, insert, and delete become "compute the hash, jump to that bucket" — average O(1) — instead of the O(n) scan a list or O(log n) walk a tree would need. It exists to answer "have I seen this?" / "what's associated with this?" in constant time, which is the single most common optimization in interviews: replace a nested-loop scan with one hash-map pass and an O(n^2) solution collapses to O(n).
The "why it works" intuition: hashing trades space for time — you allocate more buckets than entries so collisions stay rare, and a good hash spreads keys uniformly so each bucket holds O(1) elements on average. Collisions are resolved by chaining (a list per bucket) or open addressing (probe to the next slot). The catch every interviewer probes: this is average O(1); the worst case is O(n) when many keys collide into one bucket, and keys must be hashable: their hash must stay stable, and equal keys must have equal hashes. Python lists and dictionaries are not hashable; a tuple is hashable only when all of its elements are hashable.
Hash to a bucket, then scan its chain
Hashing jumps to one bucket; chaining stores colliding keys together, so lookup scans only that bucket.
Start with eight empty buckets.
Text version
- Start with eight empty buckets.
- Twelve hashes to twelve, and twelve modulo eight gives bucket four.
- Key three lands in bucket three.
- Key five lands in bucket five.
- Thirteen also maps to bucket five, so it collides with five.
- Chaining keeps both keys in a list within that bucket.
- To find thirteen, hash it and jump straight to bucket five.
- Five is not thirteen, so check the next key.
- Thirteen matches, so lookup ends after two key checks.
- Four entries use eight buckets, trading space for time.
A backend service processes a list of user IDs to find duplicates. The current implementation uses a nested loop, resulting in O(n²) time complexity. A developer refactors it to use a hash set, reducing it to O(n). Which property of hash tables makes this improvement possible?
2. Operations and Complexity
| Operation | Average | Worst | Notes |
|---|---|---|---|
| Insert / update | O(1) amortized | O(n) | Worst = all keys collide; resize is amortized |
Lookup d[k] / k in d | O(1) | O(n) | The reason hash maps win interviews |
| Delete | O(1) | O(n) | — |
| Iterate all entries | O(n) | O(n) | Order = insertion order in modern Python |
| Resize / rehash | O(n) (rare) | O(n) with direct redistribution | Geometric growth amortizes the expected resize cost |
Space is O(n). The most-probed points: (1) average vs worst — say "average O(1), worst O(n) under adversarial collisions"; (2) keys must be hashable, with a stable hash and equality behavior; (3) a hash set is just a without values — use it for membership/dedup. Note dict/set give no sorted order; if you need ordering, that is a tree's job, not a 's.
Average constant time, worst linear time
Hash lookup is constant on average, but collisions can make it linear; balanced tree lookups grow logarithmically.
Hash lookup is constant on average, so its growth curve stays flat.
Text version
- Hash lookup is constant on average, so its growth curve stays flat.
- A balanced tree lookup grows logarithmically as the number of entries increases.
- A list scan grows linearly with the number of entries.
- The flat curve grows slowest, while the linear curve grows fastest.
- At sixty-four entries, the curves reach one, six, and sixty-four.
- Linear growth also describes the worst hash lookup when many keys collide.
A backend service uses a Python dict to cache user session data, keyed by session token. Under normal load, lookups are fast. However, a security researcher discovers that an attacker can craft session tokens that all hash to the same bucket, dramatically slowing the service. What are the correct average-case and worst-case time complexities for a dict lookup, and what does this attack exploit?
3. Implementation
The example implements insertion/update and lookup with a list of entries per bucket. A collision means multiple keys map to the same bucket; the load factor is the number of entries divided by the number of buckets. Capacity must be positive. Deletion is listed above as a standard hash-table operation but is not implemented in this example.
The table gives the usual hash-table bounds when resizing moves each existing entry directly to its new bucket. To reuse code, this example reinserts through put, which scans for duplicate keys. If all keys collide, those scans make its resize—and an insertion that triggers it—O(n^2). Its expected resize cost remains O(n) with well-distributed hashes. Appending existing entries directly to their new buckets avoids the redundant duplicate checks.
class HashMap:
def __init__(self, capacity=8):
self._buckets = [[] for _ in range(capacity)] # chaining: list per bucket
self._n = 0
self._cap = capacity
def _index(self, key):
return hash(key) % self._cap # hash -> bucket; needs a hashable key
def put(self, key, value):
bucket = self._buckets[self._index(key)]
for i, (k, _) in enumerate(bucket): # collision scan: O(1) avg, O(n) worst
if k == key:
bucket[i] = (key, value) # update existing
return
bucket.append((key, value))
self._n += 1
if self._n > self._cap: # load factor > 1 -> grow
self._resize() # expected O(n); collisions can cost more
def get(self, key):
for k, v in self._buckets[self._index(key)]:
if k == key:
return v
raise KeyError(key)
def _resize(self):
old = [pair for b in self._buckets for pair in b]
self._cap *= 2 # geometric growth limits resize frequency
self._buckets = [[] for _ in range(self._cap)]
self._n = 0
for k, v in old:
self.put(k, v)In a real interview, use Python's built-in dict and set — never hand-roll this unless explicitly asked (e.g., LeetCode 706 "Design HashMap"). dict for key→value, set for membership/dedup, collections.Counter for frequency counts, collections.defaultdict(list) for grouping.
Update a match or append and grow
The put method updates a matching key without changing the count, or appends a new key and resizes when the load factor passes one.
With two entries in two buckets, updating key one reads bucket one.
Text version
- With two entries in two buckets, updating key one reads bucket one.
- The loop reads the first stored key, one.
- The stored key matches, so this is an update.
- Replace the pair in place with the new value, z.
- Return without increasing the count of two entries.
- Inserting key three also selects bucket one, causing a collision.
- The scan examines the existing key, one.
- One differs from three, so the scan finds no match.
- Append the new pair to the bucket chain.
- The entry count increases to three.
- Three entries exceed two buckets, so the load factor passes one.
- Resize doubles capacity to four and puts each key in a separate bucket.
A separate-chaining hash table currently has 8 buckets and 8 stored key-value pairs. When a 9th pair is inserted, the table triggers a resize that doubles the bucket count to 16 and rehashes all existing entries. Why does doubling the capacity (rather than, say, adding just 1 bucket) keep the amortized cost of insertions at O(1)?
4. When to Reach for It
- You need
O(1)membership / "have I seen this?" →set. - You need fast key→value lookup (two-sum complement, caching, , index-of) →
dict. - You are computing frequencies / counts / grouping →
Counter/defaultdict. - A brute-force solution does a nested scan to find a matching element → trade the inner loop for a hash map and go from
O(n^2)toO(n). - Do not use a when you need sorted order or range queries — use a balanced BST or another ordered collection. A is useful for repeated min/max extraction, not general range queries.
A backend service receives a list of API request IDs and needs to find all duplicate IDs — ones that appear more than once. The naive implementation uses a nested loop, resulting in O(n²) time. Which refactored approach best reduces this to O(n)?
5. Common Pitfalls
- Unhashable key: using a
list(orset/dict) as a key raisesTypeError. Convert totuple(orfrozenset) only if the contained elements are also hashable. - Claiming guaranteed
O(1): it's averageO(1); worst case isO(n). Say "average" or the interviewer will correct you. - Mutating a key after insertion: if a key object's hash changes, you can never find it again. Keys must be effectively immutable.
- Relying on
dictfor ordering semantics: insertion order is preserved in modern Python, but it is not sorted order — don't confuse the two. KeyErroron missing key: used.get(k, default)ordefaultdict/Counterinstead of bared[k].- Adding or removing dict/set entries while iterating it: can raise
RuntimeErroror skip entries (updating an existing key's value is fine). Iterate overlist(d)or collect changes first.
A backend developer writes a caching layer where cached results are keyed by a list of user-selected filter IDs. The code crashes with a TypeError when inserting into the cache dictionary. What is the correct fix, and why?
6. Interview Cheat Sheet
- " is average
O(1)insert/lookup/delete by trading space for time; worst case isO(n)under collisions." - "I use a
setfor membership, adictfor key→value, andCounter/defaultdictfor frequencies — turning manyO(n^2)scans intoO(n)." - "Keys need a stable hash and equality behavior; a tuple of hashable elements works, while a list does not."
- "If I need sorted order or range queries, a is the wrong tool — use an ordered structure such as a balanced BST. A serves repeated min/max extraction."
Follow-ups:
- "How are collisions handled?" — Chaining (a list per bucket) or open addressing (linear/quadratic probing). Either keeps average
O(1)with a low load factor; resize (double + rehash) keeps inserts amortizedO(1). - "When is it
O(n)?" — When many keys hash to the same bucket (bad hash or adversarial input); the bucket degenerates to a linear scan. - "Hash set vs hash map?" — A set is a hash table storing only keys (membership/dedup); a map stores key→value. Same complexity.