Hash table load factor: measure the hyperbola
Measure your machineNeeds Python
Problem
A hash table answers "where is key k?" in one step only while it is mostly empty. With linear probing, a key that lands on an occupied slot walks to the next one, and runs of occupied slots merge as the table fills, so the expected number of probes grows with 1/(1 − α), where α is the load factor (the share of slots in use). A successful lookup costs about ½(1 + 1/(1 − α)) probes and a failed one about ½(1 + 1/(1 − α)²): the curve is flat at low load and turns into a wall near full. Chaining (a list per slot) keeps fewer comparisons near full, but every link is a pointer to follow, which is a cache miss rather than a step along one line.
Build the linear-probing table from Lookup structures (fixed size, for example 2²⁰ slots). Insert random keys until the load factor α reaches 0.5, 0.7, 0.8, 0.9 and 0.95. At each point, count the average probes for 100,000 successful lookups and 100,000 failed lookups. Plot probes against α and put the curve 1/(1−α) on the same chart. Then repeat with chaining and compare time (not probe count) for the same loads.
The question. Do the measured probe counts follow the two formulas, and does probe count or cache behaviour decide the lookup time?
Predict
Expect about 1.5 and 2.5 probes at α = 0.5, about 5.5 and 50 at α = 0.9 (the formulas ½(1 + 1/(1−α)) and ½(1 + 1/(1−α)²)). You should see that cache behaviour, not the comparison count, decides.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
hash-table-load-factor-measure-the.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Hash table load factor: measure the probes. A linear-probing table of 2^20 slots filled to load factors from 0.5 to 0.95; average probes for successful and failed lookups against the formulas 1/2 (1 + 1/(1 - a)) and 1/2 (1 + 1/(1 - a)^2). Then lookup time for linear probing against chaining at the same loads. """ import argparse, json, random, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--size", type=int, default=1 << 20, help="Slots in the table.") p.add_argument("--alphas", type=lambda s: [float(v) for v in s.split(",")], default=[0.5, 0.7, 0.8, 0.9, 0.95], help="Load factors.") p.add_argument("--lookups", type=int, default=100_000, help="Lookups per measurement.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) size = args.size keys = [random.getrandbits(64) for _ in range(int(size * 0.96))] table, n = [None] * size, 0 chains = [[] for _ in range(size)] rows = [] for alpha in args.alphas: while n < alpha * size: k = keys[n]; i = k % size while table[i] is not None: i = (i + 1) % size table[i] = k; chains[k % size].append(k); n += 1 def probes(key): i, c = key % size, 1 while table[i] is not None and table[i] != key: i = (i + 1) % size; c += 1 return c present = [keys[random.randrange(n)] for _ in range(args.lookups)] # index, do not copy the list absent = [random.getrandbits(64) for _ in range(args.lookups)] hit = sum(map(probes, present)) / args.lookups; miss = sum(map(probes, absent)) / args.lookups t = time.perf_counter(); [probes(k) for k in present]; t_lin = (time.perf_counter() - t) / args.lookups * 1e9 t = time.perf_counter(); [k in chains[k % size] for k in present]; t_ch = (time.perf_counter() - t) / args.lookups * 1e9 rows.append({"alpha": alpha, "hit_probes": hit, "miss_probes": miss, "hit_formula": 0.5 * (1 + 1 / (1 - alpha)), "miss_formula": 0.5 * (1 + 1 / (1 - alpha) ** 2), "inv": 1 / (1 - alpha), "linear_ns": t_lin, "chain_ns": t_ch}) r = rows[-1] print(f"alpha={alpha:.2f} hit {hit:6.2f} (formula {r['hit_formula']:6.2f}) miss {miss:7.2f} (formula {r['miss_formula']:7.2f}) " f"lookup: linear {t_lin:5.0f} ns, chaining {t_ch:5.0f} ns") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"loads": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run hash-table-load-factor-measure-the.py uv run hash-table-load-factor-measure-the.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --size1 << 20 | Slots in the table. |
| --alphas0.5, 0.7, 0.8, 0.9, 0.95 | Load factors. |
| --lookups100,000 | Lookups per measurement. |
| --seed0 | Random seed. |
Our run
Data table
| Load factor α | Successful lookup | Failed lookup | 1/(1 − α) |
|---|---|---|---|
| 0.5 | 1.5 | 2.5 | 2 |
| 0.7 | 2.17 | 5.99 | 3.33 |
| 0.8 | 3.01 | 12.8 | 5 |
| 0.9 | 5.42 | 49.5 | 10 |
| 0.95 | 10.4 | 206 | 20 |
Data table
| Load factor α | Linear probing | Chaining |
|---|---|---|
| 0.5 | 272 | 345 |
| 0.7 | 405 | 365 |
| 0.8 | 552 | 437 |
| 0.9 | 732 | 335 |
| 0.95 | 1,310 | 336 |
Probes and time at each load
| α | Hit probes | Formula | Miss probes | Formula | Linear, ns | Chaining, ns |
|---|---|---|---|---|---|---|
| 0.5 | 1.5 | 1.5 | 2.5 | 2.5 | 272 | 345 |
| 0.7 | 2.17 | 2.17 | 5.99 | 6.06 | 405 | 365 |
| 0.8 | 3.01 | 3 | 12.8 | 13 | 552 | 437 |
| 0.9 | 5.42 | 5.5 | 49.5 | 50.5 | 732 | 335 |
| 0.95 | 10.4 | 10.5 | 206 | 200 | 1,310 | 336 |
alpha=0.50 hit 1.50 (formula 1.50) miss 2.50 (formula 2.50) lookup: linear 272 ns, chaining 345 ns
alpha=0.70 hit 2.17 (formula 2.17) miss 5.99 (formula 6.06) lookup: linear 405 ns, chaining 365 ns
alpha=0.80 hit 3.01 (formula 3.00) miss 12.82 (formula 13.00) lookup: linear 552 ns, chaining 437 ns
alpha=0.90 hit 5.42 (formula 5.50) miss 49.48 (formula 50.50) lookup: linear 732 ns, chaining 335 ns
alpha=0.95 hit 10.41 (formula 10.50) miss 205.77 (formula 200.50) lookup: linear 1311 ns, chaining 336 nsWhat we found
The probe counts followed the formulas closely: 1.5 and 2.5 at α = 0.5, 5.42 and 49.5 at α = 0.9, and 206 failed-lookup probes at α = 0.95. Time followed probes for linear probing, from 272 ns to 1,310 ns per lookup. Chaining stayed between 345 and 336 ns: at α = 0.5 linear probing was faster, and from 0.7 up chaining was.
In Python every slot holds a pointer to a boxed integer, so a probe in this lab is itself a pointer dereference, and linear probing loses most of the cache advantage it has in a flat array of keys. The crossover is real here, but a native table with inline keys would push it to a higher load.
Read it
The hyperbola is the whole argument for a maximum load factor: the curve is nearly flat up to about 0.7, and the last few percent of fill cost more than all the rest. That is why open-addressing tables resize well before they are full, and why "the table is 95% full" is a performance incident, not a capacity statistic.
Probe count is a model of cost, not the cost. What a probe costs depends on whether the next slot is on the same cache line, and that depends on the layout, not on the algorithm on paper.