Load Factor and Rehashing
Chapter One Hundred Seven
Syllabus topic Module 2, "hash table, hash functions"
Pages 384 to 390 of 411
In one line
The load factor is records divided by buckets; every hash table cost grows with it; so when it crosses a threshold the table is rebuilt at roughly twice the size, which costs O(n) once but O(1) per insertion on average.
The load factor
alpha = n / m = number of records / number of buckets
It is the single number that predicts a hash table's performance, and the previous chapters gave the formulas in terms of it:
| Scheme | Successful search | Unsuccessful search | alpha may exceed 1? |
|---|---|---|---|
| Chaining | 1 + alpha/2 | alpha | yes |
| Linear probing | (1 + 1/(1-alpha))/2 | (1 + 1/(1-alpha) squared)/2 | no |
| Quadratic probing | similar, worse than double hashing | no, and must stay below 0.5 | |
| Double hashing | (1/alpha) ln(1/(1-alpha)) | 1/(1-alpha) | no |
import math
print("%8s %14s %16s %16s %18s"
% ("alpha", "chaining miss", "linear miss", "double miss", "linear hit"))
for alpha in (0.25, 0.5, 0.75, 0.9, 0.95, 0.99):
chaining = alpha
linear = (1 + 1 / (1 - alpha) ** 2) / 2
double = 1 / (1 - alpha)
linear_hit = (1 + 1 / (1 - alpha)) / 2
print("%8.2f %14.2f %16.1f %16.1f %18.1f"
% (alpha, chaining, linear, double, linear_hit))
print()
print("chaining grows in a straight line. every open addressing scheme")
print("blows up, because the probe sequence has to find one of the few")
print("remaining free slots.")
print()
print("at alpha = 0.99 a failed linear probing search costs about %d probes."
% ((1 + 1 / 0.01 ** 2) / 2))
print("the table is not full. it is 99 per cent full, and that is enough.") alpha chaining miss linear miss double miss linear hit
0.25 0.25 1.4 1.3 1.2
0.50 0.50 2.5 2.0 1.5
0.75 0.75 8.5 4.0 2.5
0.90 0.90 50.5 10.0 5.5
0.95 0.95 200.5 20.0 10.5
0.99 0.99 5000.5 100.0 50.5
chaining grows in a straight line. every open addressing scheme
blows up, because the probe sequence has to find one of the few
remaining free slots.
at alpha = 0.99 a failed linear probing search costs about 5000 probes.
the table is not full. it is 99 per cent full, and that is enough.The thresholds actually used
| Implementation | Scheme | Grows when alpha reaches |
|---|---|---|
Java HashMap | chaining | 0.75 |
Python dict | open addressing, a probe sequence of its own | about 0.66 |
C++ unordered_map | chaining | 1.0 by default |
| A textbook linear probing table | linear probing | 0.5 to 0.7 |
| A textbook quadratic probing table | quadratic probing | 0.5, by the theorem of chapter 106 |
The pattern: chaining tolerates about 1, open addressing about 0.6 to 0.7, and quadratic probing is capped at 0.5 by arithmetic rather than by taste.
Rehashing: why the records cannot simply be copied
When the threshold is crossed, a larger table is allocated and every record is hashed again and placed in the new table. This is rehashing, and the reason it is not a copy is simple: the slot was k mod m, and m has changed.
Load Factor and Rehashing
OLD, NEW = 11, 23
KEYS = [25, 36, 47, 58, 69]
print("%8s %14s %14s %s" % ("key", "slot in %d" % OLD, "slot in %d" % NEW, "same?"))
for k in KEYS:
a, b = k % OLD, k % NEW
print("%8d %14d %14d %s" % (k, a, b, a == b))
print()
same = sum(1 for k in KEYS if k % OLD == k % NEW)
print("%d of %d keys keep their slot." % (same, len(KEYS)))
print("so the old array cannot be copied into the new one. every record")
print("must be hashed again with the NEW m and placed accordingly.")
print()
print("this is also why a hash table cannot be written to disk and reloaded")
print("into a table of a different size without rebuilding it.") key slot in 11 slot in 23 same?
25 3 2 False
36 3 13 False
47 3 1 False
58 3 12 False
69 3 0 False
0 of 5 keys keep their slot.
so the old array cannot be copied into the new one. every record
must be hashed again with the NEW m and placed accordingly.
this is also why a hash table cannot be written to disk and reloaded
into a table of a different size without rebuilding it.Rehashing, implemented
def next_prime(n):
def prime(x):
if x < 2:
return False
d = 2
while d * d <= x:
if x % d == 0:
return False
d += 1
return True
while not prime(n):
n += 1
return n
class GrowingTable:
"""Chaining, with automatic growth at a threshold."""
THRESHOLD = 0.75
def __init__(self, buckets=7):
self.table = [[] for _ in range(buckets)]
self.count = 0
self.rehashes = 0
self.records_moved = 0
def load_factor(self):
return self.count / len(self.table)
def insert(self, key, value):
chain = self.table[key % len(self.table)]
for i, (k, _v) in enumerate(chain):
if k == key:
chain[i] = (key, value)
return
chain.append((key, value))
self.count += 1
if self.load_factor() > self.THRESHOLD:
self._rehash()
def _rehash(self):
old = self.table
size = next_prime(2 * len(old) + 1)
self.table = [[] for _ in range(size)]
for chain in old:
for key, value in chain:
self.table[key % size].append((key, value))
self.records_moved += 1
self.rehashes += 1
def search(self, key):
for k, v in self.table[key % len(self.table)]:
if k == key:
return v
return None
t = GrowingTable(buckets=7)
print("%8s %10s %10s %12s %12s"
% ("inserted", "buckets", "alpha", "rehashes", "moved so far"))
for i in range(1, 201):
t.insert(i * 13, "record %d" % i)
if i in (5, 6, 10, 20, 50, 100, 200):
print("%8d %10d %10.2f %12d %12d"
% (i, len(t.table), t.load_factor(), t.rehashes, t.records_moved))
print()
print("200 records ended in a table of %d buckets at alpha %.2f."
% (len(t.table), t.load_factor()))
print("it rehashed %d times and moved %d records in total."
% (t.rehashes, t.records_moved))
print("that is %.2f moves per record inserted."
% (t.records_moved / 200))
print()
print("every record is still findable after all that rehashing:",
all(t.search(i * 13) == "record %d" % i for i in range(1, 201)))Load Factor and Rehashing
inserted buckets alpha rehashes moved so far
5 7 0.71 0 0
6 17 0.35 1 6
10 17 0.59 1 6
20 37 0.54 2 19
50 79 0.63 3 47
100 163 0.61 4 107
200 331 0.60 5 230
200 records ended in a table of 331 buckets at alpha 0.60.
it rehashed 5 times and moved 230 records in total.
that is 1.15 moves per record inserted.
every record is still findable after all that rehashing: TrueNote the last line. A rehash rewrites the entire structure, so the test that matters is that every record is still retrievable afterwards, and it is.
Why the table doubles, measured
A rehash costs O(n). If it happened often, the O(1) promise would be worthless. The reason it does not is that the table doubles, so rehashes become exponentially rarer as the table grows.
def total_moves(n, grow):
"""Count every record move while inserting n records."""
size, count, moved, rehashes = 7, 0, 0, 0
for _ in range(n):
count += 1
if count / size > 0.75:
size = grow(size)
moved += count
rehashes += 1
return moved, rehashes
print("%10s %18s %12s %14s %18s %12s %14s"
% ("records", "doubling moves", "rehashes", "moves/record",
"add 10 moves", "rehashes", "moves/record"))
for n in (100, 1000, 10000, 100000):
d_moved, d_rehash = total_moves(n, lambda s: 2 * s + 1)
a_moved, a_rehash = total_moves(n, lambda s: s + 10)
print("%10d %18d %12d %14.2f %18d %12d %14.2f"
% (n, d_moved, d_rehash, d_moved / n,
a_moved, a_rehash, a_moved / n))
print()
d = [total_moves(n, lambda s: 2 * s + 1)[0] / n
for n in (100, 1000, 10000, 100000)]
a = [total_moves(n, lambda s: s + 10)[0] / n
for n in (100, 1000, 10000, 100000)]
print("doubling: the moves per record stay BOUNDED, between %.1f and %.1f"
% (min(d), max(d)))
print("across a thousandfold increase in n. they rise and fall with where")
print("the last rehash happened to land, and they do not grow. that bound")
print("is what AMORTISED O(1) means: one insert may cost O(n), but the")
print("average over all of them is a constant.")
print()
print("growing by a fixed 10: the moves per record go from %.1f to %.1f,"
% (a[0], a[-1]))
print("a thousandfold rise, because the number of rehashes grows with n and")
print("each one copies everything. the total is O(n squared), which destroys")
print("the structure.")
print()
print("at 100,000 records, doubling moved %d and adding 10 moved %d."
% (total_moves(100000, lambda s: 2 * s + 1)[0],
total_moves(100000, lambda s: s + 10)[0]))Load Factor and Rehashing
records doubling moves rehashes moves/record add 10 moves rehashes moves/record
100 186 5 1.86 660 13 6.60
1000 1530 8 1.53 66600 133 66.60
10000 12282 11 1.23 6666000 1333 666.60
100000 196602 15 1.97 666660000 13333 6666.60
doubling: the moves per record stay BOUNDED, between 1.2 and 2.0
across a thousandfold increase in n. they rise and fall with where
the last rehash happened to land, and they do not grow. that bound
is what AMORTISED O(1) means: one insert may cost O(n), but the
average over all of them is a constant.
growing by a fixed 10: the moves per record go from 6.6 to 6666.6,
a thousandfold rise, because the number of rehashes grows with n and
each one copies everything. the total is O(n squared), which destroys
the structure.
at 100,000 records, doubling moved 196602 and adding 10 moved 666660000.That table is the argument, and it is the same argument as chapter 12's growable array: growth must be multiplicative, never additive. Doubling holds the moves per record inside a fixed band, 1.2 to 2.0 here, however large n gets; adding a fixed amount took it from 6.6 to 6,666.6 over the same range, which is the O(n squared) total showing itself.
Choosing the new size
Three rules, each with its reason:
At least double. Anything less makes the rehashes too frequent, as the measurement shows.
Take the next prime. Chapter 101 showed what a composite table size does to the division method, and chapter 106's theorem requires a prime for quadratic probing.
Never shrink at the same threshold that grows. If the table grows at alpha 0.75 and shrinks at alpha 0.75, a program that repeatedly inserts and deletes one record near the boundary rehashes on every operation.
def simulate(grow_at, shrink_at, operations):
"""One record added and removed repeatedly, right at the boundary."""
size, count, rehashes = 101, 76, 0 # alpha just above 0.75
adding = False
for _ in range(operations):
count += 1 if adding else -1
adding = not adding
if count / size > grow_at:
size = 2 * size + 1
rehashes += 1
elif count / size < shrink_at:
size = max(11, size // 2)
rehashes += 1
return rehashes
print("100 operations alternating one insert and one delete at the boundary:")
print(" grow at 0.75, shrink at 0.75 :", simulate(0.75, 0.75, 100), "rehashes")
print(" grow at 0.75, shrink at 0.25 :", simulate(0.75, 0.25, 100), "rehashes")
print()
print("the same work, and the first arrangement rebuilt the whole table")
print("on %d of the 100 operations. the gap between the two thresholds is"
% simulate(0.75, 0.75, 100))
print("called HYSTERESIS, and without it a table at the boundary thrashes.")Load Factor and Rehashing
100 operations alternating one insert and one delete at the boundary:
grow at 0.75, shrink at 0.75 : 100 rehashes
grow at 0.75, shrink at 0.25 : 1 rehashes
the same work, and the first arrangement rebuilt the whole table
on 100 of the 100 operations. the gap between the two thresholds is
called HYSTERESIS, and without it a table at the boundary thrashes.Rehashing clears tombstones
One more benefit, and it is the cure chapter 105 promised. A rehash reinserts only the live records, so every tombstone disappears.
class Tombstone:
def __repr__(self):
return "DELETED"
EMPTY = None
DELETED = Tombstone()
def rehash(slots, new_size):
"""Only live records are carried over. Tombstones are simply not copied."""
fresh = [EMPTY] * new_size
carried = 0
for cell in slots:
if cell is not EMPTY and cell is not DELETED:
at = cell % new_size
while fresh[at] is not EMPTY:
at = (at + 1) % new_size
fresh[at] = cell
carried += 1
return fresh, carried
M = 23
slots = [EMPTY] * M
for k in range(1, 18):
at = (k * 7) % M
while slots[at] is not EMPTY:
at = (at + 1) % M
slots[at] = k * 7
for k in range(1, 14): # delete 13 of the 17
for i in range(M):
at = ((k * 7) % M + i) % M
if slots[at] is not DELETED and slots[at] == k * 7:
slots[at] = DELETED
break
print("before rehashing:")
print(" live records :", sum(1 for s in slots
if s is not EMPTY and s is not DELETED))
print(" tombstones :", sum(1 for s in slots if s is DELETED))
print(" empty :", sum(1 for s in slots if s is EMPTY))
fresh, carried = rehash(slots, M)
print()
print("after rehashing into a table of the same size %d:" % M)
print(" live records :", sum(1 for s in fresh
if s is not EMPTY and s is not DELETED))
print(" tombstones :", sum(1 for s in fresh if s is DELETED))
print(" empty :", sum(1 for s in fresh if s is EMPTY))
print()
print("records carried over:", carried)
print("tombstones remaining:", sum(1 for s in fresh if s is DELETED))
print("so a delete-heavy table is repaired by rehashing even without")
print("growing, which is why implementations rehash on a tombstone count")
print("as well as on a load factor.")before rehashing:
live records : 4
tombstones : 13
empty : 6
after rehashing into a table of the same size 23:
live records : 4
tombstones : 0
empty : 19
records carried over: 4
tombstones remaining: 0
so a delete-heavy table is repaired by rehashing even without
growing, which is why implementations rehash on a tombstone count
as well as on a load factor.Load Factor and Rehashing
Quick revision
- Load factor alpha = n/m. Every hash table cost in this module is a function of it.
- Chaining's cost grows linearly in alpha and alpha may exceed 1; every open addressing cost blows up as alpha approaches 1.
- Real thresholds: Java HashMap 0.75, Python dict about 0.66, C++ unordered_map 1.0, quadratic probing capped at 0.5 by arithmetic.
- Rehashing is not a copy: the slot is k mod m and m has changed, so every record must be hashed again.
- The new size must be at least double and should be the next prime.
- Doubling keeps the record moves per insertion inside a fixed band, 1.2 to 2.0 in the measurement, however large n gets: that bound is what amortised O(1) means.
- Growing by a fixed amount took the moves per record from 6.6 at 100 records to 6,666.6 at 100,000, because the total work is O(n squared).
- Growth must be multiplicative, exactly as for chapter 12's growable array.
- Growing and shrinking at the same threshold makes a table at the boundary rehash on nearly every operation; the gap between the thresholds is called hysteresis.
- Rehashing also removes every tombstone, which is why open addressing tables rehash on a tombstone count as well as on a load factor.
Test yourself
1. Define the load factor and say why it is the central number. alpha = n/m, records divided by buckets. Every search, insert and delete cost for chaining and for open addressing is a formula in alpha, so it predicts performance by itself.
2. Why must a hash table be rebuilt rather than copied when it grows? Because a record's slot is its key modulo the table size, and the table size has changed, so almost no record belongs in its old position.
3. What is rehashing, and what does it cost? Allocating a larger table and reinserting every record by hashing it again. One rehash costs O(n).
4. Explain why rehashing does not break the O(1) promise. Because the table doubles, so rehashes become exponentially rarer. The total record moves over n insertions stay proportional to n: measured, the moves per insertion stayed between 1.2 and 2.0 from 100 records to 100,000. A bounded average per insertion is what amortised O(1) means.
5. What happens if the table grows by a fixed amount instead of doubling? The number of rehashes grows with n and each copies everything, so the total work is O(n squared) and the moves per record grow without limit.
Load Factor and Rehashing
6. Give the two rules for choosing the new table size. At least double the old size, and take the next prime, since a composite size damages the division method and quadratic probing requires a prime.
7. Why must the shrink threshold differ from the grow threshold? Otherwise a program inserting and deleting one record at the boundary triggers a full rebuild on nearly every operation. The gap between the two thresholds is hysteresis.
8. What does rehashing do about tombstones? It removes them, because only live records are reinserted. This is why an open addressing table may rehash on a tombstone count even when its load factor is low.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself, or the past papers, for the same subject.