Hash Tables
Data Structures in Practice
Chapter 9 ยท Hash Tables Built by Hand
You have used dict and set in every chapter of this course, and relied on one promise: looking up a key takes roughly the same time however many keys there are (Data Structures in Practice 1 and 3). This chapter builds that machinery yourself, so the promise stops being magic. A hash table is a list of slots plus a hash function that turns a key into a number; the number, reduced to the table's size, says which slot to look in.
The Hash Function
A good hash function is deterministic (the same key always gives the same number), fast, and spreads keys out, so that different keys rarely land in the same slot. The usual recipe for text multiplies a running total by a small prime before adding each character, so that the position of each letter matters. The final number is reduced with % (modulo) to a slot index.
cat and tac both land in slot 6 even though they are different words. The poor function that merely adds up letter codes cannot tell the three anagrams apart at all: they all hash to 312. Which slot a key gets does not matter; what matters is that the keys use all the slots evenly.
Collisions and Chaining
Two different keys with the same slot is a collision. It is unavoidable: there are far more possible keys than slots, so a table must have a plan for it. The first plan is separate chaining: each slot (called a bucket) holds a small list of the (key, value) pairs that landed there. To look up a key, hash it, go to its bucket, and compare keys one by one along that short list.
Read the output as a story. The table starts with 8 buckets. Adding the seventh key pushed the load factor (keys divided by buckets) to 7/8 = 0.88, past the 0.75 limit, so the table doubled to 16 buckets and re-hashed every key, which is why the load fell back to 0.44. That re-hash is needed because the slot is hash % size: when the size changes, nearly every key belongs somewhere new. It is the same trade as the list from Data Structures in Practice 2: an occasional expensive step buys a cheap average one, which is why adding keys is amortised constant time.
A Bad Hash Function Ruins Everything
The table is only as good as its spreading. The next snippet hashes 2,000 distinct random six-letter words into 256 buckets with each function, and reports how many buckets were used and the longest chain. (It needs simple_hash and bad_hash from the first snippet.)
The multiplying function used every one of the 256 buckets and its fullest bucket held 17 words, against an average of about 8. The letter-sum function used only 44 buckets and piled 119 words into one. Sums of six small letter codes all fall in a narrow band, so most buckets can never be chosen. With chains that long, a lookup is back to scanning a list: the table has the right shape but none of the speed.
Open Addressing
The second plan stores everything in the one flat list, with no inner lists. If a key's slot is taken, step to the next slot (linear probing), wrapping round at the end, until a free one is found. A lookup follows the same steps and stops at the first never-used slot, because the key cannot be any further along. This layout is friendly to memory caches and is the idea behind Python's own dict, though CPython uses a more elaborate probe order.
Deleting is the tricky part. If you simply emptied a slot, a lookup for a key that had been pushed past it would stop at the new hole and wrongly conclude the key is absent. The fix is a tombstone: mark the slot "deleted", so searches walk through it, while inserts may reuse it. Tombstones count towards how full the table is, so the code below tracks used (live plus tombstones) as well as count (live only).
cat and tac both want slot 6; tac was pushed on to slot 7. After act was deleted, tac was still found and the live count dropped from 4 to 3, while used stayed at 4 because of the tombstone. Putting act back reused that slot, so nothing grew. Open addressing needs a lower load limit than chaining (0.6 here), because probe sequences lengthen sharply as the table fills. Exercise 3 measures that.
| Separate chaining | Open addressing | |
|---|---|---|
| Where collided keys go | Into a list inside the bucket | The next free slot in the same flat list |
| Load factor | Can exceed 1; chains just lengthen | Must stay below 1; keep it well under |
| Deleting | Remove from the bucket's list | Needs a tombstone |
| Memory | Extra list object per bucket | One flat array |
| Weak spot | Pointer-heavy; many small lists | Clustering as the table fills |
What Makes a Key Safe to Use
The whole scheme relies on a contract, the one behind the hashing rules in Data Structures in Practice 3: keys that are equal must have equal hashes, and a key's hash must never change while it is in the table. If a key could change, it would sit in a bucket its new hash no longer points to, and could never be found again. This is why lists and dicts are not allowed as keys, while numbers, strings and tuples of them are.
Note that 5 and 5.0 hash alike, because they are equal and so must find the same entry. A class that defines __eq__ without __hash__ is made unhashable by Python for exactly this reason: equality was changed, so the old hash could no longer be trusted. When you define both, base the hash on exactly the fields that __eq__ compares.
What You Have Rebuilt
| Idea | In your hand-built table | In real Python |
|---|---|---|
| Key to slot | simple_hash(key) % size | hash(key) reduced to the table size |
| Collisions | Chains, or probing to the next slot | Probing (a more elaborate sequence) |
| Growth | Double and re-hash at a set load factor | Resizes automatically as it fills |
| Deletion | Remove from a chain, or a tombstone | A similar dummy-marker idea |
| Key rules | Equal keys, equal hashes; no mutation | Exactly the same, enforced by __hash__ |
You would never use a hand-built table in production Python: the built-in is written in C and heavily tuned. The value is understanding what it costs and why, which tells you when a dict is the right tool, why a list of keys is not, and what to look at when a lookup is unexpectedly slow.
Challenges
Build a HashSet class using separate chaining and the chapter's simple_hash. Give it add (returning True if the item was new, False if already present), in support, remove (raising KeyError if absent), len and iteration. Start with 4 buckets and double whenever the item count exceeds twice the bucket count. Test duplicates, growth past the limit, removal, and removing a missing item.
Make a chained table behave like a real dict by writing a MyDict class with d[key] = value, d[key] (raising KeyError when missing), del d[key], in, len, get, items and iteration over keys. Then use it to count the words in a sentence, and confirm your counts equal those from a real dict doing the same job.
Write a small open-addressing set of keys with linear probing in a fixed 16-slot table, with a switch that chooses between tombstones and simply emptying a slot on removal. First add several keys that share a starting slot, remove the first, and show that the naive version loses a key that is still present while the tombstone version does not. Then, in a 32-slot table, add 8, 16, 24 and 28 keys and print the load factor, the longest probe run and the average probe count to find each key, to see how probing cost grows as the table fills.
๐ View solutionQuick Reference
- Hash table: a list of slots plus a hash function; slot =
hash(key) % size - Good hash: deterministic, fast, and spreads keys evenly over all slots
- Collision: two keys, one slot; unavoidable, so every table needs a plan
- Chaining: each bucket holds a short list of pairs
- Open addressing: one flat list; on a collision probe the next slot; delete with tombstones
- Load factor: keys divided by slots; keep it under a limit by doubling and re-hashing
- Amortised: an occasional full re-hash is repaid by many cheap inserts
- Key contract: equal keys have equal hashes, and a key's hash never changes inside a table
- Weakness: a poor hash turns lookups back into list scans