Hash table mechanics
What keeps a hash table fast: the load factor, rehashing, capacity growth, why lookups are expected O(1), the worst case, and the memory a table really uses.
Learning goals
- Compute a load factor and decide when a table must grow
- Trace a rehash and say why it is amortised O(1)
- Distinguish expected, worst-case and amortised costs
- Estimate the memory overhead of a Java HashMap
Prerequisites
Load factor
The **load factor** is n / m: entries divided by buckets. It is the one number that predicts speed. For separate chaining it is the average chain length; for open addressing it is the fraction of slots in use and must stay below 1.
Java's HashMap resizes when size > 0.75 × capacity. A lower limit wastes memory but keeps chains short; a higher limit saves memory but costs more probes. Under the standard uniform-hashing assumption the expected cost of a lookup grows with the load factor α as shown here.
| Load factor α | Chaining: unsuccessful search | Chaining: successful search | Linear probing: unsuccessful | Linear probing: successful |
|---|---|---|---|---|
| 0.50 | 0.50 comparisons | 1.25 | 2.5 probes | 1.5 |
| 0.75 | 0.75 | 1.375 | 8.5 | 2.5 |
| 0.90 | 0.90 | 1.45 | 50.5 | 5.5 |
Rehashing
When the load factor passes its limit, the table allocates a bigger array (usually twice the size) and **rehashes**: every key is hashed again with the new size and stored in its new bucket. Rehashing is necessary because the bucket index depends on the table size, so an entry cannot simply be copied to the same index.
Run the “Growing from 2 buckets” preset. Starting with 2 buckets and a limit of 0.75, the table doubles to 4, 8, 16 as keys arrive, and the final message counts the keys moved. Notice how keys that collided in the small table often separate after the resize.
1private void resize() {2LinkedList<Integer>[] old = table;3table = newTable(old.length * 2); // double the number of buckets4for (LinkedList<Integer> chain : old)5for (int key : chain) table[Math.floorMod(key, table.length)].add(key); // every key is rehashed6}
Load factor and rehashing
Interactive · edit the inputtable · 2 buckets
2 empty buckets and a maximum load factor of 0.75: the table doubles as soon as size > 0.75 × buckets.
1class ChainedSet {2private LinkedList<Integer>[] table; private int size; private final double maxLoad;3void add(int key) {4int b = Math.floorMod(key, table.length);5if (table[b].contains(key)) return; // already present6table[b].add(key); size++;7if (size > maxLoad * table.length) resize(); // load factor too high8}9private void resize() {10LinkedList<Integer>[] old = table;11table = newTable(old.length * 2); // double the number of buckets12for (LinkedList<Integer> chain : old)13for (int key : chain) table[Math.floorMod(key, table.length)].add(key); // every key is rehashed14}15}
Live state
size
0
buckets
2
load factor
0.00
limit
0.75
Execution trace (dry run table)Show 31 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | size=0 buckets=2 load factor=0.00 limit=0.75 | 2 empty buckets and a maximum load factor of 0.75: the table doubles as soon as size > 0.75 × buckets. |
| 2 | L4 | size=0 buckets=2 load factor=0.00 limit=0.75 key=5 bucket=1 | add(5): 5 % 2 = 1. |
| 3 | L7 | size=1 buckets=2 load factor=0.50 limit=0.75 key=5 | Added 5: 1 ≤ 1.5, no resize yet. |
| 4 | L4 | size=1 buckets=2 load factor=0.50 limit=0.75 key=12 bucket=0 | add(12): 12 % 2 = 0. |
| 5 | L7 | size=2 buckets=2 load factor=1.00 limit=0.75 key=12 | Added 12: 2 keys exceed the limit 1.5, so the table must grow. |
| 6 | L11 | size=2 buckets=2 load factor=1.00 limit=0.75 new buckets=4 | Allocate a new array of 4 buckets. Bucket indices depend on the table size, so every key must be hashed again. |
| 7 | L13 | size=2 buckets=2 load factor=1.00 limit=0.75 moving from=0 new buckets=4 | Rehash bucket 0 (12): each key goes to key % 4. Keys that collided before often separate now. |
| 8 | L13 | size=2 buckets=2 load factor=1.00 limit=0.75 moving from=1 new buckets=4 | Rehash bucket 1 (5): each key goes to key % 4. Keys that collided before often separate now. |
| 9 | L14 | size=2 buckets=4 load factor=0.50 limit=0.75 | Resize done: 4 buckets, load factor 0.50. Cost: O(n) now, but it happens after the table has doubled, so adding is O(1) amortised. |
| 10 | L4 | size=2 buckets=4 load factor=0.50 limit=0.75 key=9 bucket=1 | add(9): 9 % 4 = 1. |
| 11 | L7 | size=3 buckets=4 load factor=0.75 limit=0.75 key=9 | Added 9: 3 ≤ 3, no resize yet. |
| 12 | L4 | size=3 buckets=4 load factor=0.75 limit=0.75 key=20 bucket=0 | add(20): 20 % 4 = 0. |
| 13 | L7 | size=4 buckets=4 load factor=1.00 limit=0.75 key=20 | Added 20: 4 keys exceed the limit 3, so the table must grow. |
| 14 | L11 | size=4 buckets=4 load factor=1.00 limit=0.75 new buckets=8 | Allocate a new array of 8 buckets. Bucket indices depend on the table size, so every key must be hashed again. |
| 15 | L13 | size=4 buckets=4 load factor=1.00 limit=0.75 moving from=0 new buckets=8 | Rehash bucket 0 (12, 20): each key goes to key % 8. Keys that collided before often separate now. |
| 16 | L13 | size=4 buckets=4 load factor=1.00 limit=0.75 moving from=1 new buckets=8 | Rehash bucket 1 (5, 9): each key goes to key % 8. Keys that collided before often separate now. |
| 17 | L14 | size=4 buckets=8 load factor=0.50 limit=0.75 | Resize done: 8 buckets, load factor 0.50. Cost: O(n) now, but it happens after the table has doubled, so adding is O(1) amortised. |
| 18 | L4 | size=4 buckets=8 load factor=0.50 limit=0.75 key=33 bucket=1 | add(33): 33 % 8 = 1. |
| 19 | L7 | size=5 buckets=8 load factor=0.63 limit=0.75 key=33 | Added 33: 5 ≤ 6, no resize yet. |
| 20 | L4 | size=5 buckets=8 load factor=0.63 limit=0.75 key=7 bucket=7 | add(7): 7 % 8 = 7. |
| 21 | L7 | size=6 buckets=8 load factor=0.75 limit=0.75 key=7 | Added 7: 6 ≤ 6, no resize yet. |
| 22 | L4 | size=6 buckets=8 load factor=0.75 limit=0.75 key=18 bucket=2 | add(18): 18 % 8 = 2. |
| 23 | L7 | size=7 buckets=8 load factor=0.88 limit=0.75 key=18 | Added 18: 7 keys exceed the limit 6, so the table must grow. |
| 24 | L11 | size=7 buckets=8 load factor=0.88 limit=0.75 new buckets=16 | Allocate a new array of 16 buckets. Bucket indices depend on the table size, so every key must be hashed again. |
| 25 | L13 | size=7 buckets=8 load factor=0.88 limit=0.75 moving from=1 new buckets=16 | Rehash bucket 1 (9, 33): each key goes to key % 16. Keys that collided before often separate now. |
| 26 | L13 | size=7 buckets=8 load factor=0.88 limit=0.75 moving from=2 new buckets=16 | Rehash bucket 2 (18): each key goes to key % 16. Keys that collided before often separate now. |
| 27 | L13 | size=7 buckets=8 load factor=0.88 limit=0.75 moving from=4 new buckets=16 | Rehash bucket 4 (12, 20): each key goes to key % 16. Keys that collided before often separate now. |
| 28 | L13 | size=7 buckets=8 load factor=0.88 limit=0.75 moving from=5 new buckets=16 | Rehash bucket 5 (5): each key goes to key % 16. Keys that collided before often separate now. |
| 29 | L13 | size=7 buckets=8 load factor=0.88 limit=0.75 moving from=7 new buckets=16 | Rehash bucket 7 (7): each key goes to key % 16. Keys that collided before often separate now. |
| 30 | L14 | size=7 buckets=16 load factor=0.44 limit=0.75 | Resize done: 16 buckets, load factor 0.44. Cost: O(n) now, but it happens after the table has doubled, so adding is O(1) amortised. |
| 31 | L15 | size=7 buckets=16 load factor=0.44 limit=0.75 return=[[],[33],[18],[],[20],[5],[],[7],[],[9],[],[],[12],[],[],[]] | 7 keys in 16 buckets after 3 resizes; 13 key moves spent on rehashing. |
Capacity growth
Capacity doubles on each resize. Doubling makes the growth geometric: after n insertions the total number of rehashed keys is at most about 2n, because each key is moved once per doubling and the sizes of earlier tables add up to less than the last one. That is why put is O(1) *amortised* even though a single put can cost O(n).
In Java the capacity of a HashMap is always a power of two (a request for 100 becomes 128) and a HashSet of 14 values added to a default table resizes once when the 13th value arrives (16 × 0.75 = 12). If you know the number of entries in advance, size the table to expected / 0.75 + 1 to avoid resizing at all. The animation shows exactly this for the 14-value preset.
1static <K, V> Map<K, V> presized(int expectedEntries) {2int capacity = (int) (expectedEntries / 0.75f) + 1; // large enough that no resize happens3return new HashMap<>(capacity);4}
Same values, different collections
Interactive · edit the inputArrayList
LinkedHashSet
HashSet (capacity 16)
TreeSet
PriorityQueue array
Five collections receive exactly the same values in the same order. Watch how each one stores them.
1List<Integer> list = new ArrayList<>();2Set<Integer> linked = new LinkedHashSet<>();3Set<Integer> hash = new HashSet<>();4Set<Integer> tree = new TreeSet<>();5PriorityQueue<Integer> heap = new PriorityQueue<>();6for (int x : arr) {7list.add(x); // keeps insertion order, keeps duplicates8linked.add(x); // keeps insertion order, drops duplicates9hash.add(x); // order decided by hash buckets10tree.add(x); // always sorted11heap.add(x); // heap array: only the smallest is guaranteed first12}13System.out.println(list + " " + linked + " " + hash + " " + tree + " " + heap);
Live state
values
8
Execution trace (dry run table)Show 10 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | values=8 | Five collections receive exactly the same values in the same order. Watch how each one stores them. |
| 2 | L7 | x=42 adds so far=1 distinct=1 HashSet capacity=16 | 42 goes to bucket 10 of 16 in the HashSet (index = (h ^ (h >>> 16)) & 15). |
| 3 | L7 | x=7 adds so far=2 distinct=2 HashSet capacity=16 | 7 goes to bucket 7 of 16 in the HashSet (index = (h ^ (h >>> 16)) & 15). |
| 4 | L7 | x=19 adds so far=3 distinct=3 HashSet capacity=16 | 19 goes to bucket 3 of 16 in the HashSet (index = (h ^ (h >>> 16)) & 15). |
| 5 | L7 | x=7 adds so far=4 distinct=3 HashSet capacity=16 | 7 is already present: the ArrayList and the PriorityQueue keep a second copy, the three sets ignore it. |
| 6 | L7 | x=3 adds so far=5 distinct=4 HashSet capacity=16 | 3 goes to bucket 3 of 16 in the HashSet (index = (h ^ (h >>> 16)) & 15). |
| 7 | L7 | x=25 adds so far=6 distinct=5 HashSet capacity=16 | 25 goes to bucket 9 of 16 in the HashSet (index = (h ^ (h >>> 16)) & 15). |
| 8 | L7 | x=16 adds so far=7 distinct=6 HashSet capacity=16 | 16 goes to bucket 0 of 16 in the HashSet (index = (h ^ (h >>> 16)) & 15). |
| 9 | L7 | x=33 adds so far=8 distinct=7 HashSet capacity=16 | 33 goes to bucket 1 of 16 in the HashSet (index = (h ^ (h >>> 16)) & 15). |
| 10 | L13 | distinct=7 capacity=16 | Same values, five different orders. Only the TreeSet is sorted; the HashSet order is an implementation detail you must never depend on, and the PriorityQueue promises only that index 0 is the minimum. |
Expected O(1)
A hash table promises **expected O(1)** lookups: if the hash function spreads keys uniformly and the load factor is bounded by a constant, a lookup scans a constant number of entries on average. Expected is a statement about the average over random keys or a random hash function, not a guarantee for one operation.
Three costs are easy to confuse: *amortised* O(1) (put, averaged over a sequence that includes resizes), *expected* O(1) (lookup, averaged over the randomness of the hash), and *worst-case* O(n) (all keys in one bucket). Hash code computation itself costs time too: hashing a string of length L is O(L), so lookups of long strings are not truly constant.
| Operation | Expected | Worst case |
|---|---|---|
| put / add | O(1) amortised | O(n) |
| get / contains | O(1) | O(n), O(log n) with treeified bins |
| remove | O(1) | O(n) |
| iterate all | O(n + m) | O(n + m) |
Worst-case behavior
If all keys hash to one bucket, the table is a linked list: every operation scans up to n entries. Pick the “Worst case: all collide” preset: the constant hash function puts all keys in bucket 0 and the search for the last key compares with every one before it.
Java mitigates this. Since Java 8 a HashMap bucket with 8 or more entries (in a table of at least 64 buckets) is converted into a red-black tree ordered by hash code and, for keys that implement Comparable, by compareTo. Lookups in such a bucket take O(log n) instead of O(n). Smaller tables resize instead of treeifying.
Hash table with separate chaining
Interactive · edit the inputbucket 0
bucket 1
bucket 2
bucket 3
bucket 4
5 empty buckets. A hash function maps every key to a bucket index; keys that land in the same bucket share a chain.
1class ChainedHashSet {2private final int m, variant, a; // m buckets3private final LinkedList<Integer>[] table; // one chain per bucket4int hash(int key) { // the option picks one variant5switch (variant) {6case 0: return Math.floorMod(key, m);7case 1: return (int) (m * ((key * 0.6180339887) % 1.0));8case 2: return 0; // worst case: every key collides9default: return ((a * key + 7) % 101) % m; // universal-style family10}11}12boolean add(int key) {13LinkedList<Integer> chain = table[hash(key)];14if (chain.contains(key)) return false; // walk the chain: duplicate15chain.add(key); return true; // append to the chain16}17boolean contains(int key) { return table[hash(key)].contains(key); }18boolean remove(int key) { return table[hash(key)].remove((Integer) key); }19}
Live state
size
0
load factor
0.00
longest chain
0
Execution trace (dry run table)Show 30 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | size=0 load factor=0.00 longest chain=0 | 5 empty buckets. A hash function maps every key to a bucket index; keys that land in the same bucket share a chain. |
| 2 | L6 | size=0 load factor=0.00 longest chain=0 op=add(12) hash=2 | add(12): hash = 12 % 5 = 2, so go to bucket 2. |
| 3 | L15 | size=1 load factor=0.20 longest chain=1 op=add(12) return=true | 12 is not in the chain (the bucket was empty): append it to bucket 2. |
| 4 | L6 | size=1 load factor=0.20 longest chain=1 op=add(7) hash=2 | add(7): hash = 7 % 5 = 2, so go to bucket 2. |
| 5 | L14 | size=1 load factor=0.20 longest chain=1 op=add(7) hash=2 | 12 ≠ 7: keep walking the chain. |
| 6 | L15 | size=2 load factor=0.40 longest chain=2 op=add(7) return=true | 7 is not in the chain (1 comparison): append it to bucket 2. |
| 7 | L6 | size=2 load factor=0.40 longest chain=2 op=add(22) hash=2 | add(22): hash = 22 % 5 = 2, so go to bucket 2. |
| 8 | L14 | size=2 load factor=0.40 longest chain=2 op=add(22) hash=2 | 12 ≠ 22: keep walking the chain. |
| 9 | L14 | size=2 load factor=0.40 longest chain=2 op=add(22) hash=2 | 7 ≠ 22: keep walking the chain. |
| 10 | L15 | size=3 load factor=0.60 longest chain=3 op=add(22) return=true | 22 is not in the chain (2 comparisons): append it to bucket 2. |
| 11 | L6 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | add(17): hash = 17 % 5 = 2, so go to bucket 2. |
| 12 | L14 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | 12 ≠ 17: keep walking the chain. |
| 13 | L14 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | 7 ≠ 17: keep walking the chain. |
| 14 | L14 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | 22 ≠ 17: keep walking the chain. |
| 15 | L15 | size=4 load factor=0.80 longest chain=4 op=add(17) return=true | 17 is not in the chain (3 comparisons): append it to bucket 2. |
| 16 | L6 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | contains(22): hash = 22 % 5 = 2, so go to bucket 2. |
| 17 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | 12 ≠ 22: keep walking the chain. |
| 18 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | 7 ≠ 22: keep walking the chain. |
| 19 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | 22 equals 22: found after 3 comparisons. |
| 20 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) return=true | contains(22) is true. |
| 21 | L6 | size=4 load factor=0.80 longest chain=4 op=remove(7) hash=2 | remove(7): hash = 7 % 5 = 2, so go to bucket 2. |
| 22 | L18 | size=4 load factor=0.80 longest chain=4 op=remove(7) hash=2 | 12 ≠ 7: keep walking the chain. |
| 23 | L18 | size=4 load factor=0.80 longest chain=4 op=remove(7) hash=2 | 7 equals 7: found after 2 comparisons. |
| 24 | L18 | size=3 load factor=0.60 longest chain=3 op=remove(7) return=true | 7 unlinked from bucket 2. |
| 25 | L6 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | add(32): hash = 32 % 5 = 2, so go to bucket 2. |
| 26 | L14 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | 12 ≠ 32: keep walking the chain. |
| 27 | L14 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | 22 ≠ 32: keep walking the chain. |
| 28 | L14 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | 17 ≠ 32: keep walking the chain. |
| 29 | L15 | size=4 load factor=0.80 longest chain=4 op=add(32) return=true | 32 is not in the chain (3 comparisons): append it to bucket 2. |
| 30 | L19 | size=4 load factor=0.80 longest chain=4 return=[[],[],[12,22,17,32],[],[]] | 4 keys in 5 buckets; the longest chain has 4. Lookup cost is the chain length, so the spread of the hash function decides the speed. |
Memory overhead
A hash table spends memory on empty buckets and on bookkeeping. A Java HashMap stores each entry in a node object holding the cached hash, the key, the value and the next pointer, and boxes int keys and values as separate Integer objects. On a typical 64-bit JVM with compressed references that is roughly 32 bytes for the node, plus 16 bytes for each boxed Integer, plus 4 bytes per bucket in the array. A million Integer→Integer entries (keys beyond the small-value cache) therefore cost roughly 70 MB, compared with 4 MB for a plain int[].
This is why primitive-specialised or open-addressed maps (from libraries) can be several times smaller, and why a boolean[] or int[] count array beats a HashMap<Integer, Integer> when the keys form a small dense range. Measure with a tool such as JOL if the exact number matters.
| Component | Approximate size (64-bit, compressed references) |
|---|---|
| HashMap node (hash, key, value, next) | 32 bytes |
| Boxed Integer key or value | 16 bytes each |
| Table slot | 4 bytes |
| int in an int[] | 4 bytes |
Practice ladder
Now try it yourself
Easy
- Compute the load factor of 12 keys in 16 buckets
- Why does HashMap use a power-of-two capacity?
Medium
- Why is put amortised O(1) with doubling?
- Design HashMap with automatic resizing
Advanced
- Compare expected probes at load factors 0.5 and 0.9 for linear probing
Key takeaways
- Load factor n/m predicts lookup cost; Java resizes at 0.75.
- Rehashing doubles the table and re-hashes every key: O(n) once, O(1) amortised.
- Expected O(1) assumes a good hash; the worst case is O(n), softened by treeified bins.