Hashing Field Notes
Hash maps and hash sets: the structure you will use in more interview problems than any other. First how to use them (counting, lookups, grouping), then how they work inside: hash function, resize, rehash, collisions. Every number in the diagrams comes from the lesson's own example.
0Why hashing exists
A coat check at a theatre. You hand over your coat and get ticket number 41. Later you say "41" and the attendant walks straight to hook 41. Nobody searches through every coat. The ticket number is computed from the item, and that number tells you exactly where to look. A hash map does the same: it computes a number from the key and uses that number as the position in an array.
You already know two ways to store key-value pairs. An array gives O(1) access by index but O(n) search by value. A TreeMap (a balanced binary search tree) gives O(log n) for everything and keeps keys sorted. A hash map gives O(1) insert, remove and search on average, by turning each key into an array index with a hash function. The price: keys are stored in no useful order.
NeetCode's rule of thumb, which you should memorize: when a problem says "unique", "count", or "frequency", a hash map or hash set is almost certainly the tool.
hash("kim") % 8 = 5 and goes there directly. The rest of this page explains how that computation works and what happens when two keys compute the same index.1Hash usage: sets, maps, counting
Lesson 26 is about using hash sets and hash maps; lesson 27 is about building one. NeetCode says knowing the usage matters more than knowing the implementation, and interviewers agree: you will use a dict in hundreds of problems and implement one in almost none.
Set = keys only. Map = key-value pairs.
A hash set stores keys and answers one question: "is this key here?". A hash map stores key-value pairs and answers "what value belongs to this key?". Both refuse duplicate keys. In Python a hash set is set and a hash map is dict. Earlier in the course you saw the same two interfaces built on a tree (TreeSet, TreeMap). Same interface, different engine, different trade-offs.
The trade-off table (memorize it)
| Operation | TreeMap | HashMap | Array |
|---|---|---|---|
| Insert | O(log n) | O(1) | O(n) |
| Remove | O(log n) | O(1) | O(n) |
| Search | O(log n) | O(1) | O(log n) if sorted, else O(n) |
| Inorder traversal (visit keys in sorted order) | O(n) | - | - |
The HashMap column is the average case: what happens with a good hash function and few collisions. The worst case is O(n). It is widely accepted, including in interviews, to say "O(1)" for hash map operations. Say "O(1) average" if you want to be exact.
The one thing a tree map does better: order
A hash map is not ordered. You cannot walk its keys from smallest to largest. If you need the keys in sorted order you must sort them first, which costs O(n log n). A tree map gives you sorted order for O(n) with an inorder traversal. So: need sorted keys or "the next larger key"? Tree map. Need fast insert, remove, and lookup? Hash map. In interviews the second case is far more common.
Since Python 3.7 a dict remembers insertion order: list(d) gives keys in the order you added them. That is not the same as sorted order. {"kim": 1, "alice": 1} iterates as kim, alice. To get alice, kim you write sorted(d), and that is the O(n log n) sort NeetCode is talking about.
Counting frequency: the phonebook example
Because a map cannot hold a key twice, and each key carries a value, a map can count. The lesson's example: a list of names, some repeated. Map each name to how many times it appears. For each name: if the key already exists, add 1 to its value; otherwise insert it with value 1.
names = ["alice", "brad", "collin", "brad", "dylan", "kim"]
countMap = {}
for name in names:
# If countMap does not contain name
if name not in countMap:
countMap[name] = 1
else:
countMap[name] += 1
# countMap == {"alice": 1, "brad": 2, "collin": 1, "dylan": 1, "kim": 1}
| Loop turn | name | name not in countMap? | action | countMap after |
|---|---|---|---|---|
| 1 | alice | True | insert 1 | {alice: 1} |
| 2 | brad | True | insert 1 | {alice: 1, brad: 1} |
| 3 | collin | True | insert 1 | {alice: 1, brad: 1, collin: 1} |
| 4 | brad | False | brad += 1 → 2 | {alice: 1, brad: 2, collin: 1} |
| 5 | dylan | True | insert 1 | {alice: 1, brad: 2, collin: 1, dylan: 1} |
| 6 | kim | True | insert 1 | {alice: 1, brad: 2, collin: 1, dylan: 1, kim: 1} |
Time and space of counting
The loop touches each of the n names once and every map operation is O(1), so counting is O(n). With a tree map each insert costs O(log n), so the same loop costs O(n log n). Space is O(n) in the worst case, more precisely proportional to the number of unique keys: five rows here, not six.
The same count, the Python way
NeetCode's if / else is the clearest version. In real code, Python gives you three shorter tools. All four produce the same dictionary; the check script proves it.
from collections import Counter, defaultdict
# 1. dict.get with a default: "current count, or 0 if missing, plus 1"
count = {}
for name in names:
count[name] = count.get(name, 0) + 1
# 2. defaultdict(int): a missing key is created as 0 the first time you touch it
count = defaultdict(int)
for name in names:
count[name] += 1
# 3. Counter: the whole loop in one call
count = Counter(names)
count["brad"] # 2
count["nobody"] # 0, no KeyError (a plain dict would raise)
count.most_common(1) # [("brad", 2)]
# set: keys only. "how many unique names?"
len(set(names)) # 5
"brad" in set(names) # True, O(1)
What can be a key?
A key must be hashable: Python must be able to turn it into a fixed number, and that number must never change. So the key must be immutable. str, int, float, bool, tuple and frozenset are hashable. list, dict and set are not, because you could change them after inserting and the stored hash would be wrong.
d = {}
d["alice"] = 1 # str ok
d[42] = 2 # int ok
d[(1, 2)] = 3 # tuple ok (this is how you key by a pair, a grid cell, a sorted word)
d[[1, 2]] = 4 # TypeError: unhashable type: 'list'
hash("Alice") == hash("Alice") # True: same key, same number, always
When your natural key is a list (a coordinate [r, c], a sorted word sorted(s), a 26-letter count), convert it: (r, c), tuple(sorted(s)), "".join(sorted(s)). You will do this in Group Anagrams and in every grid problem that keeps a visited set.
2Under the hood: hash function, resize, collisions
You are unlikely to be asked to implement a hash map from scratch, but the ideas show up in many algorithms and in system design (distributed caches, load balancing, sharding all use hashing). Hash maps are most commonly built on arrays. An empty hash map has size zero, but the underlying array does not start at size zero; NeetCode's starts at capacity 2.
The example pairs for the whole lesson:
hashmap["Alice"] = "NYC"
hashmap["Brad"] = "Chicago"
hashmap["Collin"] = "Seattle"
The hash function: string in, index out
To store "Alice" in O(1) we need a hash function: it takes the key and converts it into an integer, and that integer is the array index where the pair lives. Three requirements: the result must be a valid index; the same string must always produce the same integer; and ideally two different keys should not produce the same index. NeetCode's function is the simplest possible one:
- Take the ASCII code of each character (
ord(c)in Python). - Add them up. This sum can be big, larger than the array.
- Take
sum % capacityso the result is between 0 and capacity - 1.
Step 1-2 (turning the key into some big integer) is sometimes called pre-hashing; step 3 squeezes it into the array.
capacity, which is why resizing forces every key to be re-hashed (next).With an array of size 2 there are only two possible indexes, so a second key will very often land on the same one. Two keys mapping to the same index is a collision. Collisions are very common; they can be minimized but never fully avoided. Two things fight collisions: keeping the array roomy (resize) and having a plan for when they happen anyway (chaining or open addressing).
Resizing: double when half full
The map tracks two numbers. capacity is the length of the underlying array. size is how many pairs are stored. When space runs low, create a new array with 2 * capacity, exactly like the dynamic array chapter. The difference from a dynamic array: to keep collisions rare, we resize when the array is half full, not completely full. With capacity 2, inserting "Alice" makes size 1, which is half of 2, so the array doubles to 4 right away. The resize happens the moment the array becomes half full, not when the next pair arrives.
Rehashing: every key moves
After resizing, the pairs cannot simply be copied to the same indexes. The index was sum % capacity and capacity just changed, so the index changes too. Rehashing means: run every existing pair through the hash function again with the new capacity and re-insert it. In the video, Alice's made-up sum 25 gives 25 % 2 = 1 and then 25 % 4 = 1: she happens to stay at index 1. If the new value were 2 she would move to 2. Brad's 27 gives 27 % 4 = 3, index 3. Then the array is half full again (2 of 4), so it doubles to 8.
ord() sums (Alice 478, Brad 377) because they show the move clearly: Alice goes 0 → 2 → 6. In the video, with the made-up sum 25, Alice lands on index 1 at every size and Brad (27) on index 3; the process is identical.The three puts, traced
Two tables, same code, same three calls. The first uses the video's made-up sums so you can follow along with NeetCode. The second uses the real sums you will see when you run the check script. In both, notice when the resize happens: inside the put that made the array half full.
| Call (video sums) | sum | sum % capacity | slot | size | half full? | after |
|---|---|---|---|---|---|---|
| put("Alice", "NYC") | 25 | 25 % 2 = 1 | 1 | 1 | 1 ≥ 2//2 yes → cap 4, re-put Alice: 25 % 4 = 1 | cap 4, size 1, {1: Alice} |
| put("Brad", "Chicago") | 27 | 27 % 4 = 3 | 3 | 2 | 2 ≥ 4//2 yes → cap 8, re-put: Alice 25 % 8 = 1, Brad 27 % 8 = 3 | cap 8, size 2, {1: Alice, 3: Brad} |
| put("Collin", "Seattle") | 33 | 33 % 8 = 1 → taken by Alice → try 2 | 2 | 3 | 3 ≥ 8//2 no | cap 8, size 3, {1: Alice, 2: Collin, 3: Brad} |
| Call (real ord sums) | sum | sum % capacity | slot | size | half full? | after |
|---|---|---|---|---|---|---|
| put("Alice", "NYC") | 478 | 478 % 2 = 0 | 0 | 1 | 1 ≥ 2//2 yes → cap 4, re-put Alice: 478 % 4 = 2 | cap 4, size 1, {2: Alice} |
| put("Brad", "Chicago") | 377 | 377 % 4 = 1 | 1 | 2 | 2 ≥ 4//2 yes → cap 8, re-put: Alice 478 % 8 = 6, Brad 377 % 8 = 1 | cap 8, size 2, {1: Brad, 6: Alice} |
| put("Collin", "Seattle") | 609 | 609 % 8 = 1 → taken by Brad → try 2 | 2 | 3 | 3 ≥ 8//2 no | cap 8, size 3, {1: Brad, 2: Collin, 6: Alice} |
Both traces end with capacity 8, size 3, and a collision on index 1 that was solved by moving one step right. Only the names in the slots differ.
Collisions: two keys, one index
In the video, "Collin" hashes to 33, and 33 % 8 = 1: the same index as Alice. Two ideas that do not work: overwrite the slot (Alice is lost), or keep growing the array until the two keys separate (may never happen, and wastes huge memory). Two strategies that do work:
1. Chaining. Each slot holds a linked list. Alice and Collin both live at index 1, chained together. To look up Collin: hash to 1, walk the short chain. Worst case, if every key lands on one index, search and insert become O(n); on average the chains are tiny and it is O(1).
2. Open addressing. No slot may hold more than one pair. If index 1 is taken, try 2, then 3, and so on until an empty slot appears. Lookup does the same walk: start at the hashed index and keep checking the following slots until you find the key or hit an empty slot. This is the version NeetCode codes. It is more efficient than chaining when collisions are rare, but it has a hard limit (you can never store more pairs than the capacity) and it is trickier to get right. NeetCode's advice: stick with chaining unless you have a specific reason.
Why a prime capacity helps
NeetCode mentions that it is mathematically better to choose a prime number (divisible only by 1 and itself) for the capacity. The reason in one example: suppose your keys happen to be multiples of 4 (4, 8, 12, 16). With capacity 8, the indexes are 4, 0, 4, 0: two collisions, because 4 and 8 share a factor. With capacity 7, the indexes are 4, 1, 5, 2: all different. A prime shares no factor with any key pattern, so patterns in the keys do not turn into patterns in the slots. NeetCode uses 2, 4, 8 in the lesson for simplicity; real implementations either use primes or use a much better hash function so that the doubling does not matter.
3The code: open addressing, line by line
The lesson's implementation stores a list of Pair objects. size is the number of pairs in the map; capacity is the length of the underlying array. This is NeetCode's code exactly; the check script runs it on the three names and asserts every state in the trace tables above.
class Pair:
def __init__(self, key, val):
self.key = key
self.val = val
class HashMap:
def __init__(self):
self.size = 0 # pairs stored
self.capacity = 2 # length of the array
self.map = [None, None]
def hash(self, key):
index = 0
for c in key:
index += ord(c) # sum the ASCII codes
return index % self.capacity
def get(self, key):
index = self.hash(key)
while self.map[index] != None: # stop at the first empty slot
if self.map[index].key == key:
return self.map[index].val
index += 1 # open addressing: look in the next slot
index = index % self.capacity # wrap around at the end
return None
def put(self, key, val):
index = self.hash(key)
while True:
if self.map[index] == None: # 1. vacant: store here
self.map[index] = Pair(key, val)
self.size += 1
if self.size >= self.capacity // 2: # half full → grow now
self.rehash()
return
elif self.map[index].key == key: # 2. same key: update the value
self.map[index].val = val
return
# 3. different key: try the next slot
index += 1
index = index % self.capacity
def remove(self, key):
if not self.get(key):
return
index = self.hash(key)
while True:
if self.map[index].key == key:
# Removing an element using open-addressing actually causes a bug,
# because we may create a hole in the list, and our get() may
# stop searching early when it reaches this hole.
self.map[index] = None
self.size -= 1
return
index += 1
index = index % self.capacity
def rehash(self):
self.capacity = 2 * self.capacity
newMap = []
for i in range(self.capacity):
newMap.append(None)
oldMap = self.map
self.map = newMap
self.size = 0 # put() will count the pairs again
for pair in oldMap:
if pair:
self.put(pair.key, pair.val) # re-insert with the NEW capacity
def print(self):
for pair in self.map:
if pair:
print(pair.key, pair.val)
What each method does
- hash: sum the ASCII codes (
ord) of the characters, then% capacity. - get: compute the index. While the slot is not empty: if the key matches, return its value; otherwise move to the next slot, wrapping with
%. If you reach an empty slot the key is not here: returnNone. - put: compute the index. Three scenarios. Slot vacant: store the pair,
size += 1, and ifsize >= capacity // 2callrehash. Slot holds the same key: update the value. Slot holds a different key: move to the next slot and check again. - remove: if the key is absent, return. Otherwise find the slot holding the key and set it to
None. This creates the hole described next. - rehash: double
capacity, build a new empty array, resetsizeto 0, andputevery existing pair again. The re-puts cannot trigger another rehash: the old pair count was half of the old capacity, which is a quarter of the new one.
hm = HashMap()
hm.put("Alice", "NYC") # capacity 2 → 4
hm.put("Brad", "Chicago") # capacity 4 → 8
hm.put("Collin", "Seattle") # collision on 1, stored at 2
hm.get("Collin") # "Seattle"
hm.get("Dylan") # None
hm.capacity, hm.size # (8, 3)
The removal hole: why this remove() is buggy
The comment in remove is the most important line in the lesson. Open addressing relies on one invariant: "if I walk from a key's home index and hit an empty slot, the key is not in the map". Removing by writing None breaks that promise. Concrete case from the video: Collin's home index is 1, but he sits at 2 because Alice was at 1. Remove Alice, and slot 1 becomes a hole. Now get("Collin") starts at 1, sees None, and the while loop ends before it ever looks at slot 2. Collin is still in the array, but unreachable.
remove that re-puts the rest of the cluster, and it passes.# the concrete case, video numbers (Alice 25, Brad 27, Collin 33)
lm.put("Alice", "NYC"); lm.put("Brad", "Chicago"); lm.put("Collin", "Seattle")
# slots: {1: Alice, 2: Collin, 3: Brad}
lm.remove("Alice") # slot 1 becomes None
lm.map[2].key # "Collin" (still there)
lm.get("Collin") # None (BUG: stopped at the hole)
# one fix: after clearing the slot, re-put everything that follows in the cluster
class HashMapFixed(HashMap):
def remove(self, key):
index = self.hash(key)
while self.map[index] is not None:
if self.map[index].key == key:
self.map[index] = None
self.size -= 1
index = (index + 1) % self.capacity
while self.map[index] is not None: # everyone after the hole...
pair = self.map[index]
self.map[index] = None
self.size -= 1
self.put(pair.key, pair.val) # ...is inserted again, so Collin slides into 1
index = (index + 1) % self.capacity
return
index = (index + 1) % self.capacity
if not self.get(key): return asks "is the value falsy?", not "is the key absent?". Store put("zero", 0) and remove("zero") does nothing, because get returns 0. The check script proves it. Fix: search for the key directly (as the fixed version does), or compare with is None. Related style point: Python compares with None using is / is not, not == / !=; NeetCode's != None works but a reviewer will flag it.
Chaining in code (for comparison)
The lesson describes chaining in words only. Here it is in Python so you can see why it is simpler: every slot is a small list, and remove has no hole problem because deleting from one chain never hides another key.
class ChainedHashMap:
def __init__(self, capacity=8):
self.capacity = capacity
self.size = 0
self.buckets = [[] for _ in range(capacity)] # each slot: a list of [key, val]
def hash(self, key):
return sum(ord(c) for c in key) % self.capacity
def get(self, key):
for k, v in self.buckets[self.hash(key)]: # walk one short chain
if k == key:
return v
return None
def put(self, key, val):
bucket = self.buckets[self.hash(key)]
for item in bucket:
if item[0] == key:
item[1] = val # update
return
bucket.append([key, val]) # append to the chain
self.size += 1
def remove(self, key):
bucket = self.buckets[self.hash(key)]
for i, item in enumerate(bucket):
if item[0] == key:
bucket.pop(i)
self.size -= 1
return
CPython's dict uses open addressing with a much better probe sequence, a real hash function (hash()), resizing at two-thirds full, and a separate insertion-order array. hash("Alice") is a large, process-specific number, not 478. The lesson's version is a teaching model; the ideas (hash, mod, resize, rehash, collide, probe) are the same.
Time complexity of the implementation
| Operation | HashMap | Why |
|---|---|---|
| Insert | O(1) | hash + a few probes; the occasional rehash is O(n) but amortizes to O(1) |
| Remove | O(1) | hash + a few probes |
| Search | O(1) | hash + a few probes |
All three are O(1) on average, assuming a good hash function and few collisions. The worst case, when everything collides into one chain or one long run of slots, is O(n). Amortized means "averaged over many operations": one rehash costs O(n), but it happens only every n/2 inserts, so each insert pays O(1) on average, exactly as with dynamic arrays.
4Five moves that solve most hashing problems
The lessons teach counting. Interviews use the dict and set in five recurring ways. Each template below is tested in the check script.
Move 1: Frequency map
When: the problem says count, frequency, most common, anagram, or "same letters". Count one thing, then compare or consume the counts.
def is_anagram(s, t): # LC 242
if len(s) != len(t):
return False
count = {}
for ch in s:
count[ch] = count.get(ch, 0) + 1 # build
for ch in t:
if count.get(ch, 0) == 0:
return False
count[ch] -= 1 # consume
return True
# one-liners, same idea:
Counter(s) == Counter(t)
[x for x, _ in Counter(nums).most_common(k)] # LC 347 Top K Frequent
Move 2: Seen-set
When: duplicates, "unique", "have I met this before", visited cells in a grid. Membership test is O(1), so the whole pass is O(n).
def contains_duplicate(nums): # LC 217
seen = set()
for x in nums:
if x in seen:
return True
seen.add(x)
return False
# or: len(set(nums)) != len(nums)
def longest_consecutive(nums): # LC 128: the set answers "is x-1 here?" in O(1)
s = set(nums)
best = 0
for x in s:
if x - 1 not in s: # x is the START of a run; only starts do work
length = 1
while x + length in s:
length += 1
best = max(best, length)
return best # [100,4,200,1,3,2] → 4
Move 3: Complement lookup
When: "find two things that combine to a target". Instead of checking every pair (O(n²)), remember what you have seen and ask the map for the partner you need. Check before you insert, so an element cannot pair with itself.
def two_sum(nums, target): # LC 1
seen = {} # value → index
for i, x in enumerate(nums):
need = target - x
if need in seen: # partner already seen?
return [seen[need], i]
seen[x] = i # insert AFTER the check
return []
Move 4: Group by a canonical key
When: "group the items that are the same under some rule" (anagrams, rotations, same digit-sum). Compute one canonical key (a standard form that all members share) and use it as the dict key. For anagrams, either the sorted string or a 26-letter count tuple.
def group_anagrams(strs): # LC 49
groups = defaultdict(list)
for s in strs:
key = tuple(sorted(s)) # "eat","tea","ate" → ('a','e','t'); O(k log k) per word
groups[key].append(s)
return list(groups.values())
def group_anagrams_count(strs): # same, with an O(k) key
groups = defaultdict(list)
for s in strs:
count = [0] * 26
for ch in s:
count[ord(ch) - ord("a")] += 1
groups[tuple(count)].append(s) # tuple, because a list cannot be a key
return list(groups.values())
# ["eat","tea","tan","ate","nat","bat"] → [["eat","tea","ate"], ["tan","nat"], ["bat"]]
Move 5: Prefix sum + hashmap
When: "how many subarrays sum to k" or "longest subarray with sum k". A subarray sum is the difference of two prefix sums: sum(i..j) = prefix[j] - prefix[i-1]. So at each position ask the map: "how many earlier prefixes equal prefix - k?". Seed the map with {0: 1} for subarrays that start at index 0.
def subarray_sum(nums, k): # LC 560
count = 0
prefix = 0
seen = {0: 1} # prefix sum → how many times it appeared
for x in nums:
prefix += x
count += seen.get(prefix - k, 0) # each earlier prefix of (prefix - k) closes one subarray
seen[prefix] = seen.get(prefix, 0) + 1
return count # [1,1,1], k=2 → 2
| x | prefix | prefix - k | seen.get(prefix - k) | count | seen after |
|---|---|---|---|---|---|
| start | 0 | - | - | 0 | {0: 1} |
| 1 | 1 | -1 | 0 | 0 | {0: 1, 1: 1} |
| 1 | 2 | 0 | 1 | 1 | {0: 1, 1: 1, 2: 1} |
| 1 | 3 | 1 | 1 | 2 | {0: 1, 1: 1, 2: 1, 3: 1} |
Trace of [1, 1, 1], k = 2. The two subarrays are [1,1] at 0..1 (prefix 2 - prefix 0) and [1,1] at 1..2 (prefix 3 - prefix 1).
5Big-O summary
The three structures side by side, drawn. Say the HashMap column with the word "average" and the interviewer will trust you.
key % capacity (here capacity 7, with linear probing). That scattering is exactly what makes lookup O(1), and exactly why there is no cheap way to walk the keys in order.| Operation | HashMap / set | TreeMap | Sorted array |
|---|---|---|---|
| Insert | O(1)* | O(log n) | O(n) |
| Remove | O(1)* | O(log n) | O(n) |
Search / key in d | O(1)* | O(log n) | O(log n) |
| Iterate in sorted order | O(n log n) | O(n) | O(n) |
| Count frequencies of n items | O(n) | O(n log n) | - |
| Space | O(unique keys) | O(unique keys) | O(n) |
| Rehash (one resize) | O(n) | - | - |
The asterisk: O(1) is the average with a good hash function and few collisions; the worst case is O(n). Insert is also amortized: one insert in every n/2 triggers an O(n) rehash, which averages out to O(1) per insert. Interviewers accept "O(1)" for hash map operations; saying "O(1) average, O(n) worst" shows you understand why.
6Mistakes to watch for
d[[r, c]] or d[sorted(s)] raises TypeError: unhashable type: 'list'. Keys must be immutable. Convert: d[(r, c)], d[tuple(sorted(s))], d["".join(sorted(s))].
A Python dict iterates in insertion order, never in sorted order. If the answer needs "smallest key first", write for k in sorted(d) and remember to count the O(n log n).
count[x] += 1 crashes the first time it sees x. Use count[x] = count.get(x, 0) + 1, or defaultdict(int), or Counter. Same for lists: defaultdict(list) instead of checking if k not in groups.
for k in d: del d[k] raises RuntimeError: dictionary changed size during iteration. Loop over a copy: for k in list(d).
If you write seen[x] = i before if need in seen, then for [3, 2, 4] with target 6 the element 3 pairs with itself and you return [0, 0]. Check first, insert after.
Hash map operations are O(1) on average. The worst case, when many keys collide, is O(n). Say "average" and mention collisions; that is the answer interviewers want.
The lesson's own bug. A None in the middle of a run of probes tells get "nothing past here", so keys placed after it become unreachable. Use a tombstone marker, re-insert the rest of the cluster, or use chaining.
if not self.get(key) treats value 0, "" or None as "key absent". Use if key not in d for dicts, or search for the key directly in your own implementation. And compare with None using is, not ==.
An almost-full open-addressing table probes long runs of slots and every operation drifts toward O(n). Resize at half full (NeetCode) or two-thirds (CPython) so collisions stay rare, and remember that resizing means rehashing every key, not copying.
Interview one-liners
- "The problem says unique / count / frequency, so I will use a hash map. Insert, remove and lookup are O(1) average."
- "A hash map turns the key into an array index with a hash function: hash the key, then mod the capacity."
- "Collisions are handled by chaining, a linked list per slot, or open addressing, probing the next free slot. Average stays O(1) as long as the table is kept roomy by resizing."
- "Resizing doubles the array and rehashes every key, because the index depends on the capacity. That is O(n) once, amortized O(1) per insert."
- "A hash map has no order. If I need sorted keys I sort them, O(n log n), or I use a tree map, O(log n) per operation but O(n) in-order traversal."
- "Counting n items costs O(n) time and O(unique keys) space."
- "In Python:
dict,set,Counter,defaultdict. Keys must be immutable: strings, ints, tuples, not lists."
7Practice, in this order
Do these on LeetCode with the notes closed. For each one, say the move out loud before you type ("seen-set", "complement lookup") and then write it.
| # | Problem | Move it trains | Level |
|---|---|---|---|
| 217 | Contains Duplicate | seen-set | Easy |
| 242 | Valid Anagram | frequency map, build then consume | Easy |
| 1 | Two Sum | complement lookup, check before insert | Easy |
| 383 | Ransom Note | frequency map with Counter subtraction | Easy |
| 387 | First Unique Character in a String | count, then second pass for count == 1 | Easy |
| 219 | Contains Duplicate II | value → last index map | Easy |
| 705 | Design HashSet | build it: hash, buckets, chaining | Easy |
| 706 | Design HashMap | build it: Pair, put / get / remove | Easy |
| 49 | Group Anagrams | canonical key (sorted tuple or 26-count) | Medium |
| 347 | Top K Frequent Elements | Counter + most_common (or bucket sort by count) | Medium |
| 128 | Longest Consecutive Sequence | set + "is x-1 here?" start check | Medium |
| 560 | Subarray Sum Equals K | prefix sum + hashmap, seed {0: 1} | Medium |
Your next step today: LeetCode 1, Two Sum. Write it from memory with the check-then-insert order, run it on [3, 2, 4], target 6, and confirm you get [1, 2], not [0, 0].
8Self-check
Answer out loud before opening. If you cannot answer in one breath, reread that section.
Q1Which three words in a problem statement should make you reach for a hash map or hash set?
"Unique", "count", "frequency". Also anything like "have I seen this before" or "how many times".
Q2What is the difference between a hash set and a hash map? Name the Python type for each.
A set stores keys only and answers "is it here?" (set). A map stores key-value pairs and answers "what value goes with this key?" (dict). Neither allows duplicate keys.
Q3Why can a tuple be a dict key but a list cannot?
Keys must be hashable, which requires them to be immutable. A tuple cannot change after creation, so its hash stays valid. A list can be modified after insertion, so its stored hash would become wrong; Python refuses with TypeError: unhashable type: 'list'.
Q4Is a Python dict sorted? How do you iterate its keys in sorted order, and what does that cost?
No. Since 3.7 it keeps insertion order, which is not sorted order. Use sorted(d), which costs O(n log n). A tree map gives sorted order in O(n) with an inorder traversal; that is the one place it beats a hash map.
Q5Walk "Alice" through NeetCode's hash function with capacity 8. What index comes out?
ASCII codes 65 + 108 + 105 + 99 + 101 = 478. Then 478 % 8 = 6. (In the video NeetCode makes up the sum 25, giving 25 % 8 = 1.)
Q6When does the lesson's map resize, and why not wait until it is full?
Inside put, right after an insert makes size >= capacity // 2: at half full, not when the next pair arrives. Waiting until full would make collisions (and long probe runs) very common; keeping the table at most half full keeps operations O(1) on average.
Q7What is rehashing and why is it unavoidable after a resize?
Re-inserting every existing pair into the new array by running the hash function again. It is unavoidable because the index is sum % capacity; when capacity changes from 4 to 8 the index of most keys changes too (Alice with sum 478: 2 → 6).
Q8Name the two collision strategies and say which one NeetCode recommends by default.
Chaining (a linked list per slot) and open addressing (probe the next free slot). NeetCode recommends chaining unless you have a specific reason; open addressing is more efficient with few collisions but caps the entries at the capacity and is harder to get right, as the removal bug shows.
Q9Trace it: using the video's sums (Alice 25, Brad 27, Collin 33), give capacity, size and the occupied slots after each of the three puts.
put(Alice): 25 % 2 = 1, size 1 = half → resize to 4, re-put 25 % 4 = 1. State: cap 4, size 1, {1: Alice}. put(Brad): 27 % 4 = 3, size 2 = half → resize to 8: Alice 25 % 8 = 1, Brad 27 % 8 = 3. State: cap 8, size 2, {1: Alice, 3: Brad}. put(Collin): 33 % 8 = 1, taken → 2 is free. State: cap 8, size 3, {1: Alice, 2: Collin, 3: Brad}.
Q10Trace it: from the state in Q9, call remove("Alice") then get("Collin"). What is returned and why?
None. remove sets slot 1 to None. get("Collin") hashes to 1, sees None, and the while self.map[index] != None loop ends immediately, so it never reaches slot 2 where Collin still is. The hole is mistaken for "end of the cluster". Fix with a tombstone or by re-inserting the pairs after the hole.
Q11Counting the frequency of n names: time and space with a hash map, and time with a tree map?
Hash map: O(n) time (n operations at O(1) each), O(n) space, more precisely proportional to the number of unique keys. Tree map: O(n log n) time because each insert is O(log n).
Q12Trace it: two_sum([3, 2, 4], 6). What is seen after each turn, and what is returned?
Turn 0: x = 3, need = 3, not in seen (checked before inserting), seen = {3: 0}. Turn 1: x = 2, need = 4, not in seen, seen = {3: 0, 2: 1}. Turn 2: x = 4, need = 2, in seen at index 1 → return [1, 2]. If you had inserted before checking, turn 0 would have returned [0, 0].
Q13Why does a prime capacity reduce collisions?
If keys share a pattern (all multiples of 4, say) and the capacity shares a factor with that pattern (8), the keys pile onto a few slots: 4, 8, 12, 16 → 4, 0, 4, 0. A prime like 7 shares no factor with anything, so 4, 8, 12, 16 → 4, 1, 5, 2, all different.