Chapter 5 · Lectures

Hash tables

5.1 Hash Tables: The Big Idea

Every data structure you’ve seen so far has been one of two shapes: a list (search costs O(n)O(n)) or a sorted array (search costs O(logn)O(\log n)). A hash table does something radically different. Instead of searching for a key, it computes where the key should live. Search, insert, and remove become O(1)O(1) on average --- constant time, regardless of how many items are stored.

The core trick

Suppose you have an array of size 10 and you want to store the key 25. Where does it go?

index = 25 % 10 = 5

Store it at array[5]. To look it up later: compute the same index, read that slot. No search. No comparison loop. The work of “finding” has been replaced by a calculation that locates the element directly.

Three lines, O(1)O(1) in both operations. This is the entire idea. Everything else in this chapter --- hash functions, collisions, chaining, probing, load factor --- is about making this trick actually work in practice when keys don’t divide up so neatly.

Why not just use a giant array?

If keys are small integers, you already can. An array of size 2322^{32} indexed directly by int keys works --- but that’s 16 GB of memory to hold possibly a handful of values. Hash tables compress a large potential key space into a small array, trading perfect placement for practical memory use.

::: center Approach Lookup Space


Direct array (key = index) O(1)O(1) O(K)O(K) where K = key range Sorted array + binary search O(logn)O(\log n) O(n)O(n) Linked list O(n)O(n) O(n)O(n) Hash table O(1)O(1)^* O(n)O(n) :::

^* average case; worst case can degrade, see later sections.

The hash table gets you array-speed lookup at list-space cost. That combination is so valuable that hash tables are the backbone of almost every real-world fast key-value store: JavaScript objects, Python dicts, Java HashMap, Redis, DNS caches, database indexes (sometimes), compiler symbol tables, the list goes on.

Vocabulary

::: center Term Meaning


Key The value used to look up an item Value The data associated with the key (often absent --- just a set) Bucket A slot in the underlying array Hash function key \to bucket index Collision Two keys that hash to the same bucket Load factor nn items / mm buckets --- a density measure :::

5.2 Chaining

Chaining is the first collision-resolution strategy. The idea is almost embarrassingly simple: if two keys want the same bucket, don’t pick a winner --- let both live there, in a list.

The mental model

Picture the hash table as an array of list heads instead of an array of values. Each slot is either nullptr (empty) or points to a chain of nodes that all hashed to that index.

::: center Bucket Chain


0 nullptr 1 21 \to 31 \to 51 2 nullptr 3 13 4 24 \to 74 :::

Every chain only contains keys that hashed to that index, so the search step only compares against “nearby” items --- ideally just one or two, not the entire table.

Algorithms

Each of the three operations becomes: (1) hash the key to find the bucket, (2) do the list-level operation on that bucket’s chain.

Two implementation notes: insert checks for duplicates first because most hash-table APIs are “unique-key” (insert acts as upsert), and remove uses the Node** trick from chapter 4 so the same code handles head and interior removal without a special case.

Cost

::: center Operation Average Worst Notes


insert O(1+α)O(1 + \alpha) O(n)O(n) Chain walk to check duplicate contains O(1+α)O(1 + \alpha) O(n)O(n) Chain walk to compare keys remove O(1+α)O(1 + \alpha) O(n)O(n) Chain walk to locate node :::

α=n/m\alpha = n/m = load factor = average chain length. When α\alpha is a small constant (say, 1\leq 1), every operation is effectively O(1)O(1). When α\alpha grows, operations slow linearly. Section 5.6 discusses how to keep α\alpha bounded by resizing.

Pros and cons

::: center


Pro Never fills up --- you can keep inserting past the table size, chains just grow longer. Pro Removal is easy --- just unsplice a list node. Pro High load factors tolerable (still works at α>1\alpha > 1). Con Extra memory per item (one next pointer per node). Con Cache-unfriendly --- chain walks follow pointers all over the heap, same as plain linked lists. Con Extra allocation on every insert (one new Node).


:::

5.3 Linear Probing

Linear probing is the first flavor of open addressing --- the “stay inside the array” approach to collisions. When the computed bucket is occupied, walk forward through the array one slot at a time until you find an empty one. That’s it. Simple to describe, surprisingly subtle to get right.

Why it’s interesting

Chaining (5.2) allocates a new node per item. Linear probing does not --- every item lives in the fixed array itself.

::: center Aspect Chaining Linear probing


Extra memory per item 1 pointer 0 Allocations on insert 1 new Node 0 Cache behavior Poor (heap jumps) Excellent (contiguous) Can overflow? Never Yes (array full \Rightarrow insert fails) Removal Unsplice list node Tombstone (see below) :::

For small-to-medium tables, linear probing typically wins on real hardware: no allocation, no pointer chasing, and the CPU prefetches the next probe slot because it’s literally the next cache line. This is why many high-performance hash tables (Google’s dense_hash_map, Robin Hood hashing, Swiss tables) are built on open addressing rather than chaining.

Insert

The % N wrap makes the probe sequence circular --- if you hit the end of the array, you keep going from index 0.

The tombstone problem

Removal on an open-addressed table is not just “mark the bucket empty.” Consider:

  1. Insert 25 (bucket 5).

  2. Insert 35 (bucket 5 occupied, probe to 6).

  3. Remove 25 --- mark bucket 5 empty.

  4. Search for 35 --- check bucket 5, find it empty, stop searching. Returns “not found.” But 35 is there, in bucket 6.

To fix this, open-addressed tables distinguish two kinds of empty:

::: center State Meaning


EMPTY_SINCE_START Never held an item; search can stop here OCCUPIED Currently holds an item EMPTY_AFTER_REMOVAL (tombstone) Was occupied; search continues past :::

The tombstone preserves the “I was here” information that the probe sequence needs. Insertions treat tombstones as empty (can reuse the slot). Searches treat tombstones as occupied (keep probing past).

Search

The three termination conditions matter: (1) found a match, return it; (2) hit a never-occupied slot, definitely not present; (3) probed every bucket, not present.

Cost and load factor

Linear probing’s cost depends dramatically on how full the table is. Let α=n/m\alpha = n/m (load factor).

::: center Load factor α\alpha Avg probes (successful) Feel


0.5 1.5\approx 1.5 Fast 0.75 2.5\approx 2.5 Still OK 0.9 5.5\approx 5.5 Getting sluggish 0.95 10.5\approx 10.5 Noticeable 0.99 50\approx 50 Disaster :::

These numbers aren’t linear in α\alpha --- they grow like 11α\frac{1}{1 - \alpha} (Knuth’s analysis). That’s why open- addressed tables typically enforce a much lower max load factor than chained ones: 0.5—0.75 is common for linear probing versus 1.0+ for chaining.

5.4 Quadratic Probing

Linear probing’s flaw, remember, is primary clustering: once a run of occupied buckets forms, any key that hashes into that run lengthens it, snowballing the problem. Quadratic probing sidesteps clustering by using a probe sequence whose gaps grow with each step.

The probe pattern

With c1=0,c2=1c_1 = 0, c_2 = 1 (the simplest common choice), the probe sequence from a starting bucket hh visits:

::: center ii Offset i2i^2 Bucket


0 0 hh 1 1 h+1h+1 2 4 h+4h+4 3 9 h+9h+9 4 16 h+16h+16 :::

The gaps grow: 1, 3, 5, 7, 9, …The probe sequence jumps past nearby occupied slots quickly, so even if there’s a cluster starting near hh, it gets skipped over rather than extended.

Everything else (tombstones, search, remove) carries over from linear probing. The only change is the probe-step formula.

Primary clustering: solved. Secondary clustering: new problem

Quadratic probing eliminates primary clustering (adjacent keys no longer share probe paths). But two keys that hash to the same starting bucket will still follow the exact same probe sequence --- that’s secondary clustering. Less severe than primary, but not gone.

::: center Strategy Primary clustering Secondary clustering


Linear probing Yes Yes Quadratic probing No Yes Double hashing (5.5) No No :::

Choosing constants

c1=0,c2=1c_1 = 0, c_2 = 1 gives you h+i2h + i^2, which has the guarantee above. c1=1,c2=1c_1 = 1, c_2 = 1 gives h+i+i2h + i + i^2, which is the form used in the textbook pseudocode; it still works but the theoretical guarantees depend on specific NN values. When in doubt, prefer the simpler h+i2h + i^2 version with prime table sizes.

Cost

::: center Load factor α\alpha Avg probes (successful) Notes


0.5 1.4\approx 1.4 Slightly better than linear at 0.5 0.75 2.0\approx 2.0 Noticeably better 0.9 3.2\approx 3.2 Much better than linear’s 5.5 :::

At high load factors, quadratic probing substantially outperforms linear probing. At low load factors, the difference is minor and linear’s cache-friendliness can win on wall-clock time.

5.5 Double Hashing

Linear and quadratic probing both suffer secondary clustering: any two keys that hash to the same starting bucket follow the same probe sequence. Double hashing fixes this by making the probe step size also depend on the key --- so two keys starting at the same bucket diverge immediately.

The idea, geometrically

Think of each key as having its own “rhythm” for walking through the table. Linear probing: everyone steps by 1. Quadratic: everyone’s step pattern is 1,4,9,1, 4, 9, \ldots. Double hashing: each key’s step depends on the key itself.

Two keys can collide on their starting bucket and still separate instantly because their h2h_2 values differ. That’s the whole trick.

Example

Let m=11m = 11, h1(k)=kmod11h_1(k) = k \bmod 11, h2(k)=5(kmod5)h_2(k) = 5 - (k \bmod 5).

::: center Key kk h1(k)h_1(k) h2(k)h_2(k) Probe sequence


55 0 5 0, 5, 10, 4, 9, 3, … 66 0 4 0, 4, 8, 1, 5, … :::

Both keys start at bucket 0, but after the first collision they walk in entirely different directions. That’s why double hashing approximates “uniform hashing” --- the theoretical ideal where every key’s probe sequence is a random permutation of buckets.

Implementation

Search and remove use the same probe formula with the usual tombstone rules. The only new concern is choosing h2h_2 correctly.

The h_2 requirement: never zero, always coprime to m

If h2(k)=0h_2(k) = 0, the probe sequence stays stuck at h1(k)h_1(k) forever --- infinite loop. Always enforce h2(k)0h_2(k) \neq 0.

If h2(k)h_2(k) shares a factor with mm (table size), the probe sequence visits only a subset of buckets --- you can fail to insert even with empty buckets available. The standard fix: make mm a prime and design h2h_2 to always return a value in [1,m1][1, m-1]. A common construction:

h2(k)=p(kmodp), where p<m is primeh_2(k) = p - (k \bmod p), \text{ where } p < m \text{ is prime}

This guarantees 1h2(k)p<m1 \leq h_2(k) \leq p < m, and since mm is prime, gcd(h2(k),m)=1\gcd(h_2(k), m) = 1 always.

Cost

::: center Load factor α\alpha Avg probes (successful) Notes


0.5 1.4\approx 1.4 Matches quadratic 0.75 1.8\approx 1.8 Better than quadratic 0.9 2.6\approx 2.6 Significantly better 0.95 3.2\approx 3.2 Holds up where others collapse :::

The cost formula is 1αln11α\frac{1}{\alpha} \ln \frac{1}{1-\alpha} — essentially the theoretical best achievable by any open- addressing scheme. Double hashing’s probe distribution is statistically close to uniform random, which is what the analysis assumes.

::: center Strategy Primary clusters? Secondary clusters? Cache-friendly?


Chaining --- --- No Linear probing Yes Yes Yes Quadratic probing No Yes Somewhat Double hashing No No No :::

5.6 Hash Table Resizing

Every cost analysis so far has assumed the load factor stays bounded. But the user keeps inserting, and nn keeps growing. Without intervention, α=n/m\alpha = n/m climbs past 0.5, 0.75, 0.9, and eventually the table fills up. Resizing is how hash tables bound their load factor at runtime.

The algorithm

  1. Decide a new size mm'. Commonly m2mm' \approx 2m, rounded up to the next prime.

  2. Allocate a fresh array of size mm', all slots empty.

  3. For each non-empty bucket in the old table, re-compute h(key)modmh(key) \bmod m' (note: not modm\bmod m) and insert into the new table.

  4. Free the old array. Swap the pointer.

The old bucket indices are meaningless for the new table. Every item must be re-hashed from scratch. This is why the operation is O(n)O(n) rather than “just copy the array.”

When to resize

Three common triggers:

::: center Trigger When Typical threshold


Load factor αt\alpha \geq t 0.5—0.75 (open addr); 1.0+ (chaining) Chain length (chaining) longest chain k\geq k 4—8 Probe count (open addr) probes k\geq k N/3N/3 or similar :::

Load factor is the standard trigger because it’s cheap to check (just compare n_ / m to the threshold after each insert). The other triggers are more responsive to pathological cases --- a few really-bad buckets can justify resizing even if α\alpha looks okay.

Growth factor: why doubling?

Suppose you grow by adding a fixed amount (m=m+cm' = m + c) each time. Inserting nn items triggers n/cn/c resizes, each O(n)O(n) on average, so total work is O(n2/c)=O(n2)O(n^2 / c) = O(n^2). Back to amortized O(n)O(n) per insert on average --- a disaster.

With m=2mm' = 2m (doubling), you resize O(logn)O(\log n) times over the life of the table. Each resize ii rehashes roughly 2i2^i items. Total rehash work: 1+2+4++n2n=O(n)1 + 2 + 4 + \ldots + n \approx 2n = O(n). Amortized O(1)O(1) per insert.

Why prime sizes?

If table size mm has small factors, keys that share those factors will cluster. Example: m=10m = 10 and keys are even. Every even key hashes to an even bucket; half the table is wasted.

Prime sizes avoid this: gcd(key,m)=1\gcd(\text{key}, m) = 1 for most keys, so the modulo spreads them uniformly regardless of key structure. Doubling to the next prime (m=11234797m = 11 \to 23 \to 47 \to 97) is a common implementation choice.

Shrink?

Most implementations never shrink automatically. Why:

  • Shrinking and regrowing in a tight loop of inserts and removes would thrash.

  • The memory “wasted” by a half-empty table is usually cheap compared to the cost of a resize.

  • If the user really wants to reclaim memory, they can call rehash(n) or move into a fresh table explicitly.

std::unordered_map has rehash(n) and reserve(n) but no auto-shrink. std::vector has shrink_to_fit() but it’s still a manual request.

5.7 Common Hash Functions

Every hash table so far has assumed a hash function that “maps keys to buckets uniformly.” This section finally makes that concrete: what does a good hash function look like, and what can go wrong?

Modulo: the simplest, surprisingly subtle

h(k)=kmodmh(k) = k \bmod m is the textbook default.

When does it fail? When the keys share a factor with mm. If keys are always even and mm is even, odd buckets are never used. If m=10m = 10 and keys are 10, 20, 30, 40, every one hashes to bucket 0. This is exactly why prime table sizes are recommended: gcd(key,m)\gcd(\text{key}, m) is almost always 1, so the modulo spreads the keys out regardless of structure.

Mid-square

Square the key, extract the middle bits. The idea: squaring mixes every bit of the key into the middle of the result, so the middle bits depend on the entire key.

Mid-square is a historical curiosity now --- multiplicative hashing (below) dominates for integer keys --- but it’s a useful illustration of the general technique: mix the bits, then take a slice.

Multiplicative hashing for strings

For string keys, you can’t just take a single character and modulo. You need a function that combines every character into a single hash.

The idea: iteratively fold each character into a running hash by multiplying and adding. The multiplier (33) and seed (5381) are chosen so that small changes in the input produce large changes in the hash --- an informal avalanche property.

Variants:

  • djb2 XOR: h = h * 33 ^ c --- marginally better distribution

  • sdbm: h = c + (h << 6) + (h << 16) - h --- good for text

  • FNV-1a: iterative XOR-then-multiply --- widely used

  • CityHash, FarmHash, xxHash: modern, SIMD-accelerated, extremely fast

What makes a hash function “good”?

::: center Property Meaning


Determinism Same input \Rightarrow same output, always Uniform distribution Each bucket receives roughly n/mn/m keys Avalanche Flipping one input bit flips about half the output bits Cheap A single arithmetic operation plus modulo, ideally Non-trivial Doesn’t fall into obvious traps (e.g., identity function) :::

“Cryptographic strength” is usually not required. SHA-256 is a good hash function for ensuring data integrity, but terrible for hash tables (thousands of cycles per call). Hash table hash functions need speed over security.

C++: std::hash

C++ provides std::hash<T> for the standard types: ints, pointers, strings, and enums. You can specialize it for your own types by defining std::hash<YourType> in the std namespace, or supply a custom hasher as a template parameter to the unordered container.

The magic number 0x9e3779b9 is 232ϕ\frac{2^{32}}{\phi} (ϕ\phi = golden ratio) --- a widely-used bit-mixing constant that gives good distribution across field combinations. Boost’s hash_combine uses this; many custom hashers copy the idiom.

Universal hashing: the principled defense against adversarial input

The hash randomization notebox above is a practical sketch; the underlying theory is universal hashing, due to Carter and Wegman (1979). It is the standard answer to “what if my hash function’s worst-case input shows up?” --- a question every fixed hash function leaves dangling.

The motivation is the gap between average-case and worst-case for any single hash function. Pick any fixed hh and an adversary can find nn keys that all hash to the same bucket, giving Θ(n)\Theta(n) chains. Universal hashing escapes this by randomizing the function itself at the start of execution: the adversary picks the keys, but the program picks the hash function independently and after the keys are chosen, so worst-case inputs no longer exist for any particular run.

5.8 Direct Hashing

Direct hashing is the degenerate case: use the key itself as the bucket index. No modulo, no mixing, no collisions. Whenever it’s feasible, it’s the fastest possible hash table. Unfortunately, it’s rarely feasible.

When it works

Direct hashing requires every potential key to have a reserved slot. If your keys are integers in [0,K)[0, K), you need a table of size KK.

Three lines per operation, all O(1)O(1) worst case --- not average. No hash function to compute, no collision handling, no resizing.

The two killer limitations

::: center Limit Consequence


Keys must be small non-negative integers Can’t directly handle strings, negative numbers, or large key spaces Table size = max key + 1 A key space of 2322^{32} needs a 4-billion-slot array (16 GB if each slot is 4 bytes, more if you store anything bigger) :::

If the key space is dense --- say, student IDs 0—9999 and you have 8000 active students --- direct hashing is perfect: a 10,000-slot array, 80% utilized, all operations O(1)O(1) with zero overhead.

If the key space is sparse --- say, US social security numbers (9 digits, 10910^9 possible values) but your table has a few thousand people --- direct hashing wastes 99.9999% of the array. You need a regular hash table.

Where direct hashing actually shows up

Don’t dismiss it as purely theoretical. Small-keyspace direct access tables are genuinely common:

  • ASCII character lookup tables (256 slots) --- tokenizer tables, character-class tests (isalnum, isspace)

  • Byte-to-hex conversion (256 slots holding two-character strings)

  • Color palettes in graphics (indexed color: the “hash” is just the palette index)

  • File descriptor tables in OS kernels (fd is already an array index)

  • CPU register mapping in compilers (register number \to allocation info)

  • Symbol tables in assemblers when symbol IDs are sequential

Every one of these uses direct indexing by design because the key space is small and dense.

Comparison

::: center Strategy Worst case Collisions? Space


Direct hashing O(1)O(1) None O(K)O(K) (key range) Chaining O(n)O(n) Handled O(n+m)O(n + m) Open addressing O(n)O(n) Handled O(m)O(m) :::

Direct hashing trades space for time: you pay for a slot for every possible key, but you get the best possible access time. Regular hash tables reverse the deal: save memory, accept occasional collision handling.

5.9 Hashing for Cryptography

Hashing has a second life outside data structures: as a cryptographic primitive. Cryptographic hash functions share the “deterministic function from input to fixed-size output” shape with hash-table functions, but their design goals --- and their cost --- are radically different.

What “infeasible” means

A good cryptographic hash with an nn-bit output requires approximately 2n2^n work to find a pre-image and 2n/22^{n/2} work to find a collision (the birthday bound). For n=256n = 256, that is 1077\approx 10^{77} operations --- more than the age of the universe on all the computers ever built.

Compare this to a hash-table hash function like djb2. djb2 is fast (a few nanoseconds per byte) but trivially easy to reverse or collide on demand. It’s not secure; it was never designed to be.

::: center Goal Hash-table hash Cryptographic hash


Speed \leq 1 ns/byte 1—10 ns/byte (SHA-256) Output size 32—64 bits 128—512 bits Reversibility Often easy Infeasible Collisions Acceptable (handled) Infeasible to produce Use cases Lookups Integrity, signatures, passwords :::

Data integrity

Download a file plus its hash. Recompute the hash locally. If they match, the file wasn’t corrupted or tampered with.

Historical timeline of algorithms:

  • MD5 (1991): 128-bit output. Collision attacks demonstrated in 2004; broken, do not use for security.

  • SHA-1 (1995): 160-bit output. Collision demonstrated in 2017 (SHAttered); deprecated.

  • SHA-256 / SHA-3: current standards. 256—512 bit outputs, no known attacks faster than brute force.

Password hashing

When a user signs up, don’t store their password. Store hash(password). At login, hash the submitted password and compare. If the database is stolen, attackers have hashes, not passwords --- and with a good hash, inverting them is infeasible.

Two attacks on naive password hashing:

  1. Rainbow tables: attackers precompute hashes for billions of common passwords. If your hash is in the table, password recovered instantly.

  2. Bulk cracking: modern GPUs compute billions of SHA-256 hashes per second. For short or weak passwords, brute force is tractable.

The two standard defenses:

::: center Defense What it does


Salt Append a random per-user value before hashing. Forces the attacker to recompute the rainbow table for every user --- no more precomputation. Slow hash Use a deliberately expensive function (bcrypt, scrypt, Argon2) that takes 100ms—1s per hash. Kills bulk cracking: a million-password attack now takes 12 CPU-days instead of a second. :::

bcrypt, scrypt, and Argon2 are the current standards. Argon2 won the Password Hashing Competition in 2015 and is the recommended default for new systems.

Where else does cryptographic hashing show up?

  • Digital signatures: sign the hash of the document, not the document itself. Cheaper, and works for any size.

  • Blockchain / content addressing: each block references the hash of the previous block. Git does the same with commits.

  • Merkle trees: hash-based structures that let you verify membership in a large set without downloading the whole set.

  • HMAC: keyed hash functions used to authenticate messages.

  • Commitment schemes: hash now, reveal later --- used in auctions, coin-flipping protocols, etc.

What this connects to

Companion materials. Compact notes: chapters/ch_5/notes.tex. Practice prompts (problem \to pseudocode \to C++): chapters/ch_5/practice.md.

interactive mode active