DSA Field Notes
Course map / 09 Hashing
NeetCode · DSA for Beginners · Lessons 26-27

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

Analogy

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.

ARRAY: search by walking is it "kim"? ask every box until one says yes alice brad collin brad dylan kim no ① no ② no ③ no ④ no ⑤ yes ⑥ 6 checks → O(n) HASH MAP: search by computing turn the key into a number, use the number as the index "kim" hash() index 5 kim 0 1 2 3 4 5 6 7 1 computation + 1 jump → O(1) on average
Search by computing, not by walking. The array on top has no idea where "kim" is; it must ask each box. The hash map computes 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)

OperationTreeMapHashMapArray
InsertO(log n)O(1)O(n)
RemoveO(log n)O(1)O(n)
SearchO(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.

Keep in mind: Python dict order

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}
names[] loop top to bottom 0 alice 1 brad 2 collin 3 brad 4 dylan 5 kim countMap key value alice1 brad2 collin1 dylan1 kim1 key exists → += 1 new key → insert with 1 6 names in, 5 unique keys out. Time O(n) for the loop; space O(number of unique keys).
Six inputs, five rows. The second "brad" (index 3) does not create a new row; it finds the existing row and turns its value from 1 to 2 (orange). That is the whole trick behind every "count the frequency" problem.
Loop turnnamename not in countMap?actioncountMap after
1aliceTrueinsert 1{alice: 1}
2bradTrueinsert 1{alice: 1, brad: 1}
3collinTrueinsert 1{alice: 1, brad: 1, collin: 1}
4bradFalsebrad += 1 → 2{alice: 1, brad: 2, collin: 1}
5dylanTrueinsert 1{alice: 1, brad: 2, collin: 1, dylan: 1}
6kimTrueinsert 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
Grid cells and sorted words

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:

  1. Take the ASCII code of each character (ord(c) in Python).
  2. Add them up. This sum can be big, larger than the array.
  3. Take sum % capacity so 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.

key "Alice" ① ord(c): ASCII code of each char A l i c e 65 108 105 99 101 ② sum 478 ③ % capacity % 8 index 6 Real ord() sums: Alice 478, Brad 377, Collin 609. In the video NeetCode makes up small sums to keep the maths readable: Alice 25, Brad 27, Collin 33. With capacity 2 the video gets 25 % 2 = 1; the real code gets 478 % 2 = 0. Same pipeline, same idea.
Deterministic: same string in, same index out, every time. That is what makes lookup possible: to find "Alice" later you run the same three steps and land on the same slot. Note the last step depends on 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.

cap 2 Alice 0 1 ① put(Alice): 478 % 2 = 0 → slot 0, size = 1 size 1 ≥ 2 // 2 → half full → double to 4 rehash: 478 % 4 = 2 cap 4 Brad Alice 0 1 2 3 ② put(Brad): 377 % 4 = 1 → slot 1, size = 2 size 2 ≥ 4 // 2 → half full → double to 8 377 % 8 = 1 (stays) 478 % 8 = 6 (moves) cap 8 Brad Alice 0 1 2 3 4 5 6 7 ③ size 2 < 8 // 2 = 4 → no resize. Ready for Collin.
Orange arrows are the rehash. Nothing is copied to "the same place"; every pair is re-inserted with the new capacity. This drawing uses the real 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)sumsum % capacityslotsizehalf full?after
put("Alice", "NYC")2525 % 2 = 1111 ≥ 2//2 yes → cap 4, re-put Alice: 25 % 4 = 1cap 4, size 1, {1: Alice}
put("Brad", "Chicago")2727 % 4 = 3322 ≥ 4//2 yes → cap 8, re-put: Alice 25 % 8 = 1, Brad 27 % 8 = 3cap 8, size 2, {1: Alice, 3: Brad}
put("Collin", "Seattle")3333 % 8 = 1 → taken by Alice → try 2233 ≥ 8//2 nocap 8, size 3, {1: Alice, 2: Collin, 3: Brad}
Call (real ord sums)sumsum % capacityslotsizehalf full?after
put("Alice", "NYC")478478 % 2 = 0011 ≥ 2//2 yes → cap 4, re-put Alice: 478 % 4 = 2cap 4, size 1, {2: Alice}
put("Brad", "Chicago")377377 % 4 = 1122 ≥ 4//2 yes → cap 8, re-put: Alice 478 % 8 = 6, Brad 377 % 8 = 1cap 8, size 2, {1: Brad, 6: Alice}
put("Collin", "Seattle")609609 % 8 = 1 → taken by Brad → try 2233 ≥ 8//2 nocap 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.

Collin 33 % 8 = 1, but index 1 already holds Alice → collision Alice Brad 0 1 2 3 4 5 6 7 CHAINING a linked list per slot Brad 0 1 2 3 4 5 6 7 Alice Collin∅ get: hash → 1, walk: Alice? no. Collin? yes. average O(1); worst (one long chain) O(n) OPEN ADDRESSING next free slot Alice Collin Brad 0 1 2 3 4 5 6 7 put(Collin): 1 is taken → try 2 → empty → store there. get(Collin): 1 is Alice, not it → 2 is Collin → found. every slot holds at most one pair, so entries can never exceed capacity
Same collision, two answers. Chaining keeps Collin at his true index inside a short list. Open addressing walks right to the first empty slot; the lookup must walk the same way. Both keep the average cost O(1) as long as the array stays roomy, which is exactly what resizing at half full guarantees.

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: return None.
  • put: compute the index. Three scenarios. Slot vacant: store the pair, size += 1, and if size >= capacity // 2 call rehash. 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, reset size to 0, and put every 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.

before home = 33 % 8 = 1 Alice Collin Brad 0 1 2 3 4 5 6 7 get(Collin) works: 1 → 2 → found after remove(Alice) get(Collin) ① None Collin Brad 0 1 2 3 4 5 6 7 map[1] is None → loop stops → returns None Collin is still in slot 2, but get() never looks there. The hole lies: "nothing past here".
A hole looks like "end of cluster" to get(). The lesson leaves this as a known limitation. Real open-addressing tables fix it with a tombstone (a special "deleted, keep walking" marker) or by re-inserting the pairs that follow the hole. The check script includes a fixed 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
A second, quieter bug in remove()

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
Keep in mind: Python's dict is not this code

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

OperationHashMapWhy
InsertO(1)hash + a few probes; the occasional rehash is O(n) but amortizes to O(1)
RemoveO(1)hash + a few probes
SearchO(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 []
nums target = 9 2 7 11 15 0 1 2 3 i = 1, x = 7 need = 9 - 7 = 2 seen (value → index) 2 0 key index 2 in seen? yes → return [seen[2], i] = [0, 1] turn 0: x=2, need 7, not seen → seen = {2: 0}. turn 1: x=7, need 2, found. One pass: O(n).
The map remembers the past so you never look back. At each element you ask one O(1) question: "did I already see my partner?". Without the map you would rescan the prefix, O(n) per element, O(n²) total.

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
xprefixprefix - kseen.get(prefix - k)countseen after
start0--0{0: 1}
11-100{0: 1, 1: 1}
12011{0: 1, 1: 1, 2: 1}
13112{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.

SORTED ARRAY 1 3 5 8 9 insert 4: shift 5, 8, 9 → O(n) remove: shift back → O(n) search: binary search O(log n) (unsorted: O(n)) in order: free TREEMAP (BST) 5 3 8 1 9 insert / remove / search: O(log n) inorder traversal: O(n), sorted HASHMAP 1 8 3 9 5 0 1 2 3 4 5 6 key % 7: 1→1, 3→3, 5→5, 8→1 taken → 2, 9→2 taken → 4 insert / remove / search: O(1) avg in order: sort first, O(n log n)
Speed versus order. The array and the tree keep the keys sorted; the hash table scatters them by 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.
OperationHashMap / setTreeMapSorted array
InsertO(1)*O(log n)O(n)
RemoveO(1)*O(log n)O(n)
Search / key in dO(1)*O(log n)O(log n)
Iterate in sorted orderO(n log n)O(n)O(n)
Count frequencies of n itemsO(n)O(n log n)-
SpaceO(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

Using a list as a key

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))].

Expecting the dict to be sorted

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).

KeyError on a key you have not inserted yet

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.

Changing a dict while looping over it

for k in d: del d[k] raises RuntimeError: dictionary changed size during iteration. Loop over a copy: for k in list(d).

Two Sum: inserting before checking

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.

Saying "O(1) worst case"

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.

Removing from an open-addressing table by writing None

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.

Testing the value when you meant the key

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 ==.

Resizing only when the array is full

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.

#ProblemMove it trainsLevel
217Contains Duplicateseen-setEasy
242Valid Anagramfrequency map, build then consumeEasy
1Two Sumcomplement lookup, check before insertEasy
383Ransom Notefrequency map with Counter subtractionEasy
387First Unique Character in a Stringcount, then second pass for count == 1Easy
219Contains Duplicate IIvalue → last index mapEasy
705Design HashSetbuild it: hash, buckets, chainingEasy
706Design HashMapbuild it: Pair, put / get / removeEasy
49Group Anagramscanonical key (sorted tuple or 26-count)Medium
347Top K Frequent ElementsCounter + most_common (or bucket sort by count)Medium
128Longest Consecutive Sequenceset + "is x-1 here?" start checkMedium
560Subarray Sum Equals Kprefix 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.