Notes & software courses · Free to learn
Aph's Blog
On this page

Hash Map / Set — Fundamentals & Mental Models

👋 อ่านฟรีทั้งหมดบน Aph's Blog — เนื้อหาภาษาไทย ทำตามทีละหน้าใน sidebar ได้เลย หากมีข้อเสนอแนะหรืออยากให้เพิ่มหัวข้อไหน บอกได้เสมอ

A structure that "computes position from the value itself," so lookups skip scanning — in Python: set, dict, Counter, defaultdict. Seven parts: the mechanism, the key rule, four tools, six usage patterns, and when not to use it.

Problems in this section look different on the surface, but they all ask the same core questions: "have I seen this before?", "how many times does this appear?", "what overlaps between these two groups?" — answered naively by scanning the whole collection every time. A hash map answers them in near-constant time, no matter how large the data gets.

It is not free — you pay with memory. You build an extra structure to "remember" what you have already seen. That trade is called a space-time tradeoff, and it is the heart of this whole section.

How to read this page

Parts 1–2 are "why it is fast". Parts 3–4 are "what tools you get". Part 5 is the core — 6 usage patterns you must tell apart. If short on time, read parts 5 and 7 at minimum.

Part 1 · The problem hash maps solve — see it first

Task: given two piles a and b, find values in b that do not appear in a at all. The naive way is to take each value from b and scan every value in a — which is exactly what `x in a` does when a is a list. Let's expand that and count comparisons.

Compare two approaches and count comparisonspython
a = [4, 9, 5, 2, 7]
b = [9, 4, 9, 8, 4, 3]

# Approach 1 — check with a list (expand what `x in a` does)
steps = 0
res = []
for x in b:
    found = False
    for y in a:                # scan every item in a until found or exhausted
        steps += 1
        if x == y:
            found = True
            break
    if not found:
        res.append(x)
print("list :", res, "| compared", steps, "times")

# Approach 2 — check with a set
a_set = set(a)                 # convert once; pay one chunk of extra memory
res2 = [x for x in b if x not in a_set]
print("set  :", res2, "| compared", len(b), "times")
Output
list : [8, 3] | compared 16 times
set  : [8, 3] | compared 6 times

16 down to 6 is not exciting on a tiny pile. The point is how these numbers grow: with a list, as a grows, every query gets slower (≈ size(b) × size(a)). With a set, whether a has 5 or 5 million items, one query costs the same — total work is just size(b). In Big-O: O(n·m) vs O(n+m).

One sentence to remember from this part

If a loop asks "is this in that pile?" or "have I seen this before?", immediately suspect that pile should be a set or dict, not a list — the most common signal in this section.

Part 2 · How it finds things without scanning

The question you need answered before trusting O(1): if it does not scan item by item, how does it know where something is? A hash table does not "search" — it computes which slot the item must live in.

In a list, position and value are unrelated. The number 7 might sit at index 0 or 900 — you cannot know ahead of time, so you walk. A hash table changes the rule: run the value through hash(), then mod by the number of slots. That gives the slot index. To look it up later, compute the same formula and go straight there.

From key → number → slot (assume 8 slots)python
for k in [7, 15, 23, 42, 100]:
    print(f"key {k:<4} hash = {hash(k):<4} -> slot {hash(k) % 8}")
Output
key 7    hash = 7    -> slot 7
key 15   hash = 15   -> slot 7
key 23   hash = 23   -> slot 7
key 42   hash = 42   -> slot 2
key 100  hash = 100  -> slot 4
Hash table lookup: key through hash() then mod to a slot, then walk straight to that slot
Lookup is not a search — compute the slot from the key and go straight there (example: key = 42 → slot 2)

We use ints here because for small non-negative integers, hash(k) equals k, so key → slot is easiest to see (not a general rule — e.g. hash(-1) is -2, not -1). For str, hash is a large number and changes every process for security. Try print(hash("apple")) across two runs — do not write code that depends on raw hash values.

Notice: 7, 15, and 23 all land in slot 7. That is a collision, and it is unavoidable when you map unbounded keys into finitely many slots. Python then probes for the next open slot; lookup follows the same probe sequence comparing with == until it finds the real key or an empty slot — so one lookup is not always "one step".

Hash collision: several keys map to the same slot; probe for the next open slot and compare with ==
Collision: 7, 15, 23 all want slot 7 — the first stays in 7, the rest move to later open slots. Lookup must use ==, not slot number alone.

That is why every table says average O(1), not plain O(1). Python keeps the average fast by resizing (growing the table) when it gets full, so plenty of empty slots remain, collisions stay rare, and probe chains stay short.

What worst-case O(n) looks like

If every key hashes to the same number, everything piles into one region and one lookup compares almost every item — back to O(n) like a list. You can force this with a class whose __hash__ always returns the same value, then count how many == calls one lookup needs.

Proof that collisions can destroy O(1)python
class Key:
    calls = 0                          # shared counter for == calls

    def __init__(self, v, h):
        self.v, self.h = v, h

    def __hash__(self):
        return self.h                  # you control the hash

    def __eq__(self, other):
        Key.calls += 1
        return self.v == other.v


# Normal case — different hashes, items spread out
s_good = {Key(i, i) for i in range(1000)}
Key.calls = 0                          # reset after build; count only lookup
Key(999, 999) in s_good
print("well distributed : == called", Key.calls, "times")

# Worst case — every hash is 0, everything piles up
s_bad = {Key(i, 0) for i in range(1000)}
Key.calls = 0
Key(999, 0) in s_bad
print("all collide      : == called", Key.calls, "times")
Output
well distributed : == called 1 times
all collide      : == called 1345 times
Does this matter on LeetCode?

In practice almost never — Python's hash for int, str, and tuple distributes well. All LC75 problems in this section can safely assume average O(1). Still know it because (1) in interviews, say "average case" after "O(1)", and (2) if you write a buggy __hash__ yourself, this is the failure mode.

Part 3 · The one rule for keys — must be hashable

From part 2, storage location depends on the key's hash. If the key can change after insert, its hash changes, the slot it should live in changes, and the item is effectively lost. Python forbids mutable objects as keys up front — that property is called hashable.

What can be a key vs what cannotpython
d = {}
d[3] = "int ok"
d["abc"] = "str ok"
d[(1, 2)] = "tuple ok"        # immutable -> ok
print(d)

try:
    d[[1, 2]] = "list?"        # mutable -> not ok
except TypeError as e:
    print("list  ->", type(e).__name__ + ":", e)

try:
    d[{1, 2}] = "set?"         # mutable -> not ok (frozenset is ok)
except TypeError as e:
    print("set   ->", type(e).__name__ + ":", e)
Output
{3: 'int ok', 'abc': 'str ok', (1, 2): 'tuple ok'}
list  -> TypeError: unhashable type: 'list'
set   -> TypeError: unhashable type: 'set'

unhashable type: 'list' is the error you will see often here. The fix is almost always one line: convert the list to a tuple before using it as a key — equal tuples share the same hash, so they compare correctly (this becomes pattern 4 next). But there is a catch worth knowing: a tuple is not automatically hashable. It is hashable only if every element inside it is hashable too. So tuple([1, 2]) works, but a tuple with a nested list such as (1, [2]) still raises unhashable type: 'list'. With nested lists you must convert every level to tuples.

The tuple caveat — hashable only if every element inside is hashablepython
rows = [[1, 2], [3, 4], [1, 2]]
print("plain tuple works    :", {tuple(r) for r in rows})

nested = [[1, [2]], [3, [4]]]
try:
    {tuple(r) for r in nested}
except TypeError as e:
    print("tuple with a list    ->", type(e).__name__ + ":", e)

def deep(x):
    return tuple(deep(i) for i in x) if isinstance(x, list) else x

print("convert every level  :", {deep(r) for r in nested})
Output
plain tuple works    : {(1, 2), (3, 4)}
tuple with a list    -> TypeError: unhashable type: 'list'
convert every level  : {(1, (2,)), (3, (4,))}

The trap nobody warns you about: 1, True, and 1.0 are the same key

Samness as a key is not about type — it is about equal hash and == being True. 1, True, and 1.0 all satisfy that.

python
d = {}
d[1] = "one"
d[True] = "true"
d[1.0] = "one-point-zero"
print(d)                                        # only one key left!
print("1 == True == 1.0 :", 1 == True == 1.0)
print("same hashes      :", hash(1) == hash(True) == hash(1.0))
Output
{1: 'one-point-zero'}
1 == True == 1.0 : True
same hashes      : True

The displayed key stays 1 (the first insert), but the value is the last write — later inserts update, they do not add a new key. Silent breakage, no exception.

Part 4 · Four tools — pick the right one

Everything in this section is a hash table. They differ only in "what rides along with the key" and "what you get for free". This table is all you need.

ToolStoresFree benefitsReach for it when
setkeys onlydedupe + set ops (& | - ^)question is "is it there?", not "how many?"
dictkey → any valueinsertion order (Python 3.7+)you need side data, e.g. an index
Counterkey → countcount in one line, missing key → 0, most_common()the problem is pure "counting"
defaultdictkey → typed defaultno need to check if key existsvalue is a collection, e.g. list or set
Four equivalent ways to count frequenciespython
from collections import defaultdict, Counter

words = ["a", "b", "a", "c", "a", "b"]

c1 = {}                          # 1) check if key exists first
for w in words:
    if w not in c1:
        c1[w] = 0
    c1[w] += 1

c2 = {}                          # 2) .get(key, default) — shorter, no if
for w in words:
    c2[w] = c2.get(w, 0) + 1

c3 = defaultdict(int)            # 3) missing key starts at int() = 0
for w in words:
    c3[w] += 1

c4 = Counter(words)              # 4) count in one line

print("1) if/else       :", c1)
print("2) .get(w, 0)    :", c2)
print("3) defaultdict   :", dict(c3))
print("4) Counter       :", c4)
print("all equal        :", c1 == c2 == dict(c3) == dict(c4))
Output
1) if/else       : {'a': 3, 'b': 2, 'c': 1}
2) .get(w, 0)    : {'a': 3, 'b': 2, 'c': 1}
3) defaultdict   : {'a': 3, 'b': 2, 'c': 1}
4) Counter       : Counter({'a': 3, 'b': 2, 'c': 1})
all equal        : True
The sharpest set pitfall

Sets do not preserve order. For str keys, order can reshuffle every process (str hashes are randomized). Never rely on set order — to dedupe while keeping first-seen order use list(dict.fromkeys(nums)). Problems that return a set often say "in any order", which is itself a hint that a set is fine.

Reading a missing key — three tools, three behaviors

python
from collections import Counter

count = {"a": 3}
print('count.get("z", 0)     :', count.get("z", 0))      # safe default
print('Counter("aaa")["z"]   :', Counter("aaa")["z"])     # Counter returns 0
try:
    print(count["z"])                                     # plain dict -> boom
except KeyError as e:
    print('count["z"]            : KeyError', e)
Output
count.get("z", 0)     : 0
Counter("aaa")["z"]   : 0
count["z"]            : KeyError 'z'
defaultdict creates a key just from a "read"python
from collections import defaultdict

d = defaultdict(int)
print('"z" in d before read :', "z" in d)
_ = d["z"]                          # read only — no assignment
print('"z" in d after read  :', "z" in d, "<- created by reading")
print("d =", dict(d), "| len(d) =", len(d))
Output
"z" in d before read : False
"z" in d after read  : True <- created by reading
d = {'z': 0} | len(d) = 1

If the problem asks "how many distinct values?" and you casually print(d[x]) while debugging, len(d) silently drifts. Counter does not — it returns 0 without creating the key.

Part 5 · Six usage patterns you must separate

This is the heart of the page. Only four tools (part 4), but six distinct questions you ask them. People stuck here usually know dict — they just remember "use a hash map" without knowing what to ask it.

Each pattern below has the same three parts: the question → a skeleton (not runnable) → a runnable example. Read all six before the problems, then ask yourself "which pattern is this?"

Pattern 1 · Membership — "is this in that pile?"

Simplest and most common. You only care present/absent — not count, not position. Tool: set. Build the set once before the loop, not every iteration.

Skeleton for pattern 1python
pool = set(the_collection_to_check)   # once, before the loop
for x in data:
    if x in pool:                     # O(1) each query
        ...
Live — values in one pile but not the otherpython
a, b = [4, 9, 5], [9, 8, 4, 3]

seen = set(a)
print("in b but not a :", [x for x in b if x not in seen])

# if duplicates in the answer do not matter, set ops are shorter
print("set difference :", set(b) - set(a))
Output
in b but not a : [8, 3]
set difference : {8, 3}
Pattern 1 · summary

Appears in problem 20 · Find the Difference of Two Arrays (both directions: set(a)-set(b) and set(b)-set(a)). Trap: results are sets (unordered) — wrap with list(...) if a list is required.

Pattern 2 · Frequency — "how many times does each appear?"

Step up from "yes/no" to "how many". A set is not enough — you need a dict of numbers. Counter does it in one line.

Skeleton for pattern 2python
from collections import Counter

count = Counter(data)      # count the whole collection
count[x]                   # how many times x appears — 0 if missing, no error
Live — count characterspython
from collections import Counter

c = Counter("abracadabra")
print("Counter         :", c)              # ordered by frequency
print('c["a"] / c["z"]  :', c["a"], "/", c["z"])
print("top 2           :", c.most_common(2))
Output
Counter         : Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
c["a"] / c["z"]  : 5 / 0
top 2           : [('a', 5), ('b', 2)]
Pattern 2 · summary

Base for patterns 3 and 4. Counter(data) works on any iterable — str, list, generator. Trap: Counter returns 0 for missing keys, so if c[x] is falsy for both "missing" and "zero count" — separate those cases when the problem does.

Pattern 3 · Count of counts — "what does the frequency pattern look like?"

The most missed pattern in this section. The problem no longer asks about the data values — it asks about the counts themselves. Count with Counter, then drop the keys and reason over .values() as a second layer.

Skeleton for pattern 3python
from collections import Counter

count = Counter(data)
vals = list(count.values())      # drop keys; keep only "how many times"

# second-layer questions, e.g.
len(set(vals)) == len(vals)      # are all frequencies unique?
sorted(vals) == sorted(other)    # do two piles share the same frequency bag?
Live — are all frequencies unique?python
from collections import Counter

for s in ["aabbbc", "aabbc"]:
    c = Counter(s)
    v = list(c.values())
    print(f"{s:<8} -> {dict(c)}")
    print(f"{'':8}    freqs = {sorted(v)} | all unique? {len(set(v)) == len(v)}")
Output
aabbbc   -> {'a': 2, 'b': 3, 'c': 1}
            freqs = [1, 2, 3] | all unique? True
aabbc    -> {'a': 2, 'b': 2, 'c': 1}
            freqs = [1, 2, 2] | all unique? False

Read len(set(v)) == len(v): set(v) drops duplicates in v; if length is unchanged, there were none — the standard "are all unique?" check. Notice we stack set on top of Counter: two tools, two layers, one problem.

Pattern 3 · summary

Problem 21 · Unique Number of Occurrences (len(set(v)) == len(v)) and problem 22 · Determine if Two Strings Are Close (sorted(x.values()) == sorted(y.values()) plus set(x) == set(y)). The key is "count, then count again" — not a single counting pass.

Pattern 4 · Composite key — "several pieces as one key"

When what you compare is a whole bundle — a matrix row, a pair (x, y) — not a single value. Lists cannot be keys (part 3). Convert to a tuple so the whole bundle becomes one lookup key.

Skeleton for pattern 4python
from collections import Counter

count = Counter(tuple(item) for item in data)   # list -> tuple first
count[tuple(another_bundle)]                    # whole-bundle compare in O(1)
Live — how many (row, column) pairs look the samepython
from collections import Counter

grid = [[3, 2, 1],
        [1, 7, 6],
        [2, 7, 7]]

rowc = Counter(tuple(r) for r in grid)     # count rows; whole row is the key
print("row counts :", rowc)

cols = list(zip(*grid))                    # zip(*grid) = transpose → columns
print("columns    :", cols)

for col in cols:
    print(f"   col {col} -> matching rows: {rowc[col]}")

print("answer =", sum(rowc[col] for col in cols))
Output
row counts : Counter({(3, 2, 1): 1, (1, 7, 6): 1, (2, 7, 7): 1})
columns    : [(3, 1, 2), (2, 7, 7), (1, 6, 7)]
   col (3, 1, 2) -> matching rows: 0
   col (2, 7, 7) -> matching rows: 1
   col (1, 6, 7) -> matching rows: 0
answer = 1

The whole problem collapses into the code above because the hash map does the heavy lifting: without it you compare every row to every column cell-by-cell (three nested loops). With a row as one key, each compare is O(1) — one loop left.

Pattern 4 · summary

Problem 23 · Equal Row and Column Pairs. Remember: tuple(...) for hashability, then Counter. Build columns with tuple(grid[i][j] for i in ...) or zip(*grid). Trap: add rowc[col], not +1 — duplicate-looking rows each form their own pair.

Pattern 5 · Seen-so-far — "has the partner I need already walked by?"

Patterns 1–4 build the map first, then use it. Pattern 5 uses it while still building — one pass. At each step ask "has the value I need already passed?" If yes, done; if not, record yourself for someone later. Often called one-pass.

Skeleton for pattern 5python
seen = {}                       # or set() if you do not need the index
for i, x in enumerate(data):
    if needed_value in seen:        # 1) ask first
        ...                         #    found — done
    seen[x] = i                     # 2) then record yourself — order matters

Best example: LC1 Two Sum — the Two Pointers page left this hanging ("two pointers cannot keep original indices because sorting destroys them"). Pattern 5 solves it: it remembers which value sat at which index, so no sort is needed.

Live — LC1 Two Sum in one pass, no sort, indices intactpython
nums = [3, 9, 4, 1]
target = 12
seen = {}                       # value -> index seen so far

for i, x in enumerate(nums):
    need = target - x           # the partner that would complete target
    print(f"i={i} x={x} | need {need} | seen={seen} -> seen? {need in seen}")
    if need in seen:
        print("answer indices:", (seen[need], i))
        break
    seen[x] = i                 # not found yet -> leave yourself for later
Output
i=0 x=3 | need 9 | seen={} -> seen? False
i=1 x=9 | need 3 | seen={3: 0} -> seen? True
answer indices: (0, 1)
Pattern 5 · summary

Critical trap: check first, then record. Swap those lines and the current value pairs with itself when 2*x == target — e.g. nums = [6, 3], target = 12 wrongly returns (0, 0). Not in the four problems of this section directly, but shows up in Prefix Sum, Sliding Window, and interviews constantly.

Pattern 6 · Grouping — "bucket items by a key you compute"

Patterns 1–5 use numeric values or no value. Pattern 6 uses a collection (list/set) as the value and dumps items that share a computed key into the same bucket. The key is not in the raw data — you invent it. Equal computed keys → same group.

Skeleton for pattern 6python
from collections import defaultdict

groups = defaultdict(list)          # missing key starts as []
for item in data:
    key = compute_key(item)         # the part you design per problem
    groups[key].append(item)        # same key → same bucket
Live — group anagramspython
from collections import defaultdict

words = ["eat", "tea", "tan", "ate", "nat", "bat"]
groups = defaultdict(list)

for w in words:
    key = "".join(sorted(w))        # sorted letters → anagrams share a key
    groups[key].append(w)
    print(f"{w} -> key '{key}'")

print("result:", list(groups.values()))
Output
eat -> key 'aet'
tea -> key 'aet'
tan -> key 'ant'
ate -> key 'aet'
nat -> key 'ant'
bat -> key 'abt'
result: [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]

The whole trick is key = "".join(sorted(w)) — choosing what to compute as the key is the problem. Once the key is right, append into defaultdict is identical every time.

Pattern 6 · summary

Not in the four problems of this section, but the same shape as building an adjacency list in Graph (defaultdict(list) then append neighbors) — heavy in sections 12–13. Learn it now and Graph becomes mostly about traversal.

Summary table of the six patterns

PatternQuestion askedToolKey lineCourse #
1 · Membershipis this in that pile?setx in pool / set(b) - set(a)20
2 · Frequencyhow many times each?CounterCounter(data)[x]base of 21–23
3 · Count of countswhat do the frequencies look like?Counter + set/sortedlen(set(c.values())) == len(c)21, 22
4 · Composite keydoes this whole bundle match?Counter of tuplesCounter(tuple(r) for r in data)23
5 · Seen-so-farhas my partner already passed?dict (value → index)if need in seen: ... then seen[x] = iLC1, §4
6 · Groupinghow do these group together?defaultdict(list)groups[computed_key].append(item)§12–13
How to pick a pattern on a real problem

Ask in order: need counts? if not → pattern 1. If yes: does it ask about the data or about the counts? counts → 3, data → 2. Is the key a bundle or a single value? bundle → 4. Need positions / one pass? → 5. Is the answer groups? → 6.

Part 6 · When a hash map is the wrong tool

Hash maps are fast because they throw away "order" and "nearness" — they only keep "present/absent" and "how many". Problems that need what they threw away cannot use this move.

SituationWhy notUse instead
range queries, e.g. "how many values between 3 and 7?"hash answers exact equality only; scanning all keys defeats the pointsorted array + binary search (§15)
repeatedly take min/max while data changesmin/max over a dict is O(n) every timeheap / priority queue (§14)
need sorted outputsets are unordered; dicts only keep insertion ordersorted(...) at the end, or sort up front
key is a list / mutable objectunhashable — cannot be a key at allconvert to tuple / frozenset first (pattern 4)
tiny fixed domain, e.g. a–z onlyworks but wastes memory vs a plain arraylist of size 26 indexed by ord(c) - ord('a')
Memory cost (CPython 3.10)python
import sys

n = 1000
print("list(range(1000)) :", sys.getsizeof(list(range(n))), "bytes")
print("set(range(1000))  :", sys.getsizeof(set(range(n))), "bytes")
print("dict.fromkeys     :", sys.getsizeof(dict.fromkeys(range(n))), "bytes")
Output
list(range(1000)) : 8056 bytes
set(range(1000))  : 32984 bytes
dict.fromkeys     : 36960 bytes

About 4× — because of part 2: hash tables keep many empty slots to avoid collisions. Pack them like a list and they stop being fast. That is the concrete "trade memory for time" from the opening. Almost never a LeetCode issue, but know what you are paying.

Part 7 · Common traps

  • Writing if x in some_list inside a loop → accidental O(n²). Fix: set(...) once before the loop. #1 trap in this section.
  • Relying on set order → unstable (for str, changes every process). Dedupe keeping order: list(dict.fromkeys(nums)).
  • Reading d[key] when key may be missing → KeyError. Use d.get(key, default) or if key in d (Counter is safe; plain dict is not).
  • Reading d[key] on a defaultdict while debugging → key is created for real; len(d) and loops drift with no error.
  • Using a list as a key → TypeError: unhashable type: 'list'. Always tuple first (pattern 4).
  • In pattern 5, writing seen[x] = i before the check → current item pairs with itself. Check first, then record.
  • Forgetting 1, True, 1.0 are the same key; and key in d checks keys only, not values (v in d.values() is O(n)).
  • Mutating keys while iterating for k in d → RuntimeError: dictionary changed size during iteration. Iterate a copy: for k in list(d).
Ready for the problems

This section has 4 problems, each mapped to a pattern in part 5: 20 · Difference of Two Arrays = pattern 1 → 21 · Unique Number of Occurrences = pattern 3 → 22 · Two Strings Are Close = pattern 3 (two layers) → 23 · Equal Row and Column Pairs = pattern 4. Hit next to start problem 20.