More Buckets Will Not Save a Bad Hash Function
“Hash tables are O(1)” gets repeated until the conditions fall off the end of the sentence. The full version is: constant time on average, for a hash function that spreads keys evenly, at a load factor somebody is watching. Remove any one of those and lookups slide back toward a linear scan.
The dangerous part is that the table still returns correct answers the whole way down. There is no error, no warning, and no test failure — just a program that gets slower as it gets more data, for a reason that never appears in the code that uses it.
Change the hash, not the table
Start on length of the word and drag the bucket slider. Then switch hash function and drag it again.
A hash table sends each key to a bucket by running it through a hash function. When two keys land in the same bucket they form a chain, and a lookup has to walk that chain — so the cost of a lookup is decided by how evenly the hash spreads the keys.
With these 12 keys and a hash that uses only the LENGTH of the word, 8 buckets give a longest chain of 6 and an average successful lookup of 2.42 probes. Doubling the table to 16 buckets changes that to a longest chain of 6 and 2.42 probes — that is, nothing improves, and 11 of the 16 buckets sit completely empty. Memory cannot fix a hash that ignores its input. Switching to a polynomial hash over the same 16 buckets gives a longest chain of 2 and 1.25 probes.
Pick a hash function, then drag the table size. Click any key to look it up — the walk it takes is highlighted, and the probe count is what that lookup actually cost.
- Load factor
- 0
- Longest chain
- 0
- Avg probes
- 0
- Empty buckets
- 0
The same table, as text
Hash
Buckets
Click any key to look it up. The highlighted cells are the entries that lookup had to step over, and the probe count is what it cost.
The thing worth staring at: with the length hash, going from 8 buckets to 16 does not shorten the longest chain at all. It just creates empty buckets. Every six-letter word still hashes to the same place, because the hash never looked at anything except the length — and doubling the number of places to put things does not help when your rule for choosing a place ignores the thing you are placing.
Why the two bad hashes are bad differently
Length of the word collapses a rich key into a small integer. English words cluster hard at four to nine characters, so a dozen keys collapse into about five distinct values — and no table size recovers information the hash already threw away.
First letter is a real improvement and still not enough. It has at most 26 possible outputs however large the table is, so any table with more than 26 buckets is mostly wasted, and real key sets are never spread evenly across the alphabet anyway. Names starting with S vastly outnumber names starting with X.
A polynomial hash folds every character into the result, and multiplying by 31 at each step
means position matters too, so "stop" and "post" do not collide. This is the shape of hash real
string implementations use.
The numbers, computed
Same twelve keys, three hash functions, two table sizes.
KEYS = ["apple", "apricot", "avocado", "banana", "berry", "cherry",
"date", "fig", "grape", "guava", "lemon", "mango"]
def h_length(key):
"""Throws away everything except how long the key is."""
return len(key)
def h_first(key):
"""Uses one character. At most 26 distinct values, whatever the table size."""
return ord(key[0])
def h_poly(key):
"""Every character contributes, and its position matters."""
h = 0
for ch in key:
h = (h * 31 + ord(ch)) % 4294967296
return h
def build(keys, hash_fn, buckets):
table = [[] for _ in range(buckets)]
for key in keys:
table[hash_fn(key) % buckets].append(key)
return table
def measure(keys, hash_fn, buckets):
table = build(keys, hash_fn, buckets)
probes = sum(i + 1 for chain in table for i, _ in enumerate(chain))
longest = max(len(chain) for chain in table)
empty = sum(1 for chain in table if not chain)
return longest, probes / len(keys), empty
print(f"{'hash':<20} {'buckets':>7} {'longest':>7} {'avg probes':>10} {'empty':>5}")
for name, fn in [("length of the word", h_length), ("first letter", h_first), ("polynomial x31", h_poly)]:
for buckets in (8, 16):
longest, avg, empty = measure(KEYS, fn, buckets)
print(f"{name:<20} {buckets:>7} {longest:>7} {avg:>10.2f} {empty:>5}")hash buckets longest avg probes empty length of the word 8 6 2.42 3 length of the word 16 6 2.42 11 first letter 8 3 1.50 1 first letter 16 3 1.42 8 polynomial x31 8 3 1.75 2 polynomial x31 16 2 1.25 7
Read the first two rows together. Doubling the table left the longest chain at 6 and the average lookup at 2.42 probes — identical — while leaving 11 of 16 buckets empty. That is twice the memory for exactly no speed. The last two rows are what spending memory is supposed to buy: the longest chain halves and the average lookup drops to 1.25 probes.
Load factor is the other half
Even a perfect hash slows down as a table fills. The load factor — keys divided by buckets — is what real implementations watch, and crossing a threshold triggers a resize: allocate a bigger table and re-hash every key into it.
That resize is why an individual insert into a hash table is occasionally very expensive while the average stays cheap. It is also why the two failure modes are easy to confuse. A table that is merely full gets faster when you give it more buckets. A table with a bad hash does not, and reaching for more memory is the natural wrong move.
The distinguishing question is the one the widget makes visible: are the empty buckets increasing while the longest chain stays put? If so, the problem is the hash.
What this page simplifies
- Separate chaining only. Real tables often use open addressing, where colliding keys are placed in other slots rather than chained. It has a different and nastier failure mode — clustering — and deserves its own page.
- No resizing. The widget’s table never grows on its own, so load factor is something you set rather than something the structure manages. Real implementations resize automatically.
- Probes, not seconds. The counts are entries examined. As with arrays and linked lists, chain walking also costs cache misses that this model does not charge for.
- Real hashes are stronger than the polynomial one. Production hashes are designed against adversarial inputs too, because a hash an attacker can predict is a way to force every key into one bucket deliberately. That attack is real and is why many languages randomise their string hash at startup.