Part 3 · Data Structures · Module 23

Hash Tables & Sets

The hash table is the reason dict and set lookups take the same time whether they hold ten items or ten million. It is arguably the most useful data structure in practical programming, and Python itself is built on it: modules, classes, objects, and keyword arguments are all dicts underneath.

⏱ ~2.5 hoursPrerequisites: M19, M06, M11

The problem: look up by key, instantly

You have a million user records and need to find the one whose email is ada@example.com. In a list you scan: O(n). If the list were sorted by email you could binary search: O(log n), but keeping it sorted makes inserts O(n). Arrays give O(1) access, but only by integer position.

The hash table's idea is beautifully simple: turn the key into an integer position. Compute a number from the key, reduce it to an index into an array, and store the record there. To find it again, compute the same number and look in the same place. No scanning.

The hash-function idea

A hash function maps a key of any size to a fixed-size integer, its hash. A hash table then maps that integer into one of m buckets (array slots), usually with hash % m, or hash & (m − 1) when m is a power of two (a bit mask is cheaper than division).

"cat""dog""emu" hash(key)% 8 0123 4567 "dog" → rec"cat" → rec"emu" → rec
The same key always lands in the same bucket, so lookup goes straight there.

What makes a hash function good for a hash table?

Note what is not required: hash tables do not need cryptographic strength or uniqueness. Different keys may share a hash; that is a collision, and every hash table must handle it.

Summing character codes ignores order, so every anagram collides. A polynomial hash weighs each position differently. Python's real string hash is stronger still, as the next section explains.

hash() in Python, and PYTHONHASHSEED

The built-in hash(obj) calls obj.__hash__() and returns a 64-bit signed integer on 64-bit platforms. Some details worth knowing:

Why string hashes vary between runs

If an attacker knows your hash function, they can craft thousands of keys that all land in the same bucket. A web framework that puts request parameters into a dict would then do O(n²) work per request: a hash-flooding denial-of-service attack, demonstrated against many languages in 2011. Python's defence (default since 3.3) is to hash str and bytes with SipHash, a fast keyed hash, using a random key chosen at interpreter start-up. The attacker can't predict the buckets, so they can't flood one.

The PYTHONHASHSEED environment variable controls the key: unset or random means random; an integer fixes it (and 0 disables randomisation), which is occasionally useful for reproducing a bug.

pixi run python -c "print(hash('abc'))"      # differs each run
pixi run python -c "print(hash('abc'))"
PYTHONHASHSEED=0 pixi run python -c "print(hash('abc'))"   # same every run

The __hash__ / __eq__ contract

A hash table finds a key by going to the bucket for its hash, then checking candidates with ==. For that to work, one rule must hold:

If a == b, then hash(a) == hash(b).

The reverse is not required (collisions are allowed). A second rule follows: an object's hash must not change while it is in a hash table, otherwise it sits in the bucket for its old hash and lookups look in the wrong place. In practice, that means hash only immutable state.

Python enforces part of this for you. By default, objects compare and hash by identity. If a class defines __eq__ but not __hash__, Python sets __hash__ = None, making instances unhashable, because the inherited identity hash would break the rule.

The object is still in the set, but neither its new value nor its old value finds it: the new hash points at the wrong bucket, and the old value no longer compares equal. This is why mutable built-ins are unhashable.

Collisions are inevitable

If there are more possible keys than buckets (always), some keys must share a bucket: that is the pigeonhole principle. Collisions also arrive much sooner than intuition suggests. The birthday paradox says that with just 23 people, there is a better than even chance two share a birthday out of 365. In general, with m buckets, you expect the first collision after about √(πm/2) insertions.

So a hash table's real design question is not "how do we avoid collisions?" but "how do we resolve them cheaply?" There are two families of answers.

Separate chaining vs open addressing

Separate chaining

Each bucket holds a small list (a "chain") of the entries that hashed there. Insert appends to the chain (or replaces an existing key); lookup scans only that chain. Deletion just removes from the chain. Simple and robust; Java's HashMap uses chains (switching long ones to trees).

Open addressing

All entries live in the array itself, one per slot. On a collision, follow a probe sequence to the next candidate slot until you find the key or an empty slot. Linear probing tries i, i+1, i+2, …. Cache-friendly and compact, but deletion needs care. Python's dict uses open addressing.

chaining 0123 catemudog linear probing 0123 catemudog emu hashed to 1 (taken) → probe 2
Same three keys; "cat" and "emu" both hash to bucket 1.

Deletion in open addressing: tombstones

Suppose "cat" (slot 1) and "emu" (slot 2, displaced) are stored, and you delete "cat" by emptying slot 1. A lookup of "emu" starts at slot 1, sees empty, and stops: "not found". Wrong! Probing relies on an unbroken run. The fix is a tombstone (CPython calls it a dummy): a marker meaning "something was here; keep probing." Lookups skip tombstones; inserts may reuse them; a resize discards them.

Try all three strategies in the visualizer: chaining, linear probing, and Python's perturbed probing.

Load factor and resizing

The load factor α = n / m is the number of entries divided by the number of buckets. It controls performance:

So hash tables resize: when α passes a threshold (often 0.75 for chaining; CPython's dict uses 2/3), allocate a table about twice as large and rehash every entry into it, since each key's bucket depends on m. Rehashing is O(n), but geometric growth makes insertion amortized O(1), exactly the dynamic-array argument from M19.

How CPython's dict works

Since Python 3.6, CPython uses a compact dict (proposed by Raymond Hettinger). Instead of one sparse array of (hash, key, value) entries, it uses two arrays:

indices (the hash table, 8 slots) ·2·0 ··1· 0123 4567 entries (dense, insertion order) 0: hash("b") "b" → 21: hash("a") "a" → 12: hash("c") "c" → 3 d = {"b": 2, "a": 1, "c": 3}: iteration walks entries in order; lookups go through indices
Iteration is a fast scan of the dense entries array, which is why dicts preserve insertion order.

Benefits: memory drops sharply (the sparse part holds tiny integers instead of 24-byte entries), iteration is a tight loop over a dense array, and insertion order is preserved for free. Order preservation became a language guarantee in Python 3.7.

Lookup and perturbed probing

The table size is a power of two, so mask = size − 1 and the first slot is hash & mask, which uses only the hash's low bits. Linear probing would cluster badly for keys like consecutive integers (which hash to themselves), so CPython mixes in the higher bits as it probes:

# Simplified from Objects/dictobject.c
perturb = hash_value                      # treated as unsigned
i = hash_value & mask
while True:
    ix = indices[i]
    if ix == EMPTY:
        return NOT_FOUND                  # an empty slot ends the probe sequence
    if ix != DUMMY:
        h, k, v = entries[ix]
        if h == hash_value and (k is key or k == key):   # identity check first: fast path
            return v
    perturb >>= 5                          # PERTURB_SHIFT
    i = (i * 5 + perturb + 1) & mask

The recurrence i = 5i + 1 (mod 2ᵏ) on its own visits every slot exactly once before repeating, so the probe always finds an empty slot. Adding perturb lets every bit of the hash influence the path early on; after enough shifts perturb becomes 0 and the pure 5i + 1 sequence takes over. Note the comparison order: hash first (a cheap integer compare that rules out most non-matches), then identity, then ==.

Growth

A dict keeps its table at most 2/3 full ("usable" slots). When an insert would exceed that, it resizes to a larger power of two (based on about 3× the number of items) and rebuilds the indices, also discarding tombstones. The smallest table has 8 slots and 5 usable entries, which is why you see the size jump at the 6th key:

Sets and frozenset

A set is a hash table of keys with no values. It gives O(1) average membership, insertion, and removal, and fast set algebra. CPython's set has its own implementation tuned for membership tests: it tries a few adjacent slots (cheap, cache-friendly linear probes) before jumping with perturbation, and it does not preserve insertion order.

OperationOperatorAverage cost
membershipx in sO(1)
unions | tO(len(s) + len(t))
intersections & tO(min(len(s), len(t)))
differences - tO(len(s))
symmetric differences ^ tO(len(s) + len(t))
subsets <= tO(len(s))

frozenset is the immutable, hashable version: use it as a dict key or as an element of another set.

Counter, defaultdict, and OrderedDict

A dict subclass for counting hashable things. Missing keys count as 0. most_common(k) returns the top k; counters support +, -, & (min), and | (max).

from collections import Counter
c = Counter("mississippi")
c.most_common(2)          # [('i', 4), ('s', 4)]
c["z"]                    # 0, no KeyError
c.update("sip")           # add more counts
c.total()                 # sum of all counts

A dict that calls a factory to create a value for a missing key on first access. Perfect for grouping.

from collections import defaultdict
groups = defaultdict(list)
for word in ["apple", "avocado", "banana"]:
    groups[word[0]].append(word)
# {'a': ['apple', 'avocado'], 'b': ['banana']}

Beware: merely reading groups["z"] inserts an empty list. Use "z" in groups or groups.get("z") to test without inserting.

Regular dicts preserve insertion order now, so OrderedDict is for its extra behaviour: move_to_end(key, last=True) and popitem(last=False) in O(1), and order-sensitive equality. That makes it a ready-made LRU cache skeleton.

from collections import OrderedDict
lru = OrderedDict()
lru["a"] = 1; lru["b"] = 2; lru["c"] = 3
lru.move_to_end("a")        # a was used: now most recent
lru.popitem(last=False)     # evict least recent: ('b', 2)

Hash-based problem solving

A huge class of problems becomes O(n) once you ask: "what do I need to look up, and can I keep it in a dict or set?"

Two-sum

Find indexes of two numbers summing to a target. For each x, check whether target − x was already seen (dict from value to index). O(n) instead of O(n²).

Group anagrams

Words are anagrams iff their sorted letters match. Use "".join(sorted(w)) (or a letter-count tuple) as a dict key mapping to a list.

De-duplicate

set(items) if order doesn't matter; list(dict.fromkeys(items)) keeps first-seen order (dicts are ordered).

First unique character

Count with Counter in one pass, then scan again for the first character with count 1. Two O(n) passes.

Pitfalls

Practice: build HashMap, then use hashing

Build HashMap with separate chaining. self._buckets is a list of capacity lists, each holding [key, value] pairs. Implement _bucket(key) (return the list for hash(key) % len(self._buckets)), put(key, value) (insert or replace), get(key, default=None), __len__, plus __getitem__ (raise KeyError if missing) and __setitem__. Do not resize yet.

Hint

In put, loop over the bucket's pairs; if pair[0] == key, set pair[1] = value and return. Otherwise append [key, value] and increment _size.

Show solution
class HashMap:
    def __init__(self, capacity=8):
        self._buckets = [[] for _ in range(capacity)]
        self._size = 0

    def _bucket(self, key):
        return self._buckets[hash(key) % len(self._buckets)]

    def put(self, key, value):
        bucket = self._bucket(key)
        for pair in bucket:
            if pair[0] == key:
                pair[1] = value
                return
        bucket.append([key, value])
        self._size += 1

    def get(self, key, default=None):
        for k, v in self._bucket(key):
            if k == key:
                return v
        return default

    def __getitem__(self, key):
        for k, v in self._bucket(key):
            if k == key:
                return v
        raise KeyError(key)

    def __setitem__(self, key, value):
        self.put(key, value)

    def __len__(self):
        return self._size

Extend the chaining HashMap: after an insertion, if len / capacity > 0.75, double the number of buckets and rehash every pair. Add __delitem__ (KeyError if missing), __contains__, __iter__ (yield keys), items(), and a load_factor property.

Hint

_resize: save the old buckets, create new_capacity empty ones, then re-insert every pair into self._bucket(k) (the bucket depends on the new capacity). Don't call __setitem__ inside, or you'll double-count _size.

Show solution
class HashMap:
    MAX_LOAD = 0.75

    def __init__(self, capacity=8):
        self._buckets = [[] for _ in range(capacity)]
        self._size = 0

    def _bucket(self, key):
        return self._buckets[hash(key) % len(self._buckets)]

    @property
    def load_factor(self):
        return self._size / len(self._buckets)

    def _resize(self, new_capacity):
        old = self._buckets
        self._buckets = [[] for _ in range(new_capacity)]
        for bucket in old:
            for k, v in bucket:
                self._bucket(k).append([k, v])

    def __setitem__(self, key, value):
        bucket = self._bucket(key)
        for pair in bucket:
            if pair[0] == key:
                pair[1] = value
                return
        bucket.append([key, value])
        self._size += 1
        if self.load_factor > self.MAX_LOAD:
            self._resize(2 * len(self._buckets))

    def __getitem__(self, key):
        for k, v in self._bucket(key):
            if k == key:
                return v
        raise KeyError(key)

    def __delitem__(self, key):
        bucket = self._bucket(key)
        for i, (k, _) in enumerate(bucket):
            if k == key:
                del bucket[i]
                self._size -= 1
                return
        raise KeyError(key)

    def __contains__(self, key):
        return any(k == key for k, _ in self._bucket(key))

    def __iter__(self):
        for bucket in self._buckets:
            for k, _ in bucket:
                yield k

    def items(self):
        for bucket in self._buckets:
            for k, v in bucket:
                yield (k, v)

    def __len__(self):
        return self._size

Build ProbingMap(capacity=8) using one list self._slots of length capacity. Each slot is None (empty), the sentinel TOMBSTONE, or a (key, value) tuple. Probe linearly from hash(key) % capacity. Implement put, get(key, default=None), delete(key) (leave a tombstone; KeyError if missing), and __len__. Double the capacity (re-inserting live entries, dropping tombstones) when (size + tombstones + 1) / capacity > 2/3 before inserting a new key.

Hint

_find: start at i = hash(key) % cap; loop: if the slot is None return None; if it is a tuple whose key equals key return i; otherwise (tombstone or other key) i = (i + 1) % cap. In put, if the key is absent, resize if needed, then probe from the start to the first None or tombstone and store there (reusing a tombstone decrements the tombstone count).

Show solution
TOMBSTONE = object()

class ProbingMap:
    def __init__(self, capacity=8):
        self._slots = [None] * capacity
        self._size = 0
        self._tombstones = 0

    def __len__(self):
        return self._size

    def _find(self, key):
        cap = len(self._slots)
        i = hash(key) % cap
        for _ in range(cap):
            slot = self._slots[i]
            if slot is None:
                return None
            if slot is not TOMBSTONE and slot[0] == key:
                return i
            i = (i + 1) % cap
        return None

    def _resize(self, new_capacity):
        live = [s for s in self._slots if s is not None and s is not TOMBSTONE]
        self._slots = [None] * new_capacity
        self._size = 0
        self._tombstones = 0
        for k, v in live:
            self._insert_new(k, v)

    def _insert_new(self, key, value):
        cap = len(self._slots)
        i = hash(key) % cap
        while self._slots[i] is not None and self._slots[i] is not TOMBSTONE:
            i = (i + 1) % cap
        if self._slots[i] is TOMBSTONE:
            self._tombstones -= 1
        self._slots[i] = (key, value)
        self._size += 1

    def put(self, key, value):
        i = self._find(key)
        if i is not None:
            self._slots[i] = (key, value)
            return
        if (self._size + self._tombstones + 1) / len(self._slots) > 2 / 3:
            self._resize(2 * len(self._slots))
        self._insert_new(key, value)

    def get(self, key, default=None):
        i = self._find(key)
        return default if i is None else self._slots[i][1]

    def delete(self, key):
        i = self._find(key)
        if i is None:
            raise KeyError(key)
        self._slots[i] = TOMBSTONE
        self._size -= 1
        self._tombstones += 1

Write a class Money(amount, currency) usable as a dict key and set member. Two values are equal when both the amount and the currency (case-insensitive: "usd" equals "USD") match. Store the currency upper-cased, make the object immutable (assigning an attribute after creation raises AttributeError), return NotImplemented when compared with a non-Money, and give it a __repr__ like Money(5, 'USD').

Hint

The simplest route is @dataclass(frozen=True) with a __post_init__ that uses object.__setattr__(self, "currency", self.currency.upper()), plus a custom __repr__. Or write __slots__, __eq__, __hash__, and a __setattr__ that raises.

Show solution
from dataclasses import dataclass

@dataclass(frozen=True, repr=False)
class Money:
    amount: int
    currency: str

    def __post_init__(self):
        object.__setattr__(self, "currency", self.currency.upper())

    def __repr__(self):
        return f"Money({self.amount!r}, {self.currency!r})"

Write two_sum(nums, target) returning a tuple (i, j) with i < j and nums[i] + nums[j] == target (the first such pair found scanning j left to right), or None. It must be O(n). Then write group_anagrams(words) returning a list of groups (lists), each in first-seen order, with groups ordered by their first word's appearance.

Hint

two_sum: a dict from value to index; check target - x before storing x. group_anagrams: a dict (ordered!) from "".join(sorted(w)) to a list; return list(groups.values()).

Show solution
def two_sum(nums, target):
    seen = {}
    for j, x in enumerate(nums):
        if target - x in seen:
            return (seen[target - x], j)
        if x not in seen:
            seen[x] = j
    return None

def group_anagrams(words):
    groups = {}
    for w in words:
        groups.setdefault("".join(sorted(w)), []).append(w)
    return list(groups.values())

Write dedupe(items) that removes duplicates keeping first-seen order (items are hashable), and first_unique(s) that returns the index of the first character appearing exactly once in s, or -1. Both must be O(n).

Hint

list(dict.fromkeys(items)) dedupes in order. For first_unique, build a Counter(s) then enumerate(s) to find the first count of 1.

Show solution
from collections import Counter

def dedupe(items):
    return list(dict.fromkeys(items))

def first_unique(s):
    counts = Counter(s)
    for i, ch in enumerate(s):
        if counts[ch] == 1:
            return i
    return -1

Mini-project: a word-frequency analyzer

Mini-projectwordfreq: your HashMap vs dict

Build a tool that counts word frequencies in a text file twice, once with your own chaining HashMap and once with collections.Counter, checks that the results agree, and reports how the two compare in speed and in internal behaviour (load factor, chain lengths, resize count).

Set up

pixi init wordfreq
cd wordfreq
pixi add python=3.14 pytest matplotlib
pixi run python wordfreq.py book.txt --top 20

Any large plain-text file works; public-domain books from Project Gutenberg are a good source.

Spec

  • hashmap.py: your HashMap from exercises 1–2, plus resizes (a counter), chain_lengths() (a list), and a get_or(key, default) helper.
  • tokenize(text): lower-case, split on anything that isn't a letter or apostrophe (use re.findall).
  • count_with_hashmap(words) and count_with_counter(words); a test asserts they produce the same mapping.
  • top_k(counts, k) sorted by count descending, then word ascending for ties.
  • A report printing the top k, total and distinct words, the timing of both counters (best of 5 with perf_counter), your map's final load factor, number of resizes, and longest chain.
  • Save chains.png: a histogram of chain lengths, and compare it with the Poisson distribution you would expect for a uniform hash at that load factor.
  • Both counting methods produce identical results (checked by a test)
  • Ties in top_k break alphabetically and deterministically
  • The report includes timings for both implementations and explains the ratio
  • Load factor never exceeds your threshold; the resize count is roughly log₂(distinct words / initial capacity)
  • The chain-length histogram is saved with labelled axes
  • Try a deliberately bad hash (for example len(word)) and record how the timings and the histogram change
  • pixi run python -m pytest -q passes

Stretch goals

  • Add an open-addressing backend (exercise 3) and compare probes per lookup at several load factors.
  • Count bigrams (pairs of consecutive words) using tuple keys.
  • Stream the file line by line so memory stays flat for multi-gigabyte inputs.
  • Find words that appear in one book but not another with set difference.

Quiz

Recap

Going further