The load factor is the answer
10,000 items, index with resizing on:
$ algo index -in s10k.jsonl
n = 10000, resize = true
buckets 16384
stored 10000
resizes 10
load factor 0.610
empty buckets 8925
mean chain 1.341
LONGEST chain 5
build time 1.5ms
10000 lookups: 13095 comparisons (1.310 per lookup), 0s
same by linear scan: 50005000 comparisons (5000.5 per lookup), 85ms
1.310 comparisons per lookup. The linear scan spent 5000.5 on the same 10,000 questions. That is 3,818× fewer, and 85 ms against a time the clock did not register.
The worst case — the longest chain — is 5. Not 5,000. That is what "O(1)
average" means: not that every lookup costs exactly one step, but that the chains
stay short and stop depending on n.
Where the O(1) goes without growth
$ algo index -in s10k.jsonl -nogrow -buckets 16
n = 10000, resize = false
buckets 16
stored 10000
resizes 0
load factor 625.000
empty buckets 0
mean chain 625.000
LONGEST chain 680
build time 20.527ms
10000 lookups: 3134392 comparisons (313.439 per lookup), 17.117ms
Same data, same hash function, same code — 239× more comparisons. Because 10,000 items poured into 16 buckets is just 16 linear searches parked next to each other.
Growth is not an optimization. Without it a hash table is not a hash table.
Your guess
In step 1 you wrote down a number: how many comparisons with 10,000 items and 128 buckets?
$ for b in 128 512 2048 8192 16384; do algo index -in s10k.jsonl -nogrow -buckets $b; done
buckets load mean longest cmps/lookup
128 78.125 78.125 105 40.161
512 19.531 19.531 34 10.830
2048 4.883 4.929 15 3.465
8192 1.221 1.740 7 1.620
16384 0.610 1.341 5 1.310
40.161 — about half the load factor (78.125 / 2 ≈ 39), plus a little.
And it holds down the whole table: 19.531 → 10.8, 4.883 → 3.5, 1.221 → 1.6. A
successful lookup stops on average halfway along the chain, so the cost is
load / 2 + 1. That is not a rule of thumb — it is just the mean position of a
key that is equally likely to be anywhere in the chain.
Which is why the threshold is 1.0 and not 10 or 100: growth keeps that quotient below two.
And look at the longest column. At 128 buckets the longest chain is 105
against a mean of 78. The average is not a promise about any individual lookup;
some keys will always be worse off.