LC Patterns — Hash Map / Hash Set
lc-patterns-<topic>.md · Siblings: probability, linked-lists, … · Code: C++◆ The Master Insight
A hash map is a deal: spend memory, and in return any "have I seen this? / where is it? / how many are there?" question is answered instantly, no matter how much data you've stored.
The slow version of these problems is always "for each element, scan everything to find its partner / group / count" — O(n²). The hash map flips the direction: as you walk the data once, store each item under the key a future item would search for. Every lookup is O(1), and the whole thing is one pass. So the real skill is designing the key — the value is usually boring (an index, a count, a list); the key is where the thinking happens.
§0Mechanics & Universal Pitfalls
unordered_map<int,int> d; // key → value (average O(1))
++d[k]; // operator[] default-inits 0 — counting in one line
d.count(k); // membership test (0 or 1)
auto it = d.find(k); // lookup; it == d.end() means absent
unordered_set<int> seen; // membership only, no values
int cnt[26] = {0}; // for lowercase letters, a plain array beats a map
for (char c : s) ++cnt[c - 'a']; // frequency count
string key = w; // anagram key = a sorted copy of the word
sort(key.begin(), key.end());
Facts to have loaded
unordered_map keys need a hash + operator==. int and string work out of the box; pair/tuple have no default hash — encode them into one value (e.g. r*1000 + c or a joined string) or fall back to std::map (ordered, O(log n)). O(1) is the average — worst case O(n) with bad collisions; say "expected O(1)" once. unordered_map has no order at all — for "first unique," rescan the original string. Anagram test: equal 26-count arrays, or sort both copies. Reach for unordered_set whenever you'd only ever store true. Use reserve() to cut rehash cost when the size is known.
Universal pitfalls
1. Check before vs after insert. Two Sum works because you check for the partner first, then insert yourself — so you never pair an element with itself. Decide the order consciously every time.
2. No default hash for pair/tuple. unordered_map<pair<int,int>,…> won't compile. Encode composite keys into one int/long long/string, or use std::map.
3. Assuming order. unordered_map has no order; "give me the smallest key" is O(n). Need order? std::map (O(log n)).
4. Keys must capture everything that matters. If two things get the same key but shouldn't be grouped, the key is missing information.
5. Decrement-below-zero. When consuming counts, check cnt < 0 right after --cnt (a single count array handles supply-vs-demand in one pass).
6. Small dense integer keys? A plain list indexed by value is simpler and faster (leads to Pattern 8, where the input array itself becomes the map).
1Have I Seen It? ★ start here
What it is: The simplest deal: walk the data once, keep a set/map of what you've seen, and for each new element ask one O(1) question about the past — "has this exact thing appeared?" or "has my partner appeared?" (the partner being whatever makes the pair work, like target − x).
| see x | partner = 9 − x | in map? | action |
|---|---|---|---|
| 2 | 7 | — | store {2:0} |
| 7 | 2 | yes → idx 0 | return [0, 1] |
3 never pairs with itself, but [3,3] works.Intuition: the brute force scans backward for every element — O(n²). But that backward scan asks a question with a precomputable answer. So precompute it: store each element as you pass. Two Sum (1): partner is target − x, looked up before storing x. Contains Duplicate (217): question is "seen exactly this value?" 219 adds "…within the last k positions?" — so the stored value becomes the last index each value appeared, and you compare distances.
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int,int> seen; // value → index
for (int i = 0; i < (int)nums.size(); ++i) {
auto it = seen.find(target - nums[i]); // ask for the partner FIRST
if (it != seen.end()) return {it->second, i};
seen[nums[i]] = i; // then store yourself
}
return {};
}
Challenges
The check-then-insert order (self-pairing — the bug of this pattern); returning indices vs values (Two Sum wants indices → store value→index); duplicates in Two Sum ([3,3] works because you check before inserting the second 3 — trace it once); in 219, updating the stored index even on no match (stale indices give wrong distances); the sorted-input alternative is two pointers — the map is for the unsorted case.
- Map holds each value's most recent index. Index 0,1,2: store 1→0, 2→1, 3→2.
- Index 3: 1 is in map at index 0; distance
3 − 0 = 3 ≤ k→ True. - If a value recurs later, overwrite its index — only the latest occurrence can be close enough. ✓
2Frequency Counting (the count-array reflex)
What it is: Count how many times each thing appears, then answer questions off the counts: can these letters build that word? which character appears exactly once first? sort characters by how common they are. One counting pass (a size-26 array or an unordered_map), then a second pass over the counts.
++cnt over the magazine, --cnt over the note — if any drops < 0, demand beat supply. Here it never does → true.'l' at index 0. Two passes are mandatory — you can't call a char unique until you've seen the whole string.Intuition: many string/array questions aren't about order — only about supply. Ransom note (383) asks whether the magazine's supply covers the note's demand: count both, compare. First unique (387): a char is unique iff its total count is 1 — count first, then re-walk. Sort by frequency (451): count, then rebuild most-common-first. Second-level move: count the counts (LC 1207 asks if all frequencies are distinct → drop them in an unordered_set and check its size).
bool canConstruct(string note, string mag) {
int cnt[26] = {0};
for (char c : mag) ++cnt[c - 'a']; // supply
for (char c : note) if (--cnt[c - 'a'] < 0) // spend; demand beat supply?
return false;
return true;
}
string frequencySort(string s) {
unordered_map<char,int> cnt;
for (char c : s) ++cnt[c];
vector<pair<int,char>> v; // (count, char)
for (auto& [ch, c] : cnt) v.push_back({c, ch});
sort(v.rbegin(), v.rend()); // most common first
string out;
for (auto& [c, ch] : v) out += string(c, ch);
return out;
}
Challenges
Trying supply-questions in one pass (387 genuinely needs count-then-scan — resisting premature cleverness is the skill); mutating counts while consuming; tie order among equal counts is up to your comparator (say it if output could vary); the majority element (169) has both a counting answer and the O(1)-space Boyer–Moore vote — offer counting first, name Boyer–Moore as the follow-up; keep the meaning crisp: "letter → how many I have left."
3Count Pairs by Complement (add-then-count streaming)
What it is: Count how many pairs satisfy a relation — sum divisible by 60, difference exactly k, equal values. Like Pattern 1, but instead of stopping at the first partner you add the number of partners seen so far to a running answer, then register yourself. Every pair is counted exactly once, no index juggling.
| r | needs (60−r)%60 | count seen | + ans | running total |
|---|---|---|---|---|
| 30 | 30 | 0 | +0 | 0 |
| 20 | 40 | 0 | +0 | 0 |
| 30 | 30 | 1 | +1 | 1 |
| 40 | 20 | 1 | +1 | 2 |
| 40 | 20 | 1 | +1 | 3 |
(60 − r) % 60 so r=0 correctly needs 0, not 60.Intuition: for each element, the number of pairs it closes is exactly "how many earlier elements were my partner" — a count the map already has. The order — count first, then add yourself — guarantees each pair is counted once (as "later meets earlier") and never self-paired. Difference k (2006): partners of x are earlier x−k and x+k — look both up. Good pairs (1512): partner is an earlier equal x.
int numPairsDivisibleBy60(vector<int>& time) {
long long ans = 0; // n² pairs can overflow int → use long long
int count[60] = {0}; // remainder → how many seen
for (int t : time) {
int r = t % 60;
ans += count[(60 - r) % 60]; // add partners seen so far, FIRST
++count[r]; // then register yourself
}
return ans;
}
Challenges
Self-partnered classes (r=0 pairs with r=0; r=30 with r=30 — add-then-count handles them, but verify on a tiny case); flipping the order (count yourself first → every element self-pairs once); difference problems need two lookups (only checking x−k misses half; the streaming discipline sorts this out); overflow of pair counts in other languages (mention long); the closed-form alternative is sum of C(c,2) over groups.
- Partner of x is an earlier equal x. First 1: +0. Second 1: +1. Third 1: +2. The 2: +0.
- Answer 3 — same as
C(3,2)=3for the group of three 1s. The stream and the formula always agree. ✓
4Group by Canonical Key (make equals collide)
What it is: Things belong together when they share a hidden signature — anagrams share letters, matrix rows can equal columns, a Sudoku digit "collides" in the same row/column/box. Design a canonical key — one standardized form that all group members map to and nothing else does — and let the map do the grouping.
tea → aet
tan → ant
ate → aet
nat → ant
bat → abt
"r"+r, "c"+c, "b"+box (each with the digit). C++ has no default hash for a tuple, so encode the signature into a string. A repeat is the rule violation → invalid. box = (r/3)*3 + c/3.Intuition: grouping is pairwise comparison in disguise — O(n²) if you compare everything with everything. A canonical key makes comparison free: two things share a group iff their keys are equal. Anagrams (49): sorted letters. Equal row-column pairs (2352): key = the row joined into a string (a vector has no default hash). Valid Sudoku (36): the same pattern used for collision-detection instead of collection.
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> groups;
for (auto& w : strs) {
string key = w;
sort(key.begin(), key.end()); // canonical key = sorted letters
groups[key].push_back(w);
}
vector<vector<string>> out;
for (auto& [k, v] : groups) out.push_back(move(v));
return out;
}
Challenges
A key missing information (grouping rows by their sum collides unequal rows — the ⇐ direction fails); a key with extra information (including position in an anagram key — nothing groups); unhashable composite keys (a pair/vector in an unordered_map is a compile error — encode to a string/long long); the sorted-string vs count-string trade-off in 49 (say the complexities); in 36, one unordered_set<string> of encoded signatures keeps it uniform.
5Two-Way Mapping (bijection checks)
What it is: Check that two sequences match up one-to-one: every 'a' in s maps to the same letter in t, and no two different letters map to the same target. One map is not enough — you need both directions (or a trick that implies the second).
c mapping to two letters → False. That's why you need both.Intuition: a single map s→t catches "one letter mapping to two things" but misses "two letters mapping to the same thing." Isomorphic (205) and word pattern (290 — same problem with words) both need the backward map: check and update both every step. Find-and-replace pattern (890): normalize both to a "first-occurrence fingerprint" ("abb" → [0,1,1], "mee" → [0,1,1]) and compare — normalizing to a canonical form quietly is the two-way check, connecting back to Pattern 4.
bool isIsomorphic(string s, string t) {
unordered_map<char,char> s2t, t2s;
for (int i = 0; i < (int)s.size(); ++i) {
char a = s[i], b = t[i];
if (s2t.count(a) && s2t[a] != b) return false; // conflict forward
if (t2s.count(b) && t2s[b] != a) return false; // conflict backward
s2t[a] = b; t2s[b] = a;
}
return true;
}
Challenges
Shipping only the forward map (the "cc" counterexample — memorize it, it's the whole pattern; volunteer it before the interviewer asks); 290's length mismatch edge ("aaa" vs two words — check lengths first); updating maps before checking (order the conflict check first); recognizing the family — "follows the same pattern" or "isomorphic" mean bijection, so two maps or a fingerprint.
- Split s → [dog, cat, cat, dog]. Lengths match (4 vs 4) — check first.
- a→dog, b→cat (both directions). Then b→cat ✓, a→dog ✓ → True.
- With "dog cat cat fish": final a→fish conflicts with a→dog → False. ✓
6Prefix Sum + Hash Map ★ most-asked medium in this file
What it is: Count or find subarrays with a sum property — sum exactly k, equal 0s and 1s, sum divisible by k. A subarray sum is a difference of two running totals (sum(i..j) = prefix[j] − prefix[i−1]), so "find a subarray summing to k" becomes "find two running totals that differ by k" — Pattern 1's complement lookup, applied to prefix sums.
| after x | total | look for total − k | seen? | ans | record |
|---|---|---|---|---|---|
| seed | 0 | — | — | 0 | {0:1} |
| 1 | 1 | 1 − 3 = −2 | — | 0 | {0:1, 1:1} |
| 2 | 3 | 3 − 3 = 0 | yes (×1, the seed!) | 1 | + {3:1} |
| 3 | 6 | 6 − 3 = 3 | yes (×1) | 2 | + {6:1} |
[1,2] and [3]. The {0:1} seed did real work — without it, the subarray starting at index 0 is missed (the bug of this pattern).Intuition: at position j you want earlier positions i where prefix[j] − prefix[i] = k, i.e. earlier totals equal to prefix[j] − k — a map from total → count answers that in O(1) (560). Equal 0s/1s (525): recode 0 as −1, so it becomes "sum = 0," and for the longest such subarray store the first index of each total. Divisible by k (974): two prefixes work iff same remainder mod k → key = remainder. Seed the empty prefix: {0:1} for counting, {0:-1} for first-index.
int subarraySum(vector<int>& nums, int k) { // 560 — count
unordered_map<long long,int> count{{0, 1}}; // seed the empty prefix
long long total = 0; int ans = 0;
for (int x : nums) {
total += x;
auto it = count.find(total - k); // lookup BEFORE insert
if (it != count.end()) ans += it->second;
++count[total];
}
return ans;
}
int findMaxLength(vector<int>& nums) { // 525 — longest, store first index
unordered_map<long long,int> first{{0, -1}};
long long total = 0; int best = 0;
for (int i = 0; i < (int)nums.size(); ++i) {
total += nums[i] ? 1 : -1;
auto it = first.find(total);
if (it != first.end()) best = max(best, i - it->second);
else first[total] = i; // never overwrite — earliest wins
}
return best;
}
Challenges
The {0:1} / {0:-1} seed (the bug — test on an array whose answer starts at index 0, like [k]); overwriting first-occurrence when you needed earliest (525) vs counting all (560) — the two templates differ in exactly one line; lookup before insert; why sliding window fails here (negatives break grow/shrink — a favorite follow-up); remainder-key problems (974) apply mod to the running total — and C++ % can be negative, so normalize with ((total % k) + k) % k.
7Seen-States: sequence walks, cycles, fingerprints
What it is: The "have I seen it?" set applied to bigger things than single values: whole positions in a process (to detect a loop), stretches of consecutive numbers (to walk chains), or chunks of a string (to spot repeats). The set answers "am I revisiting?" — each problem differs in what a "state" is.
Intuition: 128: a number x is a start iff x−1 is missing — only then walk forward. The "only start at starts" gate makes it O(n) despite the loop, and the O(n) proof is the sentence interviewers wait for. 202: store every total seen; a revisit means a loop. 187 (repeated DNA): slide a length-10 window, store each chunk; a chunk already present is a repeat (report each once).
int longestConsecutive(vector<int>& nums) {
unordered_set<int> s(nums.begin(), nums.end());
int best = 0;
for (int x : s) {
if (s.count(x - 1)) continue; // not a start — skip (keeps it O(n))
int y = x;
while (s.count(y + 1)) ++y; // walk the run
best = max(best, y - x + 1);
}
return best;
}
Challenges
128's complexity claim ("isn't the while inside the for O(n²)?" — verbatim answer: "the inner walk only runs from starts, and each number belongs to exactly one run, so total inner work is O(n)"); duplicate reports in 187 (a second "already reported" set); 202's termination (bounded values ⇒ pigeonhole ⇒ reach 1 or loop); sorting for 128 (O(n log n) works — name it, then beat it).
8The Array as Its Own Hash Map (in-place index marking)
What it is: When values are (or can be brought) into 1..n and the array has length n, the array itself is the hash map: value v ↔ index v−1, and "I've seen v" is recorded by marking that slot (negating it, or swapping v into it). O(1) extra space — the trick behind every "find the missing/duplicate in O(n) time, O(1) space."
2 → slot 1 is already negative → 2 is a duplicate; see 3 → slot 2 already negative → duplicate. Answer [2, 3]. The signs were the hash set.nums[i] != i+1 → slot 1 holds −1, expected 2 → answer 2. Each swap homes one value for good → ≤ n swaps → O(n).Intuition: a hash map from {1..n} to anything is just an array of size n — and you already have one. Marking by negation (448, 442): flip nums[v−1] negative; still-positive at the end means that number never appeared (448); already-negative means seen twice (442). Marking by swapping into place (41): repeatedly swap each value into its home slot ("cyclic sort"), then the first misplaced slot reveals the answer.
vector<int> findDuplicates(vector<int>& nums) { // 442
vector<int> res;
for (int x : nums) {
int i = abs(x) - 1; // abs() mandatory — value may already be flipped
if (nums[i] < 0) res.push_back(i + 1); // already marked → duplicate
else nums[i] = -nums[i];
}
return res;
}
int firstMissingPositive(vector<int>& nums) { // 41
int n = nums.size();
for (int i = 0; i < n; ++i)
while (nums[i] >= 1 && nums[i] <= n && nums[nums[i]-1] != nums[i])
swap(nums[i], nums[nums[i]-1]); // guard stops infinite swap
for (int i = 0; i < n; ++i)
if (nums[i] != i + 1) return i + 1;
return n + 1;
}
Challenges
Forgetting abs when indexing (a flipped value indexes a negative slot — crash or silent nonsense); the infinite-swap guard in 41 (compare nums[nums[i]-1] != nums[i], not != i+1 — trace [1,1]); mutating the input (state it); the off-by-one between value v and index v−1 (write the convention as a comment); trigger recognition — "O(n) time, O(1) space, values in 1..n" is this pattern with near-certainty.
9Hash Map in Design Problems (composition)
What it is: "Design a class with O(1) operations" — the answer is almost always a hash map plus one other structure for what the map can't do: an array for random access (380), sorted lists for time queries (981), heaps/lists for feeds (355), a doubly linked list for recency (146). The map's role is always the same — jump straight to the right spot in the other structure.
Intuition: each structure is missing something. A map alone can't return a uniform random element (380) — but map + array can: the array holds elements (random index = random element), the map holds each value's position. Time-based KV (981): map key → append-only list of (timestamp, value); timestamps arrive increasing, so each list is sorted for free, and get is a binary search for "rightmost time ≤ t." Twitter (355): map user → tweets, user → followees; the feed merges recent tweets. LRU (146) = map + doubly linked list.
class RandomizedSet {
vector<int> vals; // elements, tightly packed
unordered_map<int,int> pos; // value → its index in vals (the invariant)
public:
bool insert(int v) {
if (pos.count(v)) return false;
pos[v] = vals.size(); vals.push_back(v); return true;
}
bool remove(int v) {
auto it = pos.find(v);
if (it == pos.end()) return false;
int i = it->second, last = vals.back();
vals[i] = last; pos[last] = i; // move last into the hole, fix its pos
vals.pop_back(); pos.erase(it); // shrink
return true;
}
int getRandom() { return vals[rand() % vals.size()]; } // uniform over current set
};
Challenges
380's remove when the victim is the last element (the swap becomes a self-swap — must tolerate it; test removing the only element); forgetting to update the moved element's map entry after swap-pop (the #1 bug — the invariant sentence catches it); 981 assuming you must sort per query (timestamps arrive sorted — appending keeps order); scope creep in 355 ("collect recent tweets and sort" passes and is honest); the meta-skill: decompose ops → structures out loud before any code.
- Structure:
unordered_map<string, vector<pair<int,string>>>.setjustpush_backs. - Timestamps only ever increase → every vector stays sorted with zero effort (the main observation).
get: binary-search the vector for the rightmost pair withtime ≤ t(upper_bound, step back one). Before the first timestamp → return"". ✓
▤Recognition Cheat Sheet
| # | Pattern | Trigger phrase | The key is… | Canonical trap |
|---|---|---|---|---|
| 1 | Have I seen it? | two sum, duplicates | the element (or its complement) | check before insert |
| 2 | Frequency counting | "can build", "first unique", "most common" | element → count | one-pass temptation (387 needs two) |
| 3 | Count pairs | "how many pairs with ⟨relation⟩" | partner signature (remainder, x±k) | self-partnered classes (r = 0) |
| 4 | Canonical key | "group", anagrams, "same rows" | standardized form (sorted, tuple) | key missing info / unhashable list |
| 5 | Two-way mapping | "isomorphic", "follows pattern" | both directions | one-map "cc" counterexample |
| 6 | Prefix sum + map | "subarray sum = k / divisible / balanced" | running total (or its remainder) | forgetting the {0:1} seed |
| 7 | Seen states | consecutive runs, loops, repeated chunks | position / total / substring | 128's O(n) justification |
| 8 | Array as map | "O(1) space, values in 1..n" | value v ↔ index v−1 | abs() when indexing; swap guard |
| 9 | Design combos | "O(1) operations", "design" | value → location in the other structure | update the moved element after swap-pop |