Hash Tables
If arrays are “index in, value out”, hash tables are ”anything in,
value out, still fast.” That trick — turning an arbitrary key into an array
index — is what makes dictdict and setset the single most-used tools in
interview Python.
What you’ll learn
- What hashing actually does: key → hash → bucket index.
- Collisions, and the two standard fixes: chaining and open addressing.
- Why
dictdict/setsetgive average lookup, and the worst case. - Core idioms:
getget,setdefaultsetdefault,defaultdictdefaultdict,CounterCounter. - The frequency-table and seen-set patterns, runnable.
- LeetCode-style problems to drill the pattern.
Hashing: key to bucket, in O(1)
A hash table is really just an array (buckets) plus a hash function that turns any key into an index into that array. Insert and lookup both start the same way: hash the key, jump straight to that bucket — no scanning required.
# Python exposes the hash function directly via hash()
print(hash("apple"))
print(hash(42))
print(hash((1, 2, 3))) # tuples are hashable, lists are NOT
# a tiny hash table, built by hand, to show the idea
num_buckets = 8
def bucket_for(key):
return hash(key) % num_buckets
for key in ["apple", "banana", "cherry", "date"]:
print(f"{key:<8} -> bucket {bucket_for(key)}")# Python exposes the hash function directly via hash()
print(hash("apple"))
print(hash(42))
print(hash((1, 2, 3))) # tuples are hashable, lists are NOT
# a tiny hash table, built by hand, to show the idea
num_buckets = 8
def bucket_for(key):
return hash(key) % num_buckets
for key in ["apple", "banana", "cherry", "date"]:
print(f"{key:<8} -> bucket {bucket_for(key)}")Collisions: two keys, one bucket
With a fixed number of buckets, two different keys will eventually hash to the same index — a collision. There are two classic fixes:
- Chaining — each bucket holds a small list; colliding keys just get appended to that bucket’s list.
- Open addressing — on a collision, probe forward to the next free slot instead of using a list per bucket.
CPython’s dictdict/setset use an open-addressing variant internally, but the
concept is the same either way: collisions are handled inside the table,
invisibly to you.
graph TD
A["Hash keys 'apple' and 'grape' collide -> both hash to bucket 3"] --> B["Chaining"]
A --> C["Open addressing"]
B --> B1["Bucket 3 holds a small list: ['apple', 'grape']"]
C --> C1["'apple' goes in bucket 3; 'grape' probes forward to bucket 4"]
dictdict / setset: O(1) average, in practice
nums = list(range(50_000))
lookup_set = set(nums)
target = 49_999
# list membership: O(n) -- must scan, possibly the whole thing
found_in_list = target in nums
# set membership: O(1) average -- hash once, check one bucket
found_in_set = target in lookup_set
print("found in list:", found_in_list)
print("found in set: ", found_in_set)
print("same answer, wildly different cost per lookup")nums = list(range(50_000))
lookup_set = set(nums)
target = 49_999
# list membership: O(n) -- must scan, possibly the whole thing
found_in_list = target in nums
# set membership: O(1) average -- hash once, check one bucket
found_in_set = target in lookup_set
print("found in list:", found_in_list)
print("found in set: ", found_in_set)
print("same answer, wildly different cost per lookup")Core dict idioms
Four small idioms replace almost every “check if key exists first” pattern you’d otherwise write by hand.
from collections import defaultdict, Counter
d = {"a": 1, "b": 2}
# get: lookup with a default, no KeyError, no "if key in d" needed
print(d.get("a")) # 1
print(d.get("z")) # None
print(d.get("z", 0)) # 0
# setdefault: get-or-insert in one call
d.setdefault("c", []).append(10)
d.setdefault("c", []).append(20)
print(d) # {"a": 1, "b": 2, "c": [10, 20]}
# defaultdict: every missing key gets a default value automatically
groups = defaultdict(list)
for word in ["cat", "car", "dog", "do"]:
groups[word[0]].append(word)
print(dict(groups))
# Counter: a frequency table in one call
freq = Counter("mississippi")
print(freq)
print(freq.most_common(2))from collections import defaultdict, Counter
d = {"a": 1, "b": 2}
# get: lookup with a default, no KeyError, no "if key in d" needed
print(d.get("a")) # 1
print(d.get("z")) # None
print(d.get("z", 0)) # 0
# setdefault: get-or-insert in one call
d.setdefault("c", []).append(10)
d.setdefault("c", []).append(20)
print(d) # {"a": 1, "b": 2, "c": [10, 20]}
# defaultdict: every missing key gets a default value automatically
groups = defaultdict(list)
for word in ["cat", "car", "dog", "do"]:
groups[word[0]].append(word)
print(dict(groups))
# Counter: a frequency table in one call
freq = Counter("mississippi")
print(freq)
print(freq.most_common(2))Pattern: frequency table
Counting occurrences is the single most common warm-up interview problem, and it’s always the same shape.
def char_frequencies(s):
freq = {}
for c in s:
freq[c] = freq.get(c, 0) + 1 # O(1) per character
return freq
print(char_frequencies("hello"))def char_frequencies(s):
freq = {}
for c in s:
freq[c] = freq.get(c, 0) + 1 # O(1) per character
return freq
print(char_frequencies("hello"))Pattern: seen-set (duplicate / pair detection)
Keeping a running setset of “things seen so far” turns an nested
scan into a single pass.
def has_duplicate(nums):
seen = set()
for x in nums:
if x in seen: # O(1) average
return True
seen.add(x) # O(1) average
return False
def two_sum(nums, target):
seen = {} # value -> index
for i, x in enumerate(nums):
complement = target - x
if complement in seen:
return [seen[complement], i]
seen[x] = i
return []
print(has_duplicate([4, 5, 6, 4]))
print(has_duplicate([1, 2, 3]))
print(two_sum([2, 7, 11, 15], 9))def has_duplicate(nums):
seen = set()
for x in nums:
if x in seen: # O(1) average
return True
seen.add(x) # O(1) average
return False
def two_sum(nums, target):
seen = {} # value -> index
for i, x in enumerate(nums):
complement = target - x
if complement in seen:
return [seen[complement], i]
seen[x] = i
return []
print(has_duplicate([4, 5, 6, 4]))
print(has_duplicate([1, 2, 3]))
print(two_sum([2, 7, 11, 15], 9))two_sumtwo_sum finds the answer in one pass: for each number, check if the
value that would complete the target has already been seen — no nested
loop needed.
Complexity summary
| Operation | listlist | setset / dictdict |
|---|---|---|
Membership (x in ...x in ...) | average | |
| Insert | amortized (end) | average |
| Delete by key/value | average | |
| Worst case (adversarial hashing) | — |
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 1 | Two Sum | Easy | Trade scanning for lookups — the dict is the algorithm |
| 49 | Group Anagrams | Medium | A canonical dict-of-lists bucket, keyed by the sorted letters (or a 26-slot count tuple) |
| 217 | Contains Duplicate | Easy | len(set(nums)) != len(nums)len(set(nums)) != len(nums) in one line — proof that picking the right structure ends the problem |
Practice — real LeetCode problems
Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.
LC 1 — Two Sum · Easy
Problem. Given an array numsnums and a targettarget, return the indices of the two
numbers that add up to targettarget. Exactly one solution exists, and you may not use
the same element twice.
Constraints. 2 <= len(nums) <= 10^42 <= len(nums) <= 10^4, -10^9 <= nums[i], target <= 10^9-10^9 <= nums[i], target <= 10^9.
Can you do better than ?
Examples. [2,7,11,15], target = 9[2,7,11,15], target = 9 gives [0,1][0,1] ·
[3,2,4], target = 6[3,2,4], target = 6 gives [1,2][1,2] · [3,3], target = 6[3,3], target = 6 gives [0,1][0,1]
Editorial
Trading scanning for lookups is the whole idea, and it is the canonical demonstration of why hash tables matter.
Time . Space .
The ordering — look up, then insert — is what enforces “not the same element
twice”. With [3,3][3,3] and target = 6target = 6: at i = 0i = 0 the complement 33 is not yet in
the map, so we insert it; at i = 1i = 1 the complement 33 is present, mapped to
index 00, giving [0, 1][0, 1]. Inserting first would let index 0 match itself and
return [0, 0][0, 0].
[0,4,3,0][0,4,3,0] with target = 0target = 0 is the same trap with a duplicate that is not
adjacent.
Note the input is unsorted, which is why a hash map beats the two-pointer approach. When the array is sorted, converging pointers solve it in space — that is LC 167, and knowing which applies is the real lesson.
Follow-ups: “Sorted input?” — two pointers, space. “Return the values instead of indices?” — sorting becomes viable. “All pairs, not just one?” — watch for duplicates. “Three numbers (LC 15)?” — sort, fix one, two-pointer the rest.
LC 217 — Contains Duplicate · Easy
Problem. Return TrueTrue if any value appears at least twice.
Constraints. 1 <= len(nums) <= 10^51 <= len(nums) <= 10^5, -10^9 <= nums[i] <= 10^9-10^9 <= nums[i] <= 10^9.
Examples. [1,2,3,1][1,2,3,1] gives TrueTrue · [1,2,3,4][1,2,3,4] gives FalseFalse
Editorial
A set stores each value once, so a size mismatch proves a duplicate existed.
Time . Space .
The early-exit variant is worth writing out, because it is strictly better on memory when a duplicate appears early:
seen = set()
for n in nums:
if n in seen:
return True
seen.add(n)
return Falseseen = set()
for n in nums:
if n in seen:
return True
seen.add(n)
return FalseSame worst case, but it stops as soon as it can and never holds more than the prefix it has scanned.
The alternatives are instructive: sorting and checking neighbours is time and extra space — a real trade, not a worse answer, if memory is the binding constraint.
Follow-ups: ” space?” — sort first, then compare adjacent elements.
“Duplicates within kk indices (LC 219)?” — a sliding-window set of size kk.
“Values within tt and indices within kk (LC 220)?” — bucketing or a
SortedListSortedList. “Find which value repeats?” — return it instead of a boolean.
LC 383 — Ransom Note · Easy
Problem. Return TrueTrue if ransomNoteransomNote can be built from the letters in
magazinemagazine, using each letter at most as many times as it appears there.
Constraints. 1 <= len(ransomNote), len(magazine) <= 10^51 <= len(ransomNote), len(magazine) <= 10^5, lowercase letters.
Examples. ("a", "b")("a", "b") gives FalseFalse · ("aa", "ab")("aa", "ab") gives FalseFalse ·
("aa", "aab")("aa", "aab") gives TrueTrue
Editorial
The question is whether the note’s letter multiset is contained in the
magazine’s. Counter.__le__Counter.__le__ implements precisely that comparison.
Time . Space , so for lowercase English.
("aa", "ab")("aa", "ab") returning FalseFalse is the case that rules out a set-based solution:
both strings use the same letter set, but the magazine has only one aa. Counts
are the whole problem.
The explicit version is worth knowing for interviews that ban library shortcuts:
count the magazine, then decrement per note character and fail on a miss. It also
allows an early exit, which Counter <= CounterCounter <= Counter does not.
("", "x")("", "x") returning TrueTrue — an empty note needs nothing — falls out for free.
Follow-ups: “Without CounterCounter?” — a 26-slot list, or a dict with manual
decrements. “Reuse letters unlimited times?” — then it is set containment, and
set(note) <= set(magazine)set(note) <= set(magazine). “Anagram check instead (LC 242)?” — equality rather
than containment. “Unicode input?” — the dict handles it; only the space bound
changes.
Recap
- A hash table maps key -> bucket index via
hash(key)hash(key), giving O(1)-style access without scanning. - Collisions are handled internally, via chaining (list per bucket) or open
addressing (probe to the next slot) — CPython uses an open-addressing
scheme for
dictdict/setset. dictdict/setsetgive average membership, insert, and delete; only hashable (immutable) types can be keys or set elements.getget,setdefaultsetdefault,defaultdictdefaultdict, andCounterCountercover almost every “check existence first” pattern.- Frequency tables and seen-sets turn nested scans into a single pass — the single highest-leverage pattern in easy interview problems.
You’ve now covered the core linear data structures. Next up in Phase 3: trees, graphs, and heaps — structures built from these same building blocks.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
