Skip to content
wannacode

Hash table

In short: A hash table (Map in JavaScript) stores key-value pairs in buckets: the hash of the key says which bucket, so lookup and insertion are O(1) on average.

Problem

The price cache slows down

The product page reads its price from a cache: an SKU → price table that the program keeps in memory. Someone wrote their own PriceTable, with a hash function that is "fast": only the first letter of the SKU.

const cache = new PriceTable((key) => key.charCodeAt(0));

With 20 products in the test it flies. With the real catalog the page crawls, and the profiler points at get. With 1000 products, each written and read once, the table makes 1,000,000 key comparisons: a thousand per product. A hash table should make one or two.

Every SKU starts with "S". What is happening inside?

The idea

Buckets, hash and the bucket number

In the lesson "Hash codes and hash functions" you saw the table from the outside. Inside it is three things:

  • an array of buckets;
  • a hash function that turns the key into an integer;
  • the bucket number = hash % number of buckets. So every key has exactly one place where you look for it.

Two keys with the same bucket number are a collision. Here collisions live in a chain: every bucket is the start of a linked list of key → value pairs.

function hashOf(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = (31 * hash + text.charCodeAt(i)) | 0;
}
return hash;
}
const keys = ["Boris", "Yana", "Ivan", "Emil"];
const buckets = 8;
for (const key of keys) {
const hash = hashOf(key);
const index = ((hash % buckets) + buckets) % buckets;
console.log(`${key} -> ${hash} -> bucket ${index}`);
}
Step by step, in words
  1. Four keys and eight empty buckets, numbered 0 to 7.
  2. hashOf("Boris") is 64372111. The remainder when divided by 8 is 7: that is the bucket.
  3. "Yana" gives 2748123, bucket 3.
  4. "Ivan" gives 2291258, bucket 2.
  5. "Emil" is also in bucket 3: a collision. The two keys are in one bucket: "Emil" hangs after "Yana" like a chain.
  6. Looking up "Emil": the same number, the same bucket 3. === with "Yana": no. With "Emil": yes.

get computes the bucket and walks only its chain. If the chains are short, that is O(1), however many keys there are.

See it

Your own table

The hash function for an SKU is the same as Java's hashCode of a string: 31 · h + char for every character. At the end the sign is dropped, so the number is not negative:

function skuHash(key) {
let h = 0;
for (let i = 0; i < key.length; i++) {
h = (Math.imul(31, h) + key.charCodeAt(i)) | 0;
}
return h & 0x7fffffff;
}
Math.imul multiplies as 32-bit integers, and | 0 brings the result back to 32 bits. That way the hash is the same as in the other languages.

A pair is a node with a key, a price and an arrow to the next one in the chain:

class Entry {
constructor(key, price, next) {
this.key = key;
this.price = price;
this.next = next;
}
}

The table gets its hash function from outside, so you can compare two of them. compares counts the key comparisons:

class PriceTable {
constructor(hash) {
this.hash = hash;
this.buckets = new Array(4).fill(null);
this.size = 0;
this.compares = 0;
}
index(key) {
return this.hash(key) % this.buckets.length;
}
put(key, price) {
const i = this.index(key);
for (let e = this.buckets[i]; e !== null; e = e.next) {
this.compares++;
if (e.key === key) {
e.price = price;
return;
}
}
this.buckets[i] = new Entry(key, price, this.buckets[i]);
this.size++;
const limit = Math.floor((this.buckets.length * 3) / 4);
if (this.size > limit) this.resize();
}
resize() {
const fresh = new Array(this.buckets.length * 2).fill(null);
for (const head of this.buckets) {
for (let e = head; e !== null; e = e.next) {
const i = this.hash(e.key) % fresh.length;
fresh[i] = new Entry(e.key, e.price, fresh[i]);
}
}
this.buckets = fresh;
}
get(key) {
const i = this.index(key);
for (let e = this.buckets[i]; e !== null; e = e.next) {
this.compares++;
if (e.key === key) return e.price;
}
return null;
}
}
  • put walks the chain of the bucket. If the key is there, it changes the price. If not, it puts a new pair at the front of the chain: that needs no walk.
  • get walks the same chain and stops at the key.

Try it

Fill in

The bucket number:

return this.hash(key) % ;

The idea

Load factor and growing

The more pairs, the longer the chains. The ratio pairs / buckets is called the load factor. Here, once it goes above 3/4, the table makes twice as many buckets.

The buckets are not copied one to one. The bucket number is hash % number of buckets, and that number has changed. So resize walks every pair and computes its bucket again: this is rehashing. Doubling from 4 to 8, a key from bucket b goes to b or to b + 4, and the chains split.

index(key) {
return this.hash(key) % this.buckets.length;
}
put(key, price) {
const i = this.index(key);
for (let e = this.buckets[i]; e !== null; e = e.next) {
this.compares++;
if (e.key === key) {
e.price = price;
return;
}
}
this.buckets[i] = new Entry(key, price, this.buckets[i]);
this.size++;
const limit = Math.floor((this.buckets.length * 3) / 4);
if (this.size > limit) this.resize();
}
resize() {
const fresh = new Array(this.buckets.length * 2).fill(null);
for (const head of this.buckets) {
for (let e = head; e !== null; e = e.next) {
const i = this.hash(e.key) % fresh.length;
fresh[i] = new Entry(e.key, e.price, fresh[i]);
}
}
this.buckets = fresh;
}
Step by step, in words
  1. A price cache with 4 buckets. The bucket of a key is its hash % 4.
  2. SKU-1003: 1091696434 % 4 = 2. SKU-1001: % 4 = 0. Empty buckets: no comparisons.
  3. SKU-1007 also lands in bucket 2: a collision. put compares it with SKU-1003 (not the same key) and puts it at the front of the chain.
  4. SKU-1005 joins SKU-1001 in bucket 0. Now there are 4 pairs, more than 3/4 of 4 buckets: the chains are getting long.
  5. resize makes 8 buckets. Every key is counted again with % 8: it stays at its number or moves 4 up.
  6. The new table: every chain is one pair long, so a get makes one comparison. Resizing costs n, but it happens rarely.
const cache = new PriceTable(skuHash);
cache.put("SKU-1003", 24.9);
cache.put("SKU-1001", 12.5);
cache.put("SKU-1007", 8.99);
cache.printBuckets();
console.log("---");
cache.put("SKU-1005", 45.5);
cache.printBuckets();
Output
0: SKU-1001
1:
2: SKU-1007 SKU-1003
3:
---
0: SKU-1001
1:
2: SKU-1003
3:
4: SKU-1005
5:
6: SKU-1007
7:

Growing costs as much as the number of pairs. But it happens rarely: at 4, 8, 16… pairs, like the growing array in amortized complexity. On average put stays O(1).

Try it

How many buckets?

The table starts with 4 buckets. Seven products go in one after another:

const cache = new PriceTable(skuHash);
const caps = [];
for (let n = 2001; n <= 2007; n++) {
cache.put(`SKU-${n}`, 10);
caps.push(cache.buckets.length);
}
console.log(caps.join(" "));
Answer

The idea

A bad hash: the table becomes a list

Back to the cache. The "first letter" hash gives 83 (the code of "S") for every SKU. 83 % 4, 83 % 8, 83 % 1024: with any number of buckets the number is the same. All pairs are in one chain, and the table is a linked list where every put and every get walks through all of them.

Growing does not help: it splits chains only when the keys have different hashes. A hash function must depend on the whole key and scatter close keys (SKU-10000, SKU-10001) far from each other.

function compares(cache, n) {
for (let i = 0; i < n; i++) cache.put(`SKU-${10000 + i}`, 1 + i);
for (let i = 0; i < n; i++) cache.get(`SKU-${10000 + i}`);
return cache.compares;
}
console.log(`first letter: ${compares(new PriceTable((key) => key.charCodeAt(0)), 1000)}`);
console.log(`whole SKU: ${compares(new PriceTable(skuHash), 1000)}`);
Output
first letter: 1000000
whole SKU: 2593

Run both tables in the browser:

  • Hash of the whole SKU O(n)
  • Hash of the first letter O(n²)
Algorithmn = 10n = 1,000n = 100,000
Hash of the whole SKU102,593211,990
Hash of the first letter1001,000,000≈ 10,000,000,000
The code runs in your browser, in JavaScript. The number of operations is the same in every language, because the algorithm is the same. The time depends on the machine, so it is only for comparison. “≈” is an estimate from the curve: we do not run an n that big, so the page does not freeze.

With 1000 products the lazy hash makes 1,000,000 comparisons, and the hash of the whole SKU makes 2593: under three per product. The lazy one is O(n) per operation and O(n²) in total: n products, each written and read once, make exactly n² comparisons.

More precisely, if you are curious

Open addressing (linear probing). Instead of chains, every pair lives in the array itself. If slot hash % n is taken, you try the next one, then the one after, until you find a free slot or the key. get follows the same path and stops at an empty slot. The upside is that everything is in one array and the processor reads it in order. The downsides: taken slots gather into clusters and every cluster grows faster and faster, so the table must stay emptier (it grows already at about half or 2/3). Removing cannot just clear the slot, because that would cut the path to the keys after it: it leaves a "deleted" mark.

What the languages do:

  • Java HashMap: chains. The number of buckets is a power of 2 (16 at first), load factor 0.75. The high bits are mixed into the hash (h ^ (h >>> 16)), because with a power-of-2 mask only the low bits would count otherwise. A chain with more than 8 pairs becomes a red-black tree if there are at least 64 buckets (otherwise the table grows first). So even with many collisions a lookup is O(log n), not O(n).
  • C# Dictionary<TKey, TValue>: also chains, but in arrays: _buckets holds the number of the first pair, and the pairs in _entries have a next index. The number of buckets is a prime (3, 7, 17, 37, 89…), and it grows when the array of pairs is full. There is no tree like in Java. Against deliberate collisions it relies on string hashes that differ on every run.
  • Python dict: open addressing, but not linear probing: the next slot is computed by a formula that also uses the high bits of the hash. The table is a power of 2 and grows once it is more than 2/3 full. The pairs live in a separate dense array in insertion order, and the table holds only indexes into it. That is why dict keeps insertion order (guaranteed since Python 3.7).
  • JavaScript Map: the specification requires iteration in insertion order and lookups that are on average better than linear, but does not say how. V8 uses an ordered hash table: buckets with chains, and the pairs live in an array in insertion order. JavaScript gives no hash of your own for objects: an object key is compared by reference.

Try it

Find the bug

Someone rewrote resize. The four products go in, but two of them are later "missing":

resize() {
const fresh = new Array(this.buckets.length * 2).fill(null);
for (const head of this.buckets) {
for (let e = head; e !== null; e = e.next) {
const i = this.hash(e.key) % this.buckets.length;
fresh[i] = new Entry(e.key, e.price, fresh[i]);
}
}
this.buckets = fresh;
}
const cache = new PriceTable(skuHash);
cache.put("SKU-1003", 24.9);
cache.put("SKU-1001", 12.5);
cache.put("SKU-1007", 8.99);
cache.put("SKU-1005", 45.5);
const p1003 = cache.get("SKU-1003");
console.log(`SKU-1003: ${p1003 ?? "none"}`);
const p1001 = cache.get("SKU-1001");
console.log(`SKU-1001: ${p1001 ?? "none"}`);
const p1007 = cache.get("SKU-1007");
console.log(`SKU-1007: ${p1007 ?? "none"}`);
const p1005 = cache.get("SKU-1005");
console.log(`SKU-1005: ${p1005 ?? "none"}`);
Output
SKU-1003: 24.9
SKU-1001: 12.5
SKU-1007: none
SKU-1005: none

Which line has the bug?

Pick the line with the bug.

Exercise

A product stops being sold

Solve this one in your own editor.

Removing from the cache. Add a method remove(key) to PriceTable that removes the pair and returns whether there was one. Be careful: the pair may be first in the chain (then the bucket points to the next one) or in the middle (then the previous pair points to the next one). Put SKU-1001, SKU-1005, SKU-1009 and SKU-1012 with prices €10.5, €11.5, €12.5 and €13.5; after growing to 8 buckets three of them are in bucket 0, and SKU-1009 is in the middle of the chain. Remove SKU-1009 and read all four:

Output
SKU-1001: 10.5
SKU-1005: 11.5
SKU-1009: none
SKU-1012: 13.5
products: 3

More tasks

  • Linear probing. Write a ProbeTable without chains: when a slot is taken, try the next one. Grow when the table is half full. How many comparisons does it make with 1000 SKUs?
  • Shrinking. After a sale the cache is left with 10 products and 4096 buckets. When would you shrink the table?
  • A better lazy hash. The hash "sum of the character codes" looks at the whole key. Try it with SKU-10000 to SKU-10999. Why are there still many collisions? (Hint: SKU-10019 and SKU-10091.)

Test

Check yourself

1. The table has 8 buckets. A key hashes to 1091696436. Which bucket is it in?
2. Why does growing the table compute every key again, instead of just copying the buckets?
3. The hash function returns the first letter of the SKU, and every SKU starts with "S". What does get cost with n products?
4. What is true about the Python dict?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·