Hash Maps and Sets
If you learn one structure well, learn this one. A hash map turns "search the whole list" into "look it up", and it is the answer to a startling share of interview problems.
By the end: Trade memory for speed using a Map or a Set.
If you take one structure from this course into an interview, take this one. A very large share of problems that look hard become easy the moment you ask: what if I could look that up instantly instead of searching for it?
The problem it solves
function findInArray(list, target) {
let steps = 0;
for (const x of list) {
steps++;
if (x === target) return { found: true, steps };
}
return { found: false, steps };
}
function findInSet(set, target) {
return { found: set.has(target), steps: 1 }; // one hash, one bucket check
}
const list = Array.from({ length: 10000 }, (_, i) => i);
const set = new Set(list);
console.log('array, worst case:', findInArray(list, 9999));
console.log('set, worst case:', findInSet(set, 9999));
Output
Ten thousand steps against one. The array has to look at everything because it has no idea where the value lives. The set knows, because it computed the location from the value itself.
How it works, in one paragraph
A hash function turns a key into a number. That number, reduced to the size of an internal array, is the slot where the value goes. To look a key up, hash it again and go straight to that slot. No searching, so O(1).
Two different keys can hash to the same slot, which is a collision. Implementations handle it by keeping a small list in each slot and growing the internal array when it gets crowded. If everything collided you would be back to scanning a list, which is why the honest answer is O(1) average and O(n) worst case.
class TinyMap {
#buckets;
#count = 0;
constructor(size = 8) {
this.#buckets = Array.from({ length: size }, () => []);
}
#hash(key) {
let h = 0;
const s = String(key);
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0; // a classic string hash
}
return Math.abs(h) % this.#buckets.length;
}
set(key, value) {
const bucket = this.#buckets[this.#hash(key)];
const pair = bucket.find(p => p[0] === key);
if (pair) pair[1] = value;
else { bucket.push([key, value]); this.#count++; }
return this;
}
get(key) {
const pair = this.#buckets[this.#hash(key)].find(p => p[0] === key);
return pair ? pair[1] : undefined;
}
has(key) { return this.get(key) !== undefined; }
get size() { return this.#count; }
/* Shows where things landed, including any collisions. */
inspect() {
return this.#buckets.map((b, i) => `${i}: ${b.map(p => p[0]).join(', ') || '-'}`);
}
}
const m = new TinyMap(8);
for (const word of ['apple', 'banana', 'cherry', 'date', 'elderberry']) m.set(word, word.length);
console.log('get banana:', m.get('banana'), '| get missing:', m.get('nope'), '| size:', m.size);
console.log('bucket layout:');
m.inspect().forEach(line => console.log(' ' + line));
Output
Map, Set, or a plain object?
JavaScript gives you three ways to do this. The differences matter.
const obj = {};
obj[1] = 'number one';
obj['1'] = 'string one'; // the same key: object keys are always strings
console.log('object keys:', Object.keys(obj), '→ only one entry:', obj);
const map = new Map();
map.set(1, 'number one');
map.set('1', 'string one'); // two distinct keys
console.log('map size:', map.size, '| get(1):', map.get(1), '| get("1"):', map.get('1'));
// A Map can key on anything, including objects and arrays by identity.
const keyObj = { id: 1 };
map.set(keyObj, 'keyed by an object');
console.log('object as key:', map.get(keyObj));
// A Map remembers insertion order and is directly iterable.
for (const [k, v] of map) console.log('entry:', typeof k, String(k).slice(0, 12), '→', v);
Output
const counts = {};
console.log('counts["toString"] is truthy:', Boolean(counts['toString']), '→', typeof counts['toString']);
const safe = Object.create(null);
console.log('with Object.create(null):', safe['toString']);
const asMap = new Map();
console.log('with a Map:', asMap.get('toString'));
Output
The four things you will do with a Map
const words = ['apple', 'banana', 'apple', 'cherry', 'banana', 'apple'];
// 1. Count occurrences. The single most common use.
const freq = new Map();
for (const w of words) freq.set(w, (freq.get(w) ?? 0) + 1);
console.log('counts:', [...freq]);
// 2. Group by a computed key.
const byFirstLetter = new Map();
for (const w of words) {
const k = w[0];
if (!byFirstLetter.has(k)) byFirstLetter.set(k, []);
byFirstLetter.get(k).push(w);
}
console.log('grouped:', [...byFirstLetter]);
// 3. Index for instant lookup: value → where it was.
const firstIndex = new Map();
words.forEach((w, i) => { if (!firstIndex.has(w)) firstIndex.set(w, i); });
console.log('first index of each:', [...firstIndex]);
// 4. Remember what you have seen, to detect a repeat in one pass.
const seen = new Set();
let firstRepeat = null;
for (const w of words) {
if (seen.has(w)) { firstRepeat = w; break; }
seen.add(w);
}
console.log('first repeated:', firstRepeat);
Output
The pattern that unlocks a hundred problems
Here is the move. You are looking for a pair of things that satisfy some relationship. The naive answer is a nested loop, O(n²). Instead, walk the list once and ask the map whether the partner you need has already gone past.
// Naive: check every pair. O(n^2).
function twoSumSlow(nums, target) {
for (let i = 0; i < nums.length; i++)
for (let j = i + 1; j < nums.length; j++)
if (nums[i] + nums[j] === target) return [i, j];
return [];
}
// One pass. For each number, the partner we need is target - number.
// If we have already seen that partner, we are done.
function twoSum(nums, target) {
const seen = new Map(); // value → its index
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (seen.has(need)) return [seen.get(need), i];
seen.set(nums[i], i);
}
return [];
}
console.log('slow:', twoSumSlow([2, 7, 11, 15], 9), '| fast:', twoSum([2, 7, 11, 15], 9));
console.log('slow:', twoSumSlow([3, 2, 4], 6), '| fast:', twoSum([3, 2, 4], 6));
console.log('duplicate values:', twoSum([3, 3], 6));
console.log('no answer:', twoSum([1, 2], 99));
Output
When the key has to be composite
Map keys compare objects by identity, so map.set([1,2], 'x') then map.get([1,2]) gives undefined — two different arrays. When you need to key on a combination of values, build a string.
const grid = new Map();
// Wrong: a fresh array is a different object every time.
grid.set([1, 2], 'first');
console.log('lookup with an equal array:', grid.get([1, 2]));
// Right: a string key built from the parts.
const key = (r, c) => `${r},${c}`;
const cells = new Map();
cells.set(key(1, 2), 'first');
cells.set(key(3, 4), 'second');
console.log('string key works:', cells.get(key(1, 2)));
// For visited-cell tracking in a grid, a Set of string keys is the standard tool.
const visited = new Set();
visited.add(key(0, 0));
console.log('visited (0,0)?', visited.has(key(0, 0)), '| (0,1)?', visited.has(key(0, 1)));
// Or pack two small numbers into one, which avoids string allocation.
const packed = (r, c) => r * 1000 + c;
const fast = new Set([packed(1, 2)]);
console.log('packed key works:', fast.has(packed(1, 2)));
Output
Set operations
const a = new Set([1, 2, 3, 4]);
const b = new Set([3, 4, 5, 6]);
console.log('union: ', [...new Set([...a, ...b])]);
console.log('intersection:', [...a].filter(x => b.has(x)));
console.log('difference: ', [...a].filter(x => !b.has(x)));
console.log('symmetric: ', [...[...a].filter(x => !b.has(x)), ...[...b].filter(x => !a.has(x))]);
// Deduplicating an array is a one-liner, and it is O(n).
console.log('dedupe:', [...new Set([3, 1, 3, 2, 1])]);
Output
Your turn: three counting problems
All three are one pass with a Map or Set. Write them, then run.
firstUnique(s)— the first character in a string that appears exactly once, ornull. Needs two passes: count, then scan.isAnagram(a, b)— do two strings use exactly the same letters? Count up for one, down for the other, and check nothing is left over.longestConsecutive(nums)— the length of the longest run of consecutive integers, in any order.[100,4,200,1,3,2]gives4for the run 1,2,3,4. Aim forO(n): put everything in a Set, then only start counting from numbers that have no left-hand neighbour.
Output
Recap
- A hash map turns "search the whole collection" into "go straight to it":
O(1)average,O(n)worst case. - Prefer
MapandSetover plain objects: any key type, real.size, ordered iteration, and no inherited-key surprises. - The four jobs are counting, grouping, indexing and remembering-what-you-have-seen.
- The big reframe: instead of comparing every pair, ask whether the partner you need has already been seen. That is
O(n²)toO(n). - Composite keys must be strings or packed numbers, because
Mapcompares objects by identity.