Emmanuel Akinfulubi

Lesson 04 of 2417 min read

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

Searching a list versus looking it up

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.

A hash map built by hand, so the mechanism is not magic

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.

Plain object versus Map

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


      

Watch the inherited key cause a bug

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

Counting, grouping, indexing, remembering

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.

Two Sum: the canonical example

// 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.

Composite keys

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

Union, intersection, difference

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.

  1. firstUnique(s) — the first character in a string that appears exactly once, or null. Needs two passes: count, then scan.
  2. 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.
  3. longestConsecutive(nums) — the length of the longest run of consecutive integers, in any order. [100,4,200,1,3,2] gives 4 for the run 1,2,3,4. Aim for O(n): put everything in a Set, then only start counting from numbers that have no left-hand neighbour.

Editable. Fill them in, then run.

Output


      

Recap

  • A hash map turns "search the whole collection" into "go straight to it": O(1) average, O(n) worst case.
  • Prefer Map and Set over 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²) to O(n).
  • Composite keys must be strings or packed numbers, because Map compares objects by identity.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.