munotes®

Load Factor and Rehashing

Get access to whole semester resourcesSemester Pass

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:

SchemeSuccessful searchUnsuccessful searchalpha may exceed 1?
Chaining1 + alpha/2alphayes
Linear probing(1 + 1/(1-alpha))/2(1 + 1/(1-alpha) squared)/2no
Quadratic probingsimilar, worse than double hashingno, 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

ImplementationSchemeGrows when alpha reaches
Java HashMapchaining0.75
Python dictopen addressing, a probe sequence of its ownabout 0.66
C++ unordered_mapchaining1.0 by default
A textbook linear probing tablelinear probing0.5 to 0.7
A textbook quadratic probing tablequadratic probing0.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.

munotes.in384

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)))
munotes.in385

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: True

Note 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]))
munotes.in386

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.")
munotes.in387

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.
munotes.in388

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.

munotes.in389

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.

munotes.in390

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.

Issue
Done!