How Map and Set work
Problem
The seat was sold twice
The theater box office keeps the sold seats in a dictionary: seat → name of the customer. A seat is a class with a row and a number:
class Seat { constructor(row, number) { this.row = row; this.number = number; }}const bookings = new Map();bookings.set(new Seat(5, 12), "Maria");
console.log(bookings.get(new Seat(5, 12)));console.log(bookings.size);bookings.set(new Seat(5, 12), "Ivan");console.log(bookings.size);undefined12Maria bought seat 5/12, but get with the same seat returns undefined. Then Ivan buys the same seat, and the dictionary now has two 5/12 keys. Why?
The idea
Hash: a number from the key
A Map does not compare the key with all the others. It computes one whole number from the key, and from it, it knows where to look. This number is called a hash (hash code). JavaScript does not show it: the engine computes it, and there is no function that returns it. To see how it works, here is a hash function for text. The formula is the same as in Java:
function hashOf(text) { let hash = 0; for (let i = 0; i < text.length; i++) { hash = (31 * hash + text.charCodeAt(i)) | 0; } return hash;}
console.log(hashOf("Maria"));console.log(hashOf("Maria"));console.log(hashOf("Marie"));console.log(hashOf("maria"));741137507411375074113754103666422- The same text gives the same number, every time.
- A small change gives a different number: "Maria", "Marie" and "maria" are three different hashes.
- A hash is fast to compute, no matter how many keys are in the dictionary.
charCodeAt(i)is the code of the character.| 0keeps the result a 32-bit whole number, likeintin Java. If it gets too big, it wraps around and can become negative.
A Map uses its own hash function, not this one. But the idea is the same.
The method that makes the number is called a hash function.
More precisely, if you are curious
- The formula is
s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1], wheres[i]is the code of each character. The loop computes it by multiplying by 31 at each step and adding the next code.
- Cryptographic hash functions like SHA-256 are something else. They give a long result, and it is practically impossible to find two texts with the same hash. That is why they are used to check that a file has not been tampered with and to store passwords.
hashOf()is for fast lookups and gives no such protection.
See it
The buckets
Inside, a Map is an array of buckets. The number of the bucket for a key is the remainder of the hash divided by the number of buckets. The hash here comes from hashOf, because the one a Map uses is not visible:
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
- Four keys and eight empty buckets, numbered 0 to 7.
- hashOf("Boris") is 64372111. The remainder when divided by 8 is 7: that is the bucket.
- "Yana" gives 2748123, bucket 3.
- "Ivan" gives 2291258, bucket 2.
- "Emil" is also in bucket 3: a collision. The two keys are in one bucket: "Emil" hangs after "Yana" like a chain.
- Looking up "Emil": the same number, the same bucket 3. === with "Yana": no. With "Emil": yes.
setcomputes the bucket and puts the pair there.getcomputes the same bucket and looks only in it. That is why it is fast even with a million keys.%in JavaScript, as in Java, can give a negative number:-7 % 8is -7. That is why it is((hash % buckets) + buckets) % buckets: this way the remainder is never negative. A bucket number cannot be negative.- "Yana" and "Emil" landed in bucket 3. This is called a collision. The next card is about it.
More precisely, if you are curious
- Exactly how a
Mapcomputes the bucket depends on the engine (V8 in Chrome and Node, a different one in Firefox). The language standard says only that a lookup must be faster on average than going through everything.
console.log(-7 % 8, ((-7 % 8) + 8) % 8);-7 1Try it
Which bucket?
In your own hash table, the hash of a whole number is usually the number itself. The order numbers go into a dictionary with 4 buckets.
What will the program print?
const orderIds = [6, 10, 13, 21];for (const id of orderIds) { console.log(`${id} -> bucket ${id % 4}`);}The remainders when divided by 4: 6 and 10 give 2, and 13 and 21 give 1. Four keys, two collisions, two empty buckets. With more buckets, the keys spread out more evenly.
The idea
Collision: the hash finds the bucket, comparing finds the key
Different keys can have the same hash. With hashOf, as with hashCode in Java, the best-known example is:
function hashOf(text) { let hash = 0; for (let i = 0; i < text.length; i++) { hash = (31 * hash + text.charCodeAt(i)) | 0; } return hash;}
console.log(hashOf("Aa"), hashOf("BB"));
const codes = new Map();codes.set("Aa", 1);codes.set("BB", 2);console.log(codes.get("Aa"), codes.get("BB"));2112 21121 2A Map keeps both keys and does not mix them up. It has its own hash function, and with it the collisions happen with other keys, but it is ready for them. A bucket holds several pairs, and get compares the key with each of them until it finds the one it wants.
So the two steps work like this:
- The hash says which bucket to look in.
- Comparing says which of the keys in the bucket is the one you want.
Now it is clear what happened with the theater seat. A Map compares keys almost like ===: text and numbers by value, but objects by reference. The two new Seat(5, 12) objects are two different objects, so they are also two different keys. A key that is the same object is found:
const seat = new Seat(5, 12);const bookings = new Map();bookings.set(seat, "Maria");console.log(bookings.get(seat));MariaThe idea
No equals or hashCode: a key made of text
In Java and Python, the class says when two objects are equal (equals, __eq__) and what their hash is (hashCode, __hash__). In JavaScript there is no way to do this. Map and Set compare objects by reference and do not call your methods. Even if you write equals, the Map does not look at it:
class Seat { constructor(row, number) { this.row = row; this.number = number; }
equals(other) { return other instanceof Seat && this.row === other.row && this.number === other.number; }}const bookings = new Map();bookings.set(new Seat(5, 12), "Maria");
console.log(bookings.get(new Seat(5, 12)));console.log(bookings.size);bookings.set(new Seat(5, 12), "Ivan");console.log(bookings.size);undefined12The usual solution is to make the key text, built from the fields that say which seat it is. Text is compared by value:
class Seat { constructor(row, number) { this.row = row; this.number = number; }
key() { return `${this.row}/${this.number}`; }}const bookings = new Map();bookings.set(new Seat(5, 12).key(), "Maria");
console.log(bookings.get(new Seat(5, 12).key()));console.log(bookings.size);bookings.set(new Seat(5, 12).key(), "Ivan");console.log(bookings.size);Maria11The rules for a text key are the same as the contract in Java:
- Two objects that are the same seat give the same key. Always.
- Two different objects give different keys. That is what the slash between the fields is for.
- Only the fields that make two objects the same go into the key.
More precisely, if you are curious
- Without a separator, keys get mixed up: seat 51/2 and seat 5/12 both give "512".
console.log(`${51}${2}`, `${5}${12}`, `${51}/${2}` === `${5}/${12}`);512 512 false- If you also need the object itself, put it in as the value:
bookings.set(seat.key(), { seat, name: "Maria" }).
Try it
Find the bug
Map with a text key. Two products with the same code are the same product, even if the price has changed. But has with the new price returns false:const catalog = new Map();const old = new Product("A-100", 25.0);catalog.set(old.key(), old);// The price went down, but it is the same product.console.log(catalog.has(new Product("A-100", 19.9).key()));falseWhich line in the class has the bug?
Pick the line with the bug.
return this.code;. Then has returns true.The idea
The buckets fill up
When there are many pairs, the buckets get long and lookups slow down. That is why a Map grows:
- Inside, a
Mapalso has to make more buckets when it fills up, and redistribute the keys to stay fast. - Exactly when and by how much depends on the engine, and the program cannot see it.
- That is why, on average,
setandgetare fast, but now and then onesettakes longer.
There is a trap too. A key must not change while it is in a dictionary or a set:
const seat = new Seat(5, 12);const sold = new Set();sold.add(seat.key());
seat.number = 13;console.log(sold.has(seat.key()));console.log(sold.has(new Seat(5, 12).key()));console.log(sold.size);falsetrue1seat.number = 13 the key of seat is "5/13" and is not found, while "5/12" is still in the set, for a seat that is no longer sold to anyone. A text key is a snapshot of the object at the moment of add. That is why it is a good idea for the fields the key is made from not to change.More precisely, if you are curious
- A key that is the object itself does not lose the object when it changes: the
Mapfinds it by reference. But thennew Seat(5, 12)is a different key. The choice is between "the same object" and "the same values".
Exercise
Your own keys
Solve the tasks in your editor.
1. A key for a person. A Person class with a first name, a last name and an age, and a key() method that makes a text key from the three fields. Create Ivan Petrov at 30 twice and once at 31. Print the keys and how many different people are left in a Map keyed by key().
Ivan/Petrov/30Ivan/Petrov/30Ivan/Petrov/31Different people: 22. The robot in the warehouse. The robot starts at cell (0, 0) and makes the moves NNEESWWN: N is up (y + 1), S is down, E is right (x + 1), W is left. How many different cells has it visited, counting the starting one? If you put objects of this class in a Set, the program says 9, but it should say 7. Fix it:
class Point { constructor(x, y) { this.x = x; this.y = y; }}Moves: 8Different cells: 73. A hash of a file. A hashFileContent(filePath) function reads the file and returns the hash of its content as hexadecimal text. Use SHA-256 from the built-in node:crypto module: createHash("sha256").update(content).digest("hex"). Write three reports: a.txt and b.txt with "October report: €1250", and c.txt with "October report: €1260". Compare their hashes. Different hashes mean different files, and equal SHA-256 hashes in practice mean the same files.
a.txt: 31c9db0c6cb9b9af7e6b355f9e6306b35281291f6a015049da01d1f6858bd293b.txt: 31c9db0c6cb9b9af7e6b355f9e6306b35281291f6a015049da01d1f6858bd293c.txt: cb9486eaca5007b8fef9f72a07411f78ed1fcd9a1fad3229c293ee3676024861a and b: samea and c: differentMore tasks
- Your own hash table. A follow-up to the user table from the last lesson, this time without
Map. Make an array of 8 buckets, each an array of username and email pairs. The hash function is the sum of the character codes in the name (charCodeAt). Writeadd,get, and a method that prints how many pairs are in each bucket. - An ID from an email.
generateUserId(email)returns"U"and the first 8 characters of the SHA-256 of the lowercase email. Is the ID the same forIvan@Example.comandivan@example.com? Can two different emails get the same ID? How would you find out? - Counting collisions. Compute the bucket of the keys
user1…user1000withhashOffrom the lesson for 16, 64 and 1024 buckets, and count how many keys land in a bucket that already has a key. - The shoe box. A
Boxclass with a width, a height and a depth. Two boxes are the same if their sizes are the same, in the same order. Writekey(), and check the boxes 30×20×10 and 10×20×30 in aSet.
Test