Hash Tables: Hashing, Collisions, Load Factor, and Amortized Cost
For two weeks we sorted things so we could find them faster. This week we skip the sorting and find things instantly anyway. The trick is called a hash table, and you have used one every time you wrote a Python dictionary. By Thursday you will know how it works inside, why it is fast, when it gets slow, and how to build one yourself — and, in Project 2, how to break one on purpose.
A hash table you can drive by hand — type a word, watch it turn into a bucket number, and see collisions, probing, tombstones and resizing happen in front of you. A cost chart that shows why growing a table by doubling is cheap in the long run. And a side-by-side of bad and good hash functions on the same list of names. Everything is computed live. Play with them while you read.
Session planHow we will spend the two sessions
Tuesday, October 6 — Session 10
Thursday, October 8 — Session 11
Section 1Finding things instantly
Here is the problem. You have a lot of things, each with a name, and you want to find one by its name. A phone number by a person's name. A student record by an ID. A word's definition by the word.
The simplest plan is to keep everything in a list and look through it from the start until you find the right one. That works, but it is slow when the list is long. If there are a million names, you might check a million names. In Notes 02's words, that is linear time: the work grows in step with the size of the list.
A smarter plan is to sort the list first and use binary search — the halving trick from Notes 02 and Notes 03. That is much faster: about 20 checks for a million names. But it still takes more steps as the list grows, and keeping the list sorted while you add new names is a chore.
Now think about a coat check at a theater. You hand over your coat and get a ticket with a number on it: 47. When you come back, the attendant does not search through every coat. They read the 47, walk to hook 47, and grab your coat. One step. It does not matter if there are 50 coats or 500 — it is still one walk to one hook.
If you can turn a thing's name straight into a location number, you can go directly to that location. No searching. A hash table is a row of numbered slots (the hooks), plus a rule that turns any name into a slot number (the ticket). Finding something takes about the same time whether the table holds ten things or ten million. We call that constant time — Θ(1), "doesn't grow with the size," in Notes 02's language.
A few words we will use all week:
- A key is the name you look things up by —
"cat", a student ID, a phone number. - A value is the thing stored with that key — the definition, the record, the coat.
- A bucket (or slot) is one numbered spot in the table — one hook.
You already use hash tables every day. Python's dict and set are both hash tables:
ages = {"ana": 20, "ben": 22, "cho": 19} # a dict: keys → values
print(ages["ben"]) # 22 — straight to the right bucket
print("dan" in ages) # False — also instant
seen = {"ana", "ben"} # a set: just keys, no values
seen.add("cho")
names_list = ["ana", "ben", "cho"]
print("dan" in names_list) # False — but Python had to check every item
The two in checks look the same, but they are not. On the dict, Python jumps to one bucket. On the list, Python walks the whole list. With a few items you will never notice. With a million, the list version is a million times more work. This is one of the most common speed-ups in real code: "I was checking x in some_list inside a loop — I switched it to a set and it went from minutes to seconds."
| Way to find a key | Steps for 1,000 keys | Steps for 1,000,000 keys | In words |
|---|---|---|---|
| Scan an unsorted list | up to 1,000 | up to 1,000,000 | linear — grows with the size |
| Binary search a sorted list | about 10 | about 20 | logarithmic — grows slowly |
| Hash table | about 1–2 | about 1–2 | constant, on average — doesn't grow |
Notice the words "on average" in the last row. Hold on to them. Most of this week is about what that phrase really means and what can spoil it.
Section 2Hash functions: a key in, a shelf number out
Think of a library. Every book has a call number, and the call number tells you which shelf to walk to. You never search the whole library. A hash function is the rule that gives every key its "call number."
A hash function takes a key and turns it into a whole number, called the key's hash. We then shrink that number down to a bucket number that fits the table. Doing all this is called hashing the key.
Let's hash the word "cat" by hand, the same way the demos below do it. Every character already has a number code inside the computer (in Python, ord("c") is 99). We start at 0 and, for each letter, multiply what we have by 31 and add the letter's code:
Why multiply by 31? It stirs the letters together, so that every letter and its position changes the result. "cat" and "act" use the same letters but get different hashes. (31 is just a traditional choice; many odd numbers work.)
The last line uses %, Python's remainder operator (also called "mod"). 98262 % 8 asks: "if you split 98,262 into groups of 8, how many are left over?" The answer is always between 0 and 7 — exactly the bucket numbers of an 8-bucket table. Think of a clock: count past 12 and you wrap back around. Mod wraps a big number around the table until it fits.
What makes a good hash function
A hash function has three jobs. Keep these in your head; Project 2 is built on what happens when one of them fails.
- Same key, same number — every time. If
"cat"went to bucket 6 when you stored it, it had better go to bucket 6 when you look for it. Otherwise you would walk to the wrong hook. - Spread keys out. Different keys should land in different buckets as much as possible, like guests spread across a whole hotel instead of all crammed into room 1. (Section 7 shows what happens when this fails.)
- Be fast. The whole point is speed. A hash function that takes longer than scanning the list is useless.
Job 1 has a surprising consequence: keys must not change while they are in the table. If you could edit a key after storing it, its hash would change, and it would be sitting in the wrong bucket forever. That is why Python lets you use strings, numbers and tuples as dict keys, but not lists — lists can be edited:
spots = {}
spots[(3, 4)] = "treasure" # a tuple can't change — fine as a key
spots[[3, 4]] = "treasure" # TypeError: unhashable type: 'list'
Python has a built-in hash() that does this job for you, with a stronger recipe than our "times 31" one. Try hash("cat") — then restart Python and try again. You will get a different number! Python picks a fresh secret ingredient for string hashes every time it starts (Section 7 explains why). Within one run, though, the same string always gives the same number, which is all job 1 asks for. For small whole numbers Python keeps it simple: hash(5) is just 5.
It breaks job 1. You store "cat" at 4:00:13 and it lands in some bucket. You look for "cat" at 4:00:20 and the function sends you to a different bucket — so the table says "not here" even though it is. A hash function must depend only on the key. Spreading things out is worthless if you can never find them again.
Section 3Collisions happen
Try hashing "dog" and "owl" into our 8-bucket table. Both land in bucket 4. Two different keys, one bucket. This is called a collision.
Can a cleverer hash function avoid collisions completely? No, and the reason is simple. There are endless possible keys — every word, every name, every ID — but the table has only so many buckets. If a coat check has 100 hooks and 101 coats show up, at least two coats must share a hook. No ticket system can prevent that.
The surprise is that collisions show up long before the table is full. Think of a birthday party. How many guests do you need before two of them probably share a birthday? Most people guess around 180 — half of 365. The real answer is 23. The trick is that you are not asking whether someone matches you; you are asking whether any two people match. With 23 guests there are already 253 different pairs of people, and each pair is a fresh chance for a match.
A hash table is the same party: the buckets are the days of the year, and the keys are the guests. Even in a big, mostly empty table, a few keys will bump into each other. So a hash table cannot just hope collisions never happen. It needs a plan for when they do. That plan is the next section.
False. Even a perfect, fair hash spreads keys randomly, and random things bunch up — that is the birthday party. With 1,000 buckets you should expect your first collision after only a few dozen keys. A good hash keeps collisions rare and spread out; it cannot make them disappear. Every real hash table needs a collision plan.
Section 4Two ways to handle collisions
Plan A — Chaining: every bucket holds a little list
Picture a coat check where each hook holds a small rack instead of a single coat. If two coats get ticket 4, both go on rack 4. To find your coat, the attendant walks to rack 4 and looks through the few coats hanging there.
That is chaining (also called separate chaining). Each bucket holds a small list — a chain — of all the keys that landed there.
- Insert: hash the key, go to its bucket, add the key to that bucket's list (unless it is already there).
- Search: hash the key, go to its bucket, look through that one short list.
- Delete: hash the key, go to its bucket, remove it from that list.
The important point: you only ever look through one bucket's list, never the whole table. If the keys are spread out well, each list is short — usually zero, one or two keys.
Plan B — Linear probing: take the next free spot
Now picture a hotel with one guest per room. Your booking says room 4, but room 4 is taken. The front desk says: "Try room 5." Taken too? "Try room 6." You keep walking down the hall until you find an empty room. If you reach the end of the hall, you wrap around to room 0.
That is open addressing: every key lives directly in a slot of the table, one key per slot. The simplest version is linear probing — if your slot is taken, try the next one, then the next. Each slot you try is called a probe, and the slot a key's hash points to is its home slot.
- Insert: go to the key's home slot. If it is taken, step forward until you reach a free slot, and put the key there.
- Search: go to the home slot and step forward, checking each slot, until you either find the key or hit an empty slot. An empty slot means "stop — the key is not here," because the key would have been placed there if nothing had been in the way.
The tricky part: deleting with linear probing
Deleting is where linear probing gets sneaky. Suppose "fox" and "ant" both have home slot 7. "fox" got there first, so "ant" stepped forward and wrapped around to slot 0. Now delete "fox" by simply emptying slot 7. Later you search for "ant": you go to slot 7, see an empty slot, and stop — "not here." But "ant" is there, one step further. Emptying the slot broke the trail.
The fix is a tombstone: instead of emptying the slot, you leave a small marker that says "someone used to be here — keep walking." Searches step over tombstones. Inserts are allowed to reuse a tombstone's slot. Tombstones get cleaned up for good the next time the table is rebuilt (Section 6). Chaining never needs tombstones, because removing a key from a little list doesn't hide anybody else.
Some things to try, in order:
- In Chaining mode, search for
owl. It shares bucket 4 withdog, so it takes two checks. Search forcat: one check. - Switch to Linear probing. The same five keys are inserted again.
owlnow lives in slot 5, one step past its home. - Insert
ant. Its home is slot 7, whichfoxalready has, so it wraps around to slot 0. - Delete
fox, then search forant. Watch the search step over the tombstone in slot 7 and still findant. - Click Add a random word a few times. When the next key would push the table past 75% full, it grows from 8 to 16 buckets and every key is re-placed. Look at how the bucket numbers change.
- Turn auto-resize off and keep adding. In chaining mode the lists get long. In probing mode the table eventually fills up completely and refuses new keys.
"emu", slot 4 holds "yak" (its home slot was 3), and slot 5 is empty. You delete "emu" by simply setting slot 3 back to empty. What happens when you search for "yak" next, and how would a tombstone fix it?The search goes to "yak"'s home, slot 3, finds it empty, and stops with "not found" — even though "yak" is sitting right next door in slot 4. An empty slot means "stop," so emptying slot 3 cut the trail. With a tombstone in slot 3, the search reads it as "keep walking," steps to slot 4, and finds "yak".
Section 5Load factor: how full is too full?
The load factor of a hash table is how full it is: the number of keys stored divided by the number of buckets. Six keys in eight buckets gives a load factor of 6 ÷ 8 = 0.75, or "75% full."
Load factor is the single most important number for how fast a hash table runs. As the table fills up, collisions get more common, and every collision means extra checks.
With chaining, the load factor is simply the average length of a chain. A load factor of 0.75 means the average bucket holds less than one key, so searching is quick. A load factor of 5 means chains of about five keys each — it still works, just slower. Chaining slows down gently as it fills, and it can even go past 100% full, because a bucket's list can always grow.
With linear probing, things go bad much faster. Think of a parking garage where you take the first free spot after your assigned one. Cars bunch up into long solid rows, called clusters. When a new car's spot is in the middle of a cluster, it has to drive past the whole cluster to find a space — and then it parks at the end, making the cluster even longer. Clusters grow, and the bigger they are, the faster they grow. And of course a probing table can never go past 100%: one key per slot.
Here is roughly how much looking it takes to learn that a key is not in the table (a "miss"), measured by experiment on a big table with a good hash:
| How full | Chaining: keys compared on a miss | Linear probing: slots checked on a miss |
|---|---|---|
| 25% full | less than 1 | about 1.4 |
| 50% full | less than 1 | about 2.5 |
| 75% full | less than 1 | about 7 |
| 90% full | about 1 | about 50 |
Look at the last column. Between half full and 90% full, the work goes up about twenty times. That is why every real hash table picks a maximum load factor and grows before it gets there. Java's HashMap (which uses chaining) grows at 75% full. Python's dict (which uses a fancier kind of open addressing) grows when it is about two-thirds full. Our demo uses 75%.
Now we can say it plainly. If the hash spreads keys out well, and the table never gets too full, then each bucket holds only a few keys, so every operation is a small, fixed amount of work — no matter how many keys there are in total. Both conditions matter. A bad hash (Section 7) or an overfull table breaks the promise. Notes 02 warned that hashing's fast reputation rests on assumptions; these are the assumptions, and your Project 2 write-up should state them.
300 ÷ 100 = 3, so the chains hold about 3 keys each. Nothing is broken — every key can still be found — it is just slower than it should be, and it is overdue for a resize. A linear-probing table cannot do this at all: it holds one key per slot, so 100 slots means at most 100 keys. Its load factor can never go above 1, and in practice you keep it well below.
Section 6Growing the table, and amortized cost
When the table gets too full, we make a bigger one and move everything into it. This is called resizing. The usual rule is: double the number of buckets.
There is a catch. You cannot just copy each key into the same bucket number, because the bucket came from "hash mod table size" — and the table size just changed. In a 16-bucket table, "cat" happens to stay in bucket 6 (98,262 % 16 is also 6), but "dog" moves from bucket 4 to bucket 12. So every key must be hashed again and placed into the new table. This is called rehashing. It is also when tombstones finally disappear: we only move the real keys.
Rehashing touches every key, so a resize is slow: with a million keys, that one insert has to move a million keys. Doesn't that ruin our "constant time" story?
The moving-apartments story
Imagine you collect books. Your apartment fits 4 books. When book 5 arrives, you move to an apartment that fits 8 — and moving means carrying all 4 old books. When book 9 arrives, you move to one that fits 16, carrying 8 books. Then 32, carrying 16. Moving day is hard work. But look how rare moving days become: each move gives you as much new room as you had in total before. Most days, a new book just goes on the shelf.
Now add up all the work you ever did. To reach 64 books you carried 4 + 8 + 16 + 32 = 60 books during moves, plus 64 books shelved when they arrived. That is 124 units of work for 64 books — under 2 per book. Grow to a million books and the answer is still under 3 per book. The occasional expensive move, spread over all the cheap days before it, adds only a small, fixed amount to each book.
The savings-jar story
Here is another way to see it. Pretend every insert must pay 3 coins, no matter what. A normal insert really costs just 1 coin, so it drops the other 2 into a savings jar. When moving day comes, the jar pays for carrying the old books. With doubling, the jar never runs out: between two moves, enough cheap inserts happen to save up for the next move. So 3 coins per insert always covers everything. The demo below keeps an eye on the jar for you.
The amortized cost of an operation is its cost averaged over a long run of operations: add up the total work, divide by the number of operations. With doubling, the amortized cost of an insert is a small constant — even though a single unlucky insert (a resize) can be very slow. ("Amortize" is a banking word: paying off a big cost in small, steady installments.)
Press Play and watch the shape. The red spikes get taller each time (more keys to move), but they also get further apart. The blue average line jumps up a little at each spike and then sinks back, and it never climbs above the green 3-coin line. Now switch to Add 4 more slots. The spikes come every 4 inserts forever, the blue line keeps climbing, and the savings jar runs dry. Growing by a fixed amount means moving day comes just as often no matter how big you get, so the cost per insert keeps rising. Growing by a multiple (doubling, or even 1.5×) is what keeps the average flat.
Two different kinds of average show up this week, and they are easy to mix up. "On average" in Section 5 depends on luck: if the hash spreads your keys well, chains are short, but unlucky keys can make it slow. "Amortized" in this section involves no luck at all: it is a guarantee about the total bill. Any run of n inserts into a doubling table costs less than 3 units per insert, every single time. The same trick is what makes Python's list.append fast — a Python list is a growable array that over-allocates in the same spirit.
A resize happens every 1,000 inserts, forever — that is 10,000 resizes. Early on each one is cheap, but near the end each resize moves millions of keys. The total work is enormous, and the average cost per insert keeps growing as the table grows, instead of staying flat. Doubling makes resizes rarer and rarer as the table grows, so each one is "paid for" by all the cheap inserts before it. Fixed-size growth never catches up.
Section 7Bad hash functions
Everything good about hash tables depends on job 2 from Section 2: spread the keys out. Let's see what happens when a hash function fails at it.
The worst possible hash sends every key to bucket 0. The table still works — every key can still be found — but now bucket 0 holds one long chain with everything in it. Finding a key means walking that chain from the start. That is just a list scan in disguise: linear time, the very thing we were trying to escape. Hash functions that only look at part of the key are milder versions of the same problem. The demo below runs five hash functions on the same list — the names of the 50 US states — in a 16-bucket chaining table.
"Number of letters" is a great example of a hash that looks reasonable. It does job 1 perfectly. But state names are between 4 and 14 characters long, so several buckets can never be used, and the 12 states with exactly 8 letters all pile into bucket 8. "First letter" ignores everything after the first letter, so the eight states that start with M share one bucket and the eight that start with N share another — and no bigger table can ever separate them. "Add up the letters" does fine on this list, but it has a hidden flaw: words made of the same letters ("dog" and "god", "listen" and "silent") always collide, because adding doesn't care about order. The ×31 recipe stirs in each letter's position, so it avoids that trap. Notice that even the good hash is not perfectly even: a fair hash scatters keys like random throws, and random throws always make small lumps — the birthday party again. "Good" means no big pile-ups, not perfection.
If anyone can predict exactly where your hash function sends keys, a troublemaker can send a web server thousands of keys that all land in the same bucket, on purpose. Every lookup turns into a long scan and the server slows to a crawl. This is called a hash-flooding attack, and around 2011 it was shown to work against many popular web frameworks. The fix is the one you met in Section 2: Python, and most modern languages, mix a secret random ingredient into string hashes each time the program starts, so outsiders cannot predict the buckets. For Project 2 you don't need to defend against this — just know why hash("cat") changes between runs.
Section 8Build one in Python
Here is a complete hash table using chaining, with resizing at 75% full. It is short on purpose. Read it slowly — every line is one of the ideas above. It works like a small dict: put stores a value under a key, get looks one up, remove deletes one.
MAX_LOAD = 0.75 # grow before the table gets more than 75% full class HashTable: """A key → value map built from scratch, using chaining. Each bucket is a small Python list of (key, value) pairs. """ def __init__(self, capacity=8, hash_fn=hash): self.buckets = [[] for _ in range(capacity)] # empty racks self.size = 0 # keys stored self.hash_fn = hash_fn # swappable, for testing def _index(self, key): """Which bucket does this key belong in?""" return self.hash_fn(key) % len(self.buckets) def put(self, key, value): bucket = self.buckets[self._index(key)] for i, (k, v) in enumerate(bucket): if k == key: # already here: just update bucket[i] = (key, value) return if (self.size + 1) / len(self.buckets) > MAX_LOAD: self._resize(2 * len(self.buckets)) # make room first... bucket = self.buckets[self._index(key)] # ...then find the NEW bucket bucket.append((key, value)) self.size += 1 def get(self, key): for k, v in self.buckets[self._index(key)]: if k == key: return v raise KeyError(key) def remove(self, key): bucket = self.buckets[self._index(key)] for i, (k, v) in enumerate(bucket): if k == key: del bucket[i] self.size -= 1 return raise KeyError(key) def __contains__(self, key): # makes key in table work return any(k == key for k, v in self.buckets[self._index(key)]) def __len__(self): # makes len(table) work return self.size def _resize(self, new_capacity): old_buckets = self.buckets self.buckets = [[] for _ in range(new_capacity)] for bucket in old_buckets: # rehash: every key gets for k, v in bucket: # a fresh bucket number self.buckets[self._index(k)].append((k, v)) def times31_hash(text): """The hash from Section 2 and the demos: times 31, plus the code.""" h = 0 for ch in text: h = h * 31 + ord(ch) return h t = HashTable(hash_fn=times31_hash) t.put("cat", 3) t.put("dog", 5) t.put("cat", 4) # updates — no second "cat" print(t.get("cat"), len(t)) # 4 2 print("owl" in t) # False print(times31_hash("cat") % 8) # 6 — same bucket as the demo
Three details worth pointing at:
putchecks for the key before adding. Storing"cat"twice must update it, not create a second copy. Forgetting this is one of the most common bugs in hand-built tables.- After a resize,
putlooks up the bucket again. The old bucket belonged to the old table. Using it would drop the key somewheregetcan never find it. _resizedoesn't changesize. It moves the same keys into a bigger table; the count stays the same.
Why is the hash function a parameter? (A testing trick)
With a good hash, collisions are rare, so they are hard to test — your tests might never trigger one. Passing in the hash function lets a test use a deliberately terrible one and force every key into the same bucket:
def test_everything_collides(): t = HashTable(hash_fn=lambda key: 0) # the worst hash, on purpose for word in ["ant", "bee", "cat"]: t.put(word, len(word)) t.remove("bee") assert "bee" not in t assert t.get("cat") == 3 assert len(t) == 2 def test_many_keys_survive_resizing(): t = HashTable() for i in range(1000): # forces several resizes t.put(i, i * i) assert len(t) == 1000 for i in range(1000): assert t.get(i) == i * i
The same trick works for linear probing, where forced collisions are the only reliable way to test wrap-around and tombstones.
The specification is on the course page, and the project is due Monday, November 9, 11:59 PM. It is small and villainous: a provided script contains a table with a deliberately terrible hash function, and your mission is to find five words that all crash into the same bucket — then screenshot the wreckage as the table's "constant time" collapses into one long chain. Everything you need is in these notes: §4's collisions are the crime, §5's load factor is why the crime works, and the class in §8 is the victim. One script, one screenshot — no write-up, no benchmarks.
bucket = self.buckets[self._index(key)] that comes right after the resize in put. Everything still runs without errors. What goes wrong, and which test from the fold above catches it?After the resize, bucket still points at a list from the old table, which has just been thrown away. The new key gets appended to that dead list, so it vanishes: size goes up, but get raises KeyError. It only happens on the exact insert that triggers a resize, which is why it slips past casual testing. test_many_keys_survive_resizing catches it, because it forces several resizes and then checks that every key is still there.
Section 9When not to use a hash table
Hash tables are amazing at one question: "Is this exact key here, and what is stored with it?" They are bad at almost every question about order, because hashing deliberately scatters keys all over the table. Words that sit next to each other in the dictionary end up in random, unrelated buckets.
- Sorted order. "List all students alphabetically." A hash table has no idea; you would have to pull everything out and sort it.
- Ranges. "Every score between 80 and 90." A hash table can only answer this by checking every key.
- Smallest, largest, or nearest. "Who has the earliest deadline?" "What's the closest price to $20?" Again, a full scan.
- Hard speed promises. A hash table is fast on average, and each resize is a pause. If even one slow operation is unacceptable, you may want a structure with a firm worst-case guarantee.
All four of these are what trees are good at. A balanced binary search tree keeps keys in sorted order and answers "find," "range," "smallest" and "next one up" in a number of steps that grows only slowly with the size — like binary search, but on a structure you can keep adding to. A heap hands you the smallest item right away. That is next week.
| You need to… | Use |
|---|---|
| Check if a username is taken; count word frequencies; remove duplicates | Hash table (dict / set) |
| Show results in sorted order; find everything in a range; find the nearest key | Balanced search tree (Notes 07) |
| Repeatedly grab the most urgent or smallest item | Heap / priority queue (Notes 07) |
(a) Hash table — a set of words; it is a pure "is it here?" question. (b) Tree — "top 10" and "ranks 50 to 60" are questions about order and ranges, which a hash table can't answer without sorting everything. (c) Hash table — a dict from IP address to count; each visit is one exact-key lookup and update.
Section 10Before next week
- Review Notes 06. Then close the page and explain to a friend, in plain words: what a hash function does, what a collision is, the two ways to handle one, and why doubling makes resizing cheap. If you can do that, you have it.
- Read the Project 2 specification on the course page — Break the Hash Table, a 30-minute villain mission built on exactly these notes — and bring questions on Tuesday. Typing in the class from §8 and running the two tests from the fold will make the crime easier to commit.
- Try it in the demo: in Demonstration 1, switch to linear probing, turn auto-resize off, and fill the table to 7 of 8 slots. Search for a few words that are not there and count the checks. Compare with the table in §5.
- Optional challenge: rewrite the §8 class to use linear probing with tombstones instead of chaining. Make sure resizing throws the tombstones away.
Trees, binary search trees, balancing, heaps and priority queues — the structures that keep things in order. We will see why a plain binary search tree can turn into a slow, lopsided chain (much like an all-in-one-bucket hash table), how balancing fixes that, and how a heap hands you the smallest item right away. Together with this week, that completes your data-structure toolkit.