GUIDE · Hashing

Hash Maps

trade O(n) space for O(1) lookups; turn nested loops into one pass.

lesson11 sectionsruns in browser32 asserts191 lines

At a glance

WHAT
trade O(n) space for O(1) lookups; turn nested loops into one pass.
TELL
"count", "frequency", "seen before", "pairs that sum to", "group by", "consecutive", "subarray sum equals", "LRU", "O(n) required".
SAY
hash map / hash setfrequency mapcomplement lookupprefix-sum mapbucket sort by frequencychainingload factorrehashMap insertion order
BIG-O
O(1) average per op; O(n) worst case (all keys collide).

The idea#

Picture a coat check. You hand over a coat and get a ticket. When you come back, they go to that hook. They do not pat down every coat on the rack.

You trade O(n) space for O(1) average lookups, and that trade turns a nested loop into one pass. The worst case is O(n) per operation, when every key lands in the same bucket.

A hash map stores pairs of key and value. A hash set stores keys alone, for "have I seen this." The hash function turns the key into a bucket number. Two different keys can land in one bucket. That is a collision. Chaining keeps a short list in the bucket. Open addressing walks forward to the next empty slot, which is linear probing. The load factor is the number of keys divided by the number of buckets. When it climbs past about 0.75, you rehash: double the buckets and place every key again. That keeps the average operation O(1).

JavaScript's Map remembers insertion order. That is part of the tool, not a side effect you should ignore. The first key you inserted is the first key you meet when you iterate. A frequency map is the everyday idiom: read the old count, or zero if the key is new, add one, store it back.

Why it matters#

Any time you are about to write two loops, one scanning and one searching the same array, stop and ask whether a map or a set would remember the first scan. Two-sum is the poster. The slow version checks every pair, O(n²). One pass stores each number's index and asks whether target - num is already there, O(n) time and O(n) space.

The words that mean "reach for a map" are count, frequency, seen before, pairs that sum to, group by, consecutive, and subarray sum equals. "O(n) required" usually means the same thing: the quadratic answer is obvious, and the map is how you pay for the linear one.

You already depend on this. An object used as a dictionary, a set of user ids you have already processed, a tally of votes. The bug is assuming the average case is a promise. If your hash ignores the key and returns a constant, every operation walks one long chain and you are back to O(n). Say that out loud when someone asks. Then say how a load factor and a rehash keep the chains short.

Map beats a plain object when the keys are not strings, when you need the size, or when insertion order is the order you will evict. An LRU cache can be a Map alone: delete a key and set it again, and it moves to the most recently used end. The oldest key is the first one in iteration order.

A small example#

JS
function twoSum(nums, target) {  const seen = new Map();  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 [];} const pair = twoSum([2, 7, 11, 15], 9);

pair is [0, 1]. The map held 2 before 7 arrived, so the complement lookup finished the pair in one pass.

What the code below is doing#

Section 1 is the frequency idiom: map.set(k, (map.get(k) ?? 0) + 1). O(n) time, O(k) space for k distinct keys. Section 2 is two-sum, the complement lookup above. Section 3 groups anagrams. The key is a signature of 26 counts joined together, so you never sort the letters. O(n · k) for n words of length k. Section 4 finds the longest consecutive run. Put the numbers in a set. Only start counting at x when x - 1 is absent, then walk forward. Each number is visited a constant number of times, so the whole thing is O(n), even though a loop sits inside a loop.

Section 5 counts subarrays whose sum is k. Keep a map from prefix sum to how many times you have seen it, seeded with {0: 1}. At each index add the count stored at prefix - k. Negatives are fine, which is why a sliding window on this problem would answer a different question. O(n) time and space. Section 6 spots a duplicate within distance k by storing value to last index. Section 8 finds the first non-repeating character with a frequency pass and then an order pass. Map yields keys in insertion order, so the first key whose count is 1 is the answer.

Section 7 is an LRU on Map order. Delete and set on get, so the key moves to the newest end. Over capacity, evict map.keys().next().value. Section 9 intersects two arrays down to unique values with a set. Section 10 buckets values by count and walks from the high count, O(n), for the top k frequent.

Section 11 builds a hash map from scratch. hash(key) % buckets picks a list of [key, value] pairs. Put updates a pair that is already in the chain, or appends one. Get and remove scan that one chain. Past a load factor of about 0.75, rehash doubles the bucket array and inserts again. That is the sentence to say: chaining or open addressing for collisions, rehash to keep the average O(1).

When you talk about it, say this: a map spends O(n) space to answer "have I seen this" in O(1) on average, and a bad hash that piles every key into one bucket falls back to O(n).

Everything below is the demonstration. Same idea, written as code you can run.

JSalgorithms/hash-maps.js · setup
'use strict';const assert = require('node:assert/strict');
01

Frequency counting idiom#

map.set(k, (map.get(k) ?? 0) + 1)

O(n) / O(k)
JS
function freq(iter) {  const m = new Map();  for (const x of iter) m.set(x, (m.get(x) ?? 0) + 1);  return m;}
Verified by 2 asserts
JS
assert.deepEqual([...freq('aabc')], [['a', 2], ['b', 1], ['c', 1]]);assert.deepEqual([...freq([])], []);
02

Two-sum#

complement lookup; one pass

O(n) / O(n)
JS
function twoSum(nums, target) {  const seen = new Map();  for (let i = 0; i < nums.length; i++) {    if (seen.has(target - nums[i])) return [seen.get(target - nums[i]), i];    seen.set(nums[i], i);  }  return [];}
Verified by 3 asserts
JS
assert.deepEqual(twoSum([2, 7, 11, 15], 9), [0, 1]);assert.deepEqual(twoSum([-1, -2, -3], -5), [1, 2]);assert.deepEqual(twoSum([], 1), []);
03

Group anagrams#

key = count signature (26 ints joined) avoids sorting

O(nk) / O(nk)
JS
function groupAnagrams(strs) {  const g = new Map();  for (const w of strs) {    const cnt = new Array(26).fill(0);    for (const c of w) cnt[c.charCodeAt(0) - 97]++;    const key = cnt.join('#');    if (!g.has(key)) g.set(key, []);    g.get(key).push(w);  }  return [...g.values()];}
Verified by 2 asserts
JS
assert.deepEqual(groupAnagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat']), [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]);assert.deepEqual(groupAnagrams([]), []);
04

Longest consecutive sequence#

Set; only start counting from x when x-1 absent

O(n) / O(n)
JS
function longestConsecutive(nums) {  const set = new Set(nums);  let best = 0;  for (const x of set) {    if (set.has(x - 1)) continue; // not a sequence start    let len = 1;    while (set.has(x + len)) len++;    best = Math.max(best, len);  }  return best;}
Verified by 3 asserts
JS
assert.equal(longestConsecutive([100, 4, 200, 1, 3, 2]), 4);assert.equal(longestConsecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1]), 9);assert.equal(longestConsecutive([]), 0);
05

Subarray sum equals k (count)#

prefix-sum map: count[pre - k]; seed {0:1}

O(n) / O(n)
JS
function subarraySum(nums, k) {  const count = new Map([[0, 1]]);  let pre = 0, total = 0;  for (const x of nums) {    pre += x;    total += count.get(pre - k) ?? 0;    count.set(pre, (count.get(pre) ?? 0) + 1);  }  return total;}
Verified by 3 asserts
JS
assert.equal(subarraySum([1, 1, 1], 2), 2);assert.equal(subarraySum([1, 2, 3], 3), 2);assert.equal(subarraySum([1, -1, 0], 0), 3); // negatives OK (sliding window would NOT be)
06

Contains duplicate within distance k#

map value -> last index

O(n) / O(n)
JS
function containsNearbyDuplicate(nums, k) {  const last = new Map();  for (let i = 0; i < nums.length; i++) {    if (last.has(nums[i]) && i - last.get(nums[i]) <= k) return true;    last.set(nums[i], i);  }  return false;}
Verified by 3 asserts
JS
assert.equal(containsNearbyDuplicate([1, 2, 3, 1], 3), true);assert.equal(containsNearbyDuplicate([1, 2, 3, 1, 2, 3], 2), false);assert.equal(containsNearbyDuplicate([], 1), false);
07

LRU cache via Map insertion order#

get: delete + re-set moves to end; evict map.keys().next().value

O(1) / O(cap)
JS
class LRUCache {  #cap; #m = new Map();  constructor(capacity) { this.#cap = capacity; }  get(key) {    if (!this.#m.has(key)) return -1;    const v = this.#m.get(key);    this.#m.delete(key); this.#m.set(key, v); // refresh recency    return v;  }  put(key, value) {    if (this.#m.has(key)) this.#m.delete(key);    this.#m.set(key, value);    if (this.#m.size > this.#cap) this.#m.delete(this.#m.keys().next().value); // oldest = first key  }}const lru = new LRUCache(2);lru.put(1, 1); lru.put(2, 2);lru.put(3, 3); // evicts 2
Verified by 3 asserts
JS
assert.equal(lru.get(1), 1);assert.equal(lru.get(2), -1);assert.equal(lru.get(3), 3);
08

First non-repeating character#

freq pass + order pass (Map keeps insertion order)

O(n) / O(k)
JS
function firstNonRepeating(s) {  const f = freq(s);  for (const [c, n] of f) if (n === 1) return c;  return null;}
Verified by 3 asserts
JS
assert.equal(firstNonRepeating('swiss'), 'w');assert.equal(firstNonRepeating('aabb'), null);assert.equal(firstNonRepeating(''), null);
09

Intersection of two arrays (unique)#

Set of a, filter b, dedupe

O(n+m) / O(n)
JS
function intersection(a, b) {  const sa = new Set(a);  return [...new Set(b.filter((x) => sa.has(x)))];}
Verified by 3 asserts
JS
assert.deepEqual(intersection([1, 2, 2, 1], [2, 2]), [2]);assert.deepEqual(intersection([4, 9, 5], [9, 4, 9, 8, 4]), [9, 4]);assert.deepEqual(intersection([], [1]), []);
10

Top-k frequent elements (bucket sort variant)#

bucket[count] = [values]; walk from high count

O(n) / O(n)
JS
function topKFrequent(nums, k) {  const f = freq(nums);  const buckets = Array.from({ length: nums.length + 1 }, () => []);  for (const [v, c] of f) buckets[c].push(v);  const out = [];  for (let c = buckets.length - 1; c >= 0 && out.length < k; c--) out.push(...buckets[c]);  return out.slice(0, k);}
Verified by 3 asserts
JS
assert.deepEqual(topKFrequent([1, 1, 1, 2, 2, 3], 2), [1, 2]);assert.deepEqual(topKFrequent([1], 1), [1]);assert.deepEqual(topKFrequent([], 1), []);
11

Design a HashMap (buckets + chaining)#

hash(key) % buckets; each bucket is a list of [k,v]

JS
//        SAY: collision -> chaining (lists) or open addressing (linear probing);//             load factor = size / buckets; rehash (double buckets) when > ~0.75 keeps O(1) amortized.class MyHashMap {  #buckets; #size = 0;  constructor(n = 16) { this.#buckets = Array.from({ length: n }, () => []); }  #hash(key) {    let h = 0;    for (const c of String(key)) h = (h * 31 + c.charCodeAt(0)) | 0;    return Math.abs(h) % this.#buckets.length;  }  put(key, value) {    const b = this.#buckets[this.#hash(key)];    const e = b.find((p) => p[0] === key);    if (e) e[1] = value;    else { b.push([key, value]); this.#size++; }    if (this.#size / this.#buckets.length > 0.75) this.#rehash();  }  get(key) { return this.#buckets[this.#hash(key)].find((p) => p[0] === key)?.[1] ?? -1; }  remove(key) {    const b = this.#buckets[this.#hash(key)];    const i = b.findIndex((p) => p[0] === key);    if (i >= 0) { b.splice(i, 1); this.#size--; }  }  #rehash() {    const old = this.#buckets.flat();    this.#buckets = Array.from({ length: this.#buckets.length * 2 }, () => []);    this.#size = 0;    for (const [k, v] of old) this.put(k, v);  }  get size() { return this.#size; }}const hm = new MyHashMap(2); // tiny so rehash actually triggersfor (let i = 0; i < 20; i++) hm.put(i, i * i);hm.remove(7);
Verified by 4 asserts
JS
assert.equal(hm.get(7), 49);assert.equal(hm.get(99), -1);assert.equal(hm.get(7), -1);assert.equal(hm.size, 19);

See also