Skip to content

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/setset give O(1)O(1) 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.

hash_basics.py
# 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)}")
hash_basics.py
# 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.

diagram Two collision-resolution strategies mermaid

dictdict / setset: O(1) average, in practice

dict_set_speed.py
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")
dict_set_speed.py
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.

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

frequency_pattern.py
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"))
frequency_pattern.py
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 O(n2)O(n^2) nested scan into a single O(n)O(n) pass.

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

Operationlistlistsetset / dictdict
Membership (x in ...x in ...)O(n)O(n)O(1)O(1) average
InsertO(1)O(1) amortized (end)O(1)O(1) average
Delete by key/valueO(n)O(n)O(1)O(1) average
Worst case (adversarial hashing)O(n)O(n)

LeetCode problem set

#ProblemDifficultyThe twist
1Two SumEasyTrade O(n2)O(n^2) scanning for O(n)O(n) lookups — the dict is the algorithm
49Group AnagramsMediumA canonical dict-of-lists bucket, keyed by the sorted letters (or a 26-slot count tuple)
217Contains DuplicateEasylen(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 O(n2)O(n^2)?

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 O(n2)O(n^2) scanning for O(n)O(n) lookups is the whole idea, and it is the canonical demonstration of why hash tables matter.

Time O(n)O(n). Space O(n)O(n).

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 O(1)O(1) space — that is LC 167, and knowing which applies is the real lesson.

Follow-ups: “Sorted input?” — two pointers, O(1)O(1) 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 O(n)O(n). Space O(n)O(n).

The early-exit variant is worth writing out, because it is strictly better on memory when a duplicate appears early:

python
seen = set()
for n in nums:
    if n in seen:
        return True
    seen.add(n)
return False
python
seen = set()
for n in nums:
    if n in seen:
        return True
    seen.add(n)
return False

Same 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 O(nlogn)O(n \log n) time and O(1)O(1) extra space — a real trade, not a worse answer, if memory is the binding constraint.

Follow-ups:O(1)O(1) 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 O(n+m)O(n + m). Space O(Σ)O(|\Sigma|), so O(1)O(1) 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/setset give O(1)O(1) average membership, insert, and delete; only hashable (immutable) types can be keys or set elements.
  • getget, setdefaultsetdefault, defaultdictdefaultdict, and CounterCounter cover almost every “check existence first” pattern.
  • Frequency tables and seen-sets turn O(n2)O(n^2) nested scans into a single O(n)O(n) 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 coffee

Was this page helpful?

Let us know how we did