Skip to content
About
Main content

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 , 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 (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. (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

At load factor 0.95, how many probes does a failed lookup take with linear probing?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    hash-table-load-factor-measure-the.py51 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Probes per lookup against the load factorApple M2 · 8 cores · 8 GB
Successful lookupFailed lookup1/(1 − α)
Data table
Probes per lookup against the load factor
Load factor αSuccessful lookupFailed lookup1/(1 − α)
0.51.52.52
0.72.175.993.33
0.83.0112.85
0.95.4249.510
0.9510.420620
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Lookup time: linear probing against chainingApple M2 · 8 cores · 8 GB
Linear probingChaining
Data table
Lookup time: linear probing against chaining
Load factor αLinear probingChaining
0.5272345
0.7405365
0.8552437
0.9732335
0.951,310336
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Probes and time at each load

Probes and time at each load
αHit probesFormulaMiss probesFormulaLinear, nsChaining, ns
0.51.51.52.52.5272345
0.72.172.175.996.06405365
0.83.01312.813552437
0.95.425.549.550.5732335
0.9510.410.52062001,310336
Output · uv run hash-table-load-factor-measure-the.py
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 ns

What 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 , and that depends on the layout, not on the algorithm on paper.