Skip to content
Educora
University25 min48 / 59

Hash tables

Learn how hash functions turn keys into indexes, how collisions are resolved by chaining and open addressing, and how the load factor keeps operations O(1) on average.

Check yourself
In this lesson you will learn
  • Compute a polynomial hash and an index for a string key
  • Fill a table step by step with chaining and linear probing
  • Estimate the expected number of probes from the load factor

Finding Elvin's number in a phone book by scanning the whole list takes O(n) time; binary search in a sorted list takes O(log n). What if the number of steps did not depend on n at all? That is what a hash table does: it turns the key directly into an array index. Python's dict and set, database hash indexes, caches and compilers' symbol tables are all built on this idea.

The hash function

Definition
Hash table

An array of size m (its cells are called buckets) together with a hash function h that maps a key to an index in 0 … m − 1. A key → value pair is stored in bucket h(key), and a lookup starts right at that bucket.

h(s) = (s₀ · pᵏ⁻¹ + s₁ · pᵏ⁻² + … + sₖ₋₁ · p⁰) mod m
where:
  • s₀ … sₖ₋₁numeric codes of the string's characters
  • pbase (usually a small prime such as 31)
  • klength of the string
  • mnumber of buckets

A polynomial hash depends on both the characters and their order: “ab” and “ba” get different hashes.

Example 1: hashing a string

Give letters codes a = 1, b = 2, c = 3. With p = 31 and m = 11, which bucket does the key “cab” go to?

Show solution
h = 3 · 31² + 1 · 31 + 2 = 3 · 961 + 31 + 2 = 2883 + 33 = 2916.
2916 = 11 · 265 + 1, so 2916 mod 11 = 1.
“cab” goes to bucket 1. In practice Horner's scheme is used: h = (h · 31 + code) — one multiplication and one addition per character, i.e. O(k).
  • Deterministic: the same key always gives the same hash.
  • Uniformity: keys spread roughly evenly over the buckets.
  • Speed: computing it takes O(key length) time.
  • Uses the whole key: a function that looks only at the first letter sends “Aysel” and “Anar” to the same bucket.

Collisions and how to resolve them

There are far more possible keys than buckets, so by the pigeonhole principle two different keys landing in the same bucket — a collision — is unavoidable. It also happens sooner than expected: this is the “birthday paradox”. In a group of just 23 people the chance that two share a birthday is already above 50%.

P(no collision) ≈ e^(−k(k − 1) / (2m))
where:
  • knumber of keys inserted
  • mnumber of buckets

m = 365, k = 23: k(k − 1)/2 = 253, 253/365 ≈ 0.693, e^(−0.693) ≈ 0.5. About √m keys are enough for collisions to start.

  • Separate chaining: each bucket holds a list, and colliding keys are simply appended to it.
  • Open addressing: all keys live in the array itself; if a bucket is taken, we look for the next free slot. Linear probing: h(k), h(k) + 1, h(k) + 2, … (mod m).
  • Quadratic probing (h(k) + i²) and double hashing (h₁(k) + i · h₂(k)) reduce the piling-up of keys (clustering).
Example 2: linear probing

m = 11, h(k) = k mod 11. Insert the keys 12, 44, 13, 88, 23, 94, 11, 39, 20 using linear probing.

Show solution
12 → 1; 44 → 0; 13 → 2.
88 → 0 taken, 1 taken, 2 taken → 3 (4 probes).
23 → 1, 2, 3 taken → 4 (4 probes).
94 → 6.
11 → 0, 1, 2, 3, 4 taken → 5 (6 probes).
39 → 6 taken → 7; 20 → 9.
Result: [44, 12, 13, 88, 23, 11, 94, 39, —, 20, —], α = 9/11 ≈ 0.82.
Cells 0–7 form a cluster: every new key that lands there will travel a long way.
Python
def insert_linear(table, key):
    m = len(table)
    i = key % m
    probes = 1
    while table[i] is not None:
        i = (i + 1) % m
        probes += 1
    table[i] = key
    return probes

table = [None] * 11
for k in [12, 44, 13, 88, 23, 94, 11, 39, 20]:
    p = insert_linear(table, k)
    print(k, '-> slot', table.index(k), 'probes:', p)
print(table)
print('load factor:', round(9 / 11, 2))
▸ Expected output
12 -> slot 1 probes: 1
44 -> slot 0 probes: 1
13 -> slot 2 probes: 1
88 -> slot 3 probes: 4
23 -> slot 4 probes: 4
94 -> slot 6 probes: 1
11 -> slot 5 probes: 6
39 -> slot 7 probes: 2
20 -> slot 9 probes: 1
[44, 12, 13, 88, 23, 11, 94, 39, None, 20, None]
load factor: 0.82
The program confirms our hand trace. With chaining the same keys would sit like this: 0: 44 → 88 → 11, 1: 12 → 23, 2: 13, 6: 94 → 39, 9: 20.

Load factor and O(1) on average

α = n / mα = n / m
where:
  • αload factor
  • nnumber of keys in the table
  • mnumber of buckets

With chaining α is the average chain length and may exceed 1; with open addressing α < 1 always.

E(chaining) = 1 + α; E(linear probing) ≈ ½ · (1 + 1 / (1 − α)²)E(chaining) = 1 + α; E(linear probing) ≈ ½ · (1 + 1 / (1 − α)²)
where:
  • Eexpected number of probes in an unsuccessful search (key absent)

Assuming a uniformly spreading hash. The linear-probing formula comes from Knuth's analysis; as α → 1 the probes explode.

αChaining: 1 + αLinear probing (unsuccessful)Linear probing (successful) ½(1 + 1/(1 − α))
0.51.52.51.5
0.751.758.52.5
0.91.950.55.5
Expected number of probes. Open-addressing tables should not be filled beyond α ≈ 0.5–0.75.

When α crosses a threshold (0.75 in Java's HashMap, for example), the table roughly doubles and every key is hashed again for the new m (rehashing). This occasional O(n) work is amortised exactly as in a dynamic array, so insert, search and delete stay O(1) on average. In the worst case — all keys in one bucket — they are O(n).

Python
class HashMap:
    def __init__(self, m=5):
        self.buckets, self.n = [[] for _ in range(m)], 0
    def _index(self, key):
        h = 0
        for ch in key:
            h = h * 31 + ord(ch)
        return h % len(self.buckets)
    def put(self, key, value):
        bucket = self.buckets[self._index(key)]
        for pair in bucket:
            if pair[0] == key:
                pair[1] = value
                return
        bucket.append([key, value])
        self.n += 1
        if self.n / len(self.buckets) > 0.75:
            pairs = [p for b in self.buckets for p in b]
            self.buckets = [[] for _ in range(2 * len(self.buckets) + 1)]
            for p in pairs:
                self.buckets[self._index(p[0])].append(p)
    def get(self, key):
        for k, v in self.buckets[self._index(key)]:
            if k == key:
                return v

caps = HashMap()
for pair in ['Azerbaijan:Baku', 'Georgia:Tbilisi', 'Japan:Tokyo', 'Italy:Rome', 'Egypt:Cairo']:
    caps.put(*pair.split(':'))
print(caps.get('Azerbaijan'), caps.get('Japan'), caps.get('France'))
print(len(caps.buckets), [len(b) for b in caps.buckets])
▸ Expected output
Baku Tokyo None
11 [1, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0]
A small hash table with chaining: when the 4th key arrives α = 4/5 = 0.8 > 0.75, and the table grows from 5 to 11 buckets. All five keys end up in separate buckets, and a missing key returns None.
StructureSearchInsertOrder / range query
Unsorted arrayO(n)amortised O(1)no
Sorted arrayO(log n)O(n)yes
Balanced search treeO(log n)O(log n)yes
Hash tableO(1) avg, O(n) worstO(1) avgno
A hash table gives the fastest “is it there?” answer, but keeps no order: queries like the minimum or “from 70 to 80” need a tree.

Key points

  • A hash table maps a key to index h(k) mod m; operations are O(1) on average and O(n) in the worst case.
  • Collisions are unavoidable (birthday paradox): they start after about √m keys.
  • Chaining keeps lists in the buckets; open addressing probes for the next free cell and marks deleted slots with tombstones.
  • Load factor α = n/m; when α passes a threshold the table grows and rehashes (amortised O(1)).
  • A hash table keeps no order: choose a tree for range and minimum queries.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
m = 7, h(k) = k mod 7, linear probing. The keys 10, 17, 24 are inserted into an empty table in that order. Which cell does 24 end up in?