B5 · Part 1 · Beginner
Hashing
The single most common way an O(n²) brute force becomes O(n).
14On this page
- What a hash map actually does
- Map/Set vs plain objects
- The pattern: trade space for time
- Frequency counting — the other 80% of hashing questions
- Grouping — building a Map of arrays
- Set membership: asking "is the neighbour here?"
- Top-k frequent: count, then bucket by frequency
- Canonical keys: make things that are equal look equal
- Bijections: two maps for isomorphic strings and word patterns
- Composite keys: a Map compares objects by reference
- Complement lookups: find each value's partner
- How a hash map works inside: buckets, collisions and resizing
- Build a HashMap from scratch
- When hashing is the wrong tool
What a hash map actually does
A hash function turns a key into a number, and that number picks a
"bucket" in an underlying array. Look-up doesn't search — it computes
the bucket from the key and jumps straight there. That's why
Map/Set/object lookups are O(1) average case:
the cost of hashing the key doesn't grow with how many other keys are
already stored.
Map/Set vs plain objects
| Map / Set | Plain object | |
|---|---|---|
| Key types | anything (objects, NaN, etc.) | strings/symbols only — numbers get coerced |
| Size | .size, O(1) | Object.keys(o).length, O(n) |
| Iteration order | insertion order, guaranteed | mostly insertion order, but integer-like keys sort first |
| Accidental prototype keys | impossible | "toString" in {} is true |
In interviews, default to Map/Set unless
there's a specific reason not to — it sidesteps a whole category of
"wait, why is this key already there" bugs.
The pattern: trade space for time
Almost every "hashing" interview question is the same trade: spend O(n) space to remember what you've already seen, so a second O(n) pass (or even the same pass) can answer "have I seen this before?" in O(1) instead of O(n).
// Two Sum — brute force: O(n²) time, O(1) space
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];
}
}
}
// Two Sum — hashed: O(n) time, O(n) space
function twoSumFast(nums, target) {
const seen = new Map(); // value → 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);
}
}
Dry run: twoSumFast([2, 7, 11, 15], 9)
| i | nums[i] | need = target − nums[i] | seen.has(need)? | action |
|---|---|---|---|---|
| 0 | 2 | 7 | no — map is empty | store {2 → 0} |
| 1 | 7 | 2 | yes — seen.get(2) = 0 | return [0, 1] ✓ |
Notice the map is only ever looked up for the value we still need, and only ever written for values we've already passed. That single pass does the work a nested loop would need two passes (and O(n²) time) to do.
Say it like this → "I'll trade O(n) space for a
hash map so each lookup is O(1) instead of O(n) — that turns the O(n²)
nested-loop version into a single O(n) pass."
Frequency counting — the other 80% of hashing questions
function frequency(arr) {
const counts = new Map();
for (const x of arr) {
counts.set(x, (counts.get(x) || 0) + 1);
}
return counts;
}
// anagram check: same characters, same counts
function isAnagram(a, b) {
if (a.length !== b.length) return false;
const counts = new Map();
for (const c of a) counts.set(c, (counts.get(c) || 0) + 1);
for (const c of b) {
if (!counts.get(c)) return false;
counts.set(c, counts.get(c) - 1);
}
return true;
}
Grouping — building a Map of arrays
// group anagrams: same sorted letters → same bucket
function groupAnagrams(words) {
const groups = new Map();
for (const word of words) {
const key = [...word].sort().join("");
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(word);
}
return [...groups.values()];
}
⚠ Hash collisions aren't your problem, but worst case is
V8's hash maps handle collisions internally, so in a normal solution you
never write collision-resolution code (the from-scratch section below is
the exception). But a hash map's O(1) is an average
case — a pathological hash function can degrade to O(n) per operation.
You'll never need to defend against this in an interview, but "average
case, not worst case" is the correct answer if asked.
Set membership: asking "is the neighbour here?"
Many hashing problems do not need a count or an index, only the answer to
"is this value present?". A Set gives that in O(1), and it
changes what you can afford to do inside a loop. Two problems show it.
Longest consecutive sequence. Given unsorted integers, find the
length of the longest run of consecutive values, such as 1, 2, 3, 4,
in O(n) time. Sorting gives O(n log n). With a set, you can ask
"does x + 1 exist?" directly, so a run can be walked one step
at a time. The danger is walking the same run many times. If you start a
walk from every element, the run 1, 2, ..., n is walked once from each of
its n elements, n(n+1)/2 steps in all, O(n²). The fix is to start a walk only at the beginning
of a run, which is a value whose predecessor is missing.
function longestConsecutive(nums) {
const set = new Set(nums);
let best = 0;
for (const x of set) {
if (set.has(x - 1)) continue;
let length = 1;
while (set.has(x + length)) length++;
if (length > best) best = length;
}
return best;
}
Dry run: longestConsecutive([100, 4, 200, 1, 3, 2])
| x | set.has(x − 1)? | walk | length |
|---|---|---|---|
| 100 | no, so a run starts here | 101 is missing | 1 |
| 4 | yes (3), skip | none | - |
| 200 | no, so a run starts here | 201 is missing | 1 |
| 1 | no, so a run starts here | 2, 3, 4 are present, 5 is missing | 4 |
| 3 | yes (2), skip | none | - |
| 2 | yes (1), skip | none | - |
The answer is 4. Why this is O(n) even though there is a while
inside a for: the inner loop only runs from a run's first
element, and every value belongs to exactly one run, so across the whole
call the inner steps visit each value at most once. The outer loop adds n
more. Total at most 2n set lookups.
⚠ Loop over the Set, not the array
The code iterates
set, not nums. With duplicates,
looping over the array repeats the walk for every copy of a run's start.
For [1, 1, 1, ..., 1, 2, 3, ..., n/2] (n/2 copies of 1, then a
run of n/2 values) that is n/2 walks of length n/2: for n = 20,000 the inner
loop steps 100,000,000 times instead of about 10,000. Deduplicating through
the Set first removes the problem.
Happy number. Replace a number by the sum of the squares of its digits, and repeat. Does it reach 1, or loop forever? A set of the values already seen answers it: stop at 1 (happy), or at a value you have met before (a cycle, so never happy).
function digitSquareSum(n) {
let sum = 0;
while (n > 0) {
const digit = n % 10;
sum += digit * digit;
n = Math.floor(n / 10);
}
return sum;
}
function isHappy(n) {
const seen = new Set();
while (n !== 1 && !seen.has(n)) {
seen.add(n);
n = digitSquareSum(n);
}
return n === 1;
}
isHappy(19) follows 19 → 82 → 68 → 100 → 1 and returns
true. isHappy(2) follows 2 → 4 → 16 → 37 → 58 →
89 → 145 → 42 → 20 → 4 and meets the 4 again, so it returns
false. The loop must end, because the sequence cannot grow
forever: a number with d digits maps to at most 81 · d, which is smaller
than the number itself from four digits up, and any number below 1,000
maps to at most 243. So every sequence lives in a finite range, and a
finite range forces either 1 or a repeat. The set costs O(1) extra per
step; remembering nothing and detecting the cycle with a slow and a fast
pointer (see Two pointers) trades the
set for O(1) space.
Top-k frequent: count, then bucket by frequency
"Return the k most frequent values" has a standard first half: count every
value with a Map, which costs O(n). The second half, picking
the top k counts, can be done in several ways, and the comparison is a
classic interview discussion.
| Second half | Time (m distinct values) | Extra space | Note |
|---|---|---|---|
| Sort all (value, count) pairs | O(m log m) | O(m) | simplest; fine when m is small |
| Min-heap of size k | O(m log k) | O(k) beyond the map | best when k is much smaller than m; see heaps |
| Bucket by frequency | O(n) | O(n) | works because a count is a small integer |
The bucket idea: a count can only be a whole number from 1 to n, so make an array indexed by count, and put each value in the bucket of its count. The most frequent values then sit in the highest buckets; read from the top until you have k. It is counting sort applied to the counts themselves.
function topKFrequent(nums, k) {
const counts = new Map();
for (const x of nums) counts.set(x, (counts.get(x) || 0) + 1);
const buckets = Array.from({ length: nums.length + 1 }, () => []);
for (const [value, count] of counts) buckets[count].push(value);
const out = [];
for (let count = buckets.length - 1; count >= 1 && out.length < k; count--) {
for (const value of buckets[count]) {
out.push(value);
if (out.length === k) break;
}
}
return out;
}
Dry run: topKFrequent([1, 1, 1, 2, 2, 3], 2)
| step | state |
|---|---|
| count | Map {1 → 3, 2 → 2, 3 → 1} |
| bucket | buckets[1] = [3], buckets[2] = [2], buckets[3] = [1], every other bucket is empty (indexes 0 to 6) |
| read from index 6 down | buckets 6, 5, 4 are empty |
| count = 3 | take 1; out = [1] |
| count = 2 | take 2; out = [1, 2]; length is k, stop |
Time O(n): counting n values, filling at most m buckets, and reading at most n + 1 buckets, with O(n) extra space for the buckets. When the boundary bucket holds more values than there are slots left, the statement has to say which to take: if it guarantees a unique answer there is no such tie, and if it asks for, say, alphabetical order among ties, sort the values inside each bucket.
⚠ Do not fill the buckets with one shared array
new Array(n + 1).fill([]) puts the same array into every
slot, so a push to buckets[2] shows up in every bucket. Create
each one with Array.from(..., () => []), the same fix as the
shared-row bug for 2D grids in
Arrays & strings.
Canonical keys: make things that are equal look equal
Grouping problems ask which items are "the same" in some sense: anagrams,
shifted strings, patterns with the same shape. The hashing recipe is to
compute a canonical key for each item, a value that is identical for
every item in the same class and different for items in different classes,
and then group by that key in a Map. The sorted-letters key
from the grouping section above is one. Its cost is O(L log L) per word of
length L. Counting letters gives a faster one:
function groupAnagramsByCount(words) {
const groups = new Map();
for (const word of words) {
const counts = new Array(26).fill(0);
for (const ch of word) counts[ch.charCodeAt(0) - 97]++;
const key = counts.join("#");
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(word);
}
return [...groups.values()];
}
Building a key costs O(L + 26) per word, so the whole grouping is
O(n · (L + 26)), against O(n · L log L) for sorting each word. The
"#" separator is not decoration. Without it, the count lists
[1, 11] and [11, 1] would both join to
"111", and two different words would share a key. A good key
has three properties: equal items give equal keys; different items give
different keys (so separators or fixed widths); and building it is cheap.
The same recipe handles "shifted" strings ("abc" and
"bcd" are the same shape). Describe a word by the gaps between
neighbouring letters instead of the letters themselves:
function shiftKey(word) {
const diffs = [];
for (let i = 1; i < word.length; i++) {
diffs.push((word.charCodeAt(i) - word.charCodeAt(i - 1) + 26) % 26);
}
return diffs.join(",");
}
shiftKey("abc") and shiftKey("bcd") are both
"1,1"; shiftKey("az") is "25" and
shiftKey("ba") is "25" too, because the
+ 26 and % 26 wrap the gap around the end of the
alphabet. A single letter has no gaps, so its key is the empty string, and
all single-letter words land in one group, which is right since each is a
shift of the other.
Bijections: two maps for isomorphic strings and word patterns
Some problems need a one-to-one correspondence, not just "same
group". "egg" and "add" are isomorphic: replace
e with a and g with d, and no two different letters may map to the same
letter. One map is not enough. With only a forward map from s to t, the
strings "bar" and "foo" pass, because b → f,
a → o and r → o are each consistent, even though two different letters
collide on o. A second map, in the opposite direction, catches that.
function isIsomorphic(s, t) {
if (s.length !== t.length) return false;
const forward = new Map();
const backward = new Map();
for (let i = 0; i < s.length; i++) {
const a = s[i];
const b = t[i];
if (forward.has(a) && forward.get(a) !== b) return false;
if (backward.has(b) && backward.get(b) !== a) return false;
forward.set(a, b);
backward.set(b, a);
}
return true;
}
Dry run: isIsomorphic("bar", "foo")
| i | a → b | forward check | backward check |
|---|---|---|---|
| 0 | b → f | b is new | f is new; store b→f and f→b |
| 1 | a → o | a is new | o is new; store a→o and o→a |
| 2 | r → o | r is new | backward.get("o") is "a", not "r": return false |
Word pattern is the same problem with a pattern letter on one side and a
whole word, from s.split(" "), on the other. Check that the
number of words equals the pattern length first, or the loop will compare
against undefined. An equivalent approach is a canonical key:
replace each character by the index of its first appearance, so
"egg" and "add" both become [0, 1, 1],
and compare the two lists. The two-map version stops at the first conflict
and is easier to explain under pressure.
Composite keys: a Map compares objects by reference
A Map or Set treats two objects as the same key
only if they are the same object. Arrays are objects, so a pair of
coordinates stored as an array is never found again:
const visited = new Set();
visited.add([1, 2]);
visited.has([1, 2]); // false: a different array that merely looks equal
The fix is to turn the composite value into a primitive key that is equal exactly when the parts are equal. There are three options:
- A string, such as
x + "," + y. Easy, and works for any number of parts. The separator is required:"1" + "11"and"11" + "1"are the same string without it. Building the string costs O(length), and hashing it costs the same. - A number, such as
row * cols + col, when both parts have known bounds. It is the fastest, and it needs0 <= col < colsto be one-to-one. - Nested maps (a
Mapfrom x to aMaporSetof y). No string building, but more code.
function distinctVisited(moves) {
let x = 0;
let y = 0;
const seen = new Set(["0,0"]);
for (const move of moves) {
if (move === "U") y++;
else if (move === "D") y--;
else if (move === "L") x--;
else x++;
seen.add(x + "," + y);
}
return seen.size;
}
distinctVisited("UDUD") visits (0,0), (0,1), (0,0), (0,1),
(0,0), so the set holds 2 keys and the function returns 2. Negative
coordinates are fine with a separator: the keys "-1,0" and
"1,0" are different strings. JSON.stringify([x, y])
also works as a key but is slower, and is only safe for plain arrays and
objects built in a consistent order.
⚠ The silent miss
A
Set of arrays never throws and never warns. It just answers
"not found", so a visited-set BFS stores every state again and again and
runs forever or blows up in memory. If a hashing solution is mysteriously
slow or never terminates, check first whether the keys are objects.
Complement lookups: find each value's partner
Two Sum is one case of a wider pattern: for each item, compute the partner that would complete the answer, then ask the map whether it exists. Two variations show how far it stretches.
4Sum II. Four arrays a, b, c, d of length n each. Count the tuples
(i, j, k, l) with a[i] + b[j] + c[k] + d[l] === 0. Four nested
loops cost O(n⁴). Split the problem in two halves: the sums of an a-element
and a b-element on one side, and of a c-element and a d-element on the
other. The tuple works exactly when the left sum is the negative of the
right sum. Store the left sums with their counts, then look up the negative
of each right sum.
function fourSumCount(a, b, c, d) {
const sums = new Map();
for (const x of a) {
for (const y of b) {
const sum = x + y;
sums.set(sum, (sums.get(sum) || 0) + 1);
}
}
let count = 0;
for (const x of c) {
for (const y of d) {
count += sums.get(-(x + y)) || 0;
}
}
return count;
}
Time O(n²) and space O(n²) for the map of pair sums. Take
a = [1, 2], b = [-2, -1], c = [-1, 2]
and d = [0, 2]. The sums of an a-element and a b-element are
-1, 0, 0 and 1, stored as {-1: 1, 0: 2, 1: 1}. The sums of a
c-element and a d-element are -1, 1, 2 and 4. Their negatives are 1, -1,
-2 and -4: the lookup of 1 finds one pair and the lookup of -1 finds one,
while -2 and -4 find nothing, so the count is 2. A detail to trust: when
x + y is 0, -(x + y) is -0, and a
Map treats 0 and -0 as the same key, so the lookup still finds
a stored 0.
Contains duplicate II. Is there a value that appears twice with the two positions at most k apart? For each element, the only earlier position that matters is the nearest one, the most recent time the same value appeared. A map from value to last index holds exactly that.
function containsNearbyDuplicate(nums, k) {
const lastIndex = new Map();
for (let i = 0; i < nums.length; i++) {
if (lastIndex.has(nums[i]) && i - lastIndex.get(nums[i]) <= k) {
return true;
}
lastIndex.set(nums[i], i);
}
return false;
}
containsNearbyDuplicate([1, 0, 1, 1], 1) reaches the third
element, finds last index 0 and distance 2 (too far), then overwrites
the stored index with 2, and at the fourth element finds distance 1 and
returns true. Skipping that overwrite, keeping the first index,
is the usual bug: it would compare against 0 and miss the pair. The same
question can be answered with a window: a Set of the last k
values, adding nums[i] and removing nums[i − k],
which uses O(k) space.
Counting subarrays whose sum is a target is the complement idea applied to running totals (the partner of a prefix sum is the prefix sum minus the target). That is the main technique of Prefix sums.
How a hash map works inside: buckets, collisions and resizing
The opening section said a key picks a bucket. Here is the rest of the story, using the textbook design. Real engines vary in the details, but the costs below are why "average O(1)" is the honest claim.
- Hash. A hash function turns the key into a large integer. Equal keys must give equal hashes; different keys should spread out.
- Bucket. Reduce the hash to a position:
hash % capacity. The table is an array ofcapacitybuckets. - Collision. Two different keys can land in the same bucket. In chaining, each bucket holds a small list of its entries; a lookup goes to the bucket and walks that list, comparing keys. (The other common design, open addressing, probes neighbouring slots instead.)
- Resize. Let the load factor be size ÷ capacity. When it passes a threshold (0.75 is a common choice), allocate a table with twice the capacity and re-insert every entry, because the bucket depends on the capacity. This costs O(n) but happens rarely.
With a good hash and a bounded load factor, the lists stay short (the
average length is the load factor, below 1), so lookup, insert and delete
take O(1) on average. The resize is the same doubling argument as the
array's push: expensive occasionally, amortized O(1) per
insert (see Complexity analysis).
The worst case is every key in one bucket: O(n) per operation. A bad hash
function, or keys chosen by an adversary who knows the hash, can cause it,
which is why production hash tables seed or randomize their hashing.
What counts as "the same key" is a language decision, and JavaScript has specific rules:
| Question | Map / Set | Plain object |
|---|---|---|
Is NaN found again? | yes: new Set([NaN]).has(NaN) is true | the key becomes the string "NaN" |
Are 0 and -0 the same key? | yes, they are the same key | yes, both become "0" |
Are 1 and "1" the same key? | no, two different keys | yes, both become "1" |
| Are two equal-looking arrays the same key? | no, only the same object is | both become the same string, such as "1,2" |
Map and Set compare keys with a rule called
SameValueZero. It differs from === in exactly one way that
matters: NaN equals NaN. That is also why
[NaN].includes(NaN) is true while
[NaN].indexOf(NaN) is -1: includes
uses SameValueZero and indexOf uses ===. A
Map also remembers insertion order, and re-inserting an
existing key keeps its place, but deleting a key and adding it again moves
it to the end.
Build a HashMap from scratch
Interviewers like "implement a hash map" because it checks that you know what the library hides. The design below uses string keys, chaining, a polynomial string hash, and doubling at load factor 0.75.
function hashString(s) {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return h;
}
class ChainedMap {
constructor() {
this.capacity = 8;
this.size = 0;
this.buckets = Array.from({ length: this.capacity }, () => []);
}
indexFor(key, capacity) {
return hashString(key) % capacity;
}
get(key) {
const bucket = this.buckets[this.indexFor(key, this.capacity)];
for (const entry of bucket) {
if (entry[0] === key) return entry[1];
}
return undefined;
}
has(key) {
const bucket = this.buckets[this.indexFor(key, this.capacity)];
return bucket.some((entry) => entry[0] === key);
}
put(key, value) {
const bucket = this.buckets[this.indexFor(key, this.capacity)];
for (const entry of bucket) {
if (entry[0] === key) {
entry[1] = value;
return;
}
}
bucket.push([key, value]);
this.size++;
if (this.size > this.capacity * 0.75) this.grow();
}
remove(key) {
const bucket = this.buckets[this.indexFor(key, this.capacity)];
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket.splice(i, 1);
this.size--;
return true;
}
}
return false;
}
grow() {
const bigger = this.capacity * 2;
const next = Array.from({ length: bigger }, () => []);
for (const bucket of this.buckets) {
for (const entry of bucket) {
next[this.indexFor(entry[0], bigger)].push(entry);
}
}
this.buckets = next;
this.capacity = bigger;
}
}
The hash multiplies the running value by 31 and adds the next character
code, the same polynomial idea as the string hashes of the
string algorithms chapter.
The >>> 0 keeps the value an unsigned 32-bit integer,
and the product stays far below 253, so the arithmetic is exact.
For "cat" the hash is 98,262, which is bucket 6 of 8; for
"act" it is bucket 2; and for "tac" it is bucket 6
again.
Dry run: three puts, then a collision and a lookup
| call | bucket | buckets afterwards (non-empty) | size |
|---|---|---|---|
| put("cat", 1) | 6 | 6: [cat] | 1 |
| put("act", 2) | 2 | 2: [act], 6: [cat] | 2 |
| put("tac", 3) | 6 | 2: [act], 6: [cat, tac] | 3 |
| get("tac") | 6 | walks cat (no match), then tac: returns 3 | 3 |
| put("cat", 9) | 6 | cat found, value replaced, nothing appended | 3 |
The two clashing keys share bucket 6, and the get has to compare
keys to tell them apart: the bucket only narrows the search. The resizes
happen at the 7th insertion (size 7 is more than 0.75 × 8 = 6, so capacity
goes 8 → 16) and the 13th (13 is more than 12, so 16 → 32). With 1,000
keys such as "user0" through "user999" the table
ends at capacity 2,048 with a longest chain of 3.
⚠ Where hand-written hash maps go wrong
Copying the buckets on resize. Each entry's bucket depends on the
capacity, so it must be re-hashed into the new table. Moving the old lists
across unchanged puts entries where lookups will not find them.
Appending on a repeated key.
put must update an existing
entry, or the size over-counts and the old value is still returned.
Forgetting to shrink size on remove, which makes the load
factor drift upward. Using the raw hash as an index: it can be
larger than the table, or negative if you let the arithmetic go signed.
When hashing is the wrong tool
- You need order — a hash map doesn't sort. If the question wants sorted output or range queries, you likely want a sorted structure (or a heap) instead.
- You need the closest match, not an exact one — hashing only answers "is this exact key present." Nearest-value questions want binary search on a sorted structure.
- Memory is the actual constraint — if the problem explicitly asks for O(1) space, a hash map is disqualified by definition.
Before you move on
- Explain why a hash map lookup is O(1) average case, in terms of what the hash function actually does.
- Trace
twoSumFaston a new array by hand, tracking exactly what's inseenat each step. - Write a frequency counter and an anagram check from memory, using the same "count then compare" shape.
- Say out loud why "average case, not worst case" is the correct caveat on hash map complexity.
- Given a new problem, recognize when hashing is the wrong tool — because it needs order or a closest match, not an exact key.
- Solve longest consecutive sequence by starting a walk only where a run begins, and explain why looping over the array instead of the
Setcan turn it quadratic. - Pick between sorting, a size-k heap and frequency buckets for top-k, and say what each costs.
- Design a canonical key for anagrams or shifted strings, and say why the key needs a separator; use two maps when the mapping must be one-to-one.
- Explain why
new Set([[1, 2]]).has([1, 2])isfalse, and turn a coordinate pair into a key that works. - Split a four-way sum into two pair-sum maps, and keep the nearest index when a window limit matters.
- Describe buckets, collisions, load factor and resizing, and write a chained hash map whose resize re-hashes every entry.
Opens in the editor: write it, run it, and check it against real tests.
All 217 for this level →5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.
Finished reading?
0 of 48 chapters read in this series