Hash Table: Practice Problems with Solutions
Welcome to the practice problems for hash tables. If you need a refresher on the code, the code templates have the patterns in all 6 languages. Each problem below includes a hint, a visual walkthrough, and the full solution.
Recommended Study Order
Work through the problems in the order they appear. Contains Duplicate teaches the membership check with a set. Two Sum adds the complement search, and Ransom Note adds decrementing counts. The Medium problems each add one twist. Group Anagrams builds a canonical key. Intersection of Two Arrays II combines counting with consumption. Longest Consecutive Sequence turns the set into a linear-time algorithm. LRU Cache is the capstone. It pairs the map with a linked list, and both halves must be correct for it to work.
Easy Problems
1. Contains Duplicate
LeetCode 217 | Difficulty: Easy
Brief: determine whether any value in an array appears more than once.
Why this pattern: membership testing is the purest hash table use. A set records what you have seen, and the first repeat is the answer.
Key Insight: you do not need counts. Existence is enough, which is exactly what a set provides.
Visual:
graph LR
A["nums = [1,2,3,1]"] --> B["seen = {}"]
B --> C["1: not seen, add"]
C --> D["2: not seen, add"]
D --> E["3: not seen, add"]
E --> F["1: already seen, return true"]
Code:
var containsDuplicate = function(nums) {
const seen = new Set();
for (const num of nums) {
if (seen.has(num)) {
return true;
}
seen.add(num);
}
return false;
};The early exit matters. As soon as a duplicate appears, the answer is known and the loop stops. The worst case is an array with no duplicates, where every element ends up in the set. Time is
2. Two Sum
LeetCode 1 | Difficulty: Easy
Brief: return the indices of the two numbers that add up to a target.
Why this pattern: complement search. For each number, the partner you need is the target minus the number. Store what you have seen, check what you still need.
Key Insight: check the map before storing the current number. Otherwise an element can pair with itself.
Visual:
graph LR
A["nums = [2,7,11,15], target = 9"] --> B["map = {}"]
B --> C["2: need 7. Not seen. Store 2 at index 0"]
C --> D["7: need 2. Seen at index 0. Return [0, 1]"]
Code:
var twoSum = function(nums, target) {
const seen = new Map();
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
// Check before storing so the current element
// cannot pair with itself.
if (seen.has(complement)) {
return [seen.get(complement), i];
}
seen.set(nums[i], i);
}
return [];
};The map stores value to index, and each element is visited once. Time is
nums = [3] and target = 6 to see why.3. Ransom Note
LeetCode 383 | Difficulty: Easy
Brief: decide whether the letters in a magazine can build a ransom note, with each letter used at most once.
Why this pattern: frequency counting with decrement. Count the magazine letters, then consume them one by one for the note.
Key Insight: counts, not booleans. A letter used twice in the note needs two copies in the magazine.
Visual:
graph LR
A["magazine = aab"] --> B["counts: a=2, b=1"]
B --> C["note = aba"]
C --> D["a: 2 to 1. b: 1 to 0. a: 1 to 0"]
D --> E["All letters found. Return true"]
Code:
var canConstruct = function(ransomNote, magazine) {
const counts = new Map();
for (const ch of magazine) {
counts.set(ch, (counts.get(ch) || 0) + 1);
}
for (const ch of ransomNote) {
if (!counts.has(ch) || counts.get(ch) === 0) {
return false;
}
counts.set(ch, counts.get(ch) - 1);
}
return true;
};The default value does the work. A letter that is not in the magazine reads as 0 and fails immediately, and an empty note passes because the second loop never runs. Each letter is handled once, so time is
Medium Problems
4. Group Anagrams
LeetCode 49 | Difficulty: Medium
Brief: group strings that are anagrams of each other.
Why this pattern: grouping by a canonical key. Two anagrams share the same sorted form, so the sorted form is the key and the strings collect under it.
Key Insight: the key must be identical for every anagram and different otherwise. Sorting the letters gives exactly that.
Visual:
graph LR
A["eat"] --> B["key = aet"]
C["tea"] --> B
D["ate"] --> B
B --> E["groups[aet] = [eat, tea, ate]"]
F["tan"] --> G["key = ant"]
G --> H["groups[ant] = [tan]"]
Code:
var groupAnagrams = function(strs) {
const groups = new Map();
for (const str of strs) {
// Sorted letters are the canonical form:
// every anagram produces the same key.
const key = str.split('').sort().join('');
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(str);
}
return Array.from(groups.values());
};Each string costs a sort, so the total is
5. Intersection of Two Arrays II
LeetCode 350 | Difficulty: Medium
Brief: return the elements common to two arrays, including duplicates.
Why this pattern: frequency counting with consumption. Count one array, then take from the count for the other.
Key Insight: each element of the result must consume one unit of count, so a number cannot be counted more times than it appears.
Visual:
graph LR
A["nums1 = [1,2,2,1]"] --> B["freq: 1=2, 2=2"]
B --> C["nums2 = [2,2]"]
C --> D["2: count 2. Add. Count becomes 1"]
D --> E["2: count 1. Add. Count becomes 0"]
E --> F["result = [2,2]"]
Code:
var intersect = function(nums1, nums2) {
const freq = new Map();
const result = [];
for (const num of nums1) {
freq.set(num, (freq.get(num) || 0) + 1);
}
for (const num of nums2) {
if (freq.has(num) && freq.get(num) > 0) {
result.push(num);
freq.set(num, freq.get(num) - 1);
}
}
return result;
};The decrement is the point. Without it, a number appearing twice in nums2 would be counted twice even if nums1 had it once. Time and space are both
6. Longest Consecutive Sequence
LeetCode 128 | Difficulty: Medium
Brief: find the length of the longest run of consecutive numbers. The solution must run in
Why this pattern: set membership checks. Store every number in a set, then walk each run forward.
Key Insight: only start counting at the beginning of a run. A number starts a run when its predecessor is not in the set. Every run gets counted exactly once, which is what keeps the time linear.
Visual:
graph LR
A["nums = [100,4,200,1,3,2]"] --> B["set = {1,2,3,4,100,200}"]
B --> C["1: 0 not in set. Start. Run is 1,2,3,4. Length 4"]
C --> D["100: 99 not in set. Start. Run is 100. Length 1"]
D --> E["200: 199 not in set. Start. Run is 200. Length 1"]
E --> F["longest = 4"]
Code:
var longestConsecutive = function(nums) {
const numSet = new Set(nums);
let longest = 0;
for (const num of numSet) {
// Only begin at the start of a run. If num - 1
// exists, this number belongs to a longer run
// and will be counted when that run starts.
if (!numSet.has(num - 1)) {
let current = num;
let length = 1;
while (numSet.has(current + 1)) {
current++;
length++;
}
longest = Math.max(longest, length);
}
}
return longest;
};The linear time depends on starting only at run beginnings. Each number is visited at most twice: once when checking whether it is a run start, and once while its own run is being walked. The while loop never re-walks a run because only the first element of a run enters the loop. Space is
Hard Problems
7. LRU Cache
LeetCode 146 | Difficulty: Hard
Brief: design a cache with
Why this pattern: the map alone cannot track order. A doubly linked list provides
Key Insight: the head of the list is the most recently used entry and the tail is the least recently used. get and put both move a node to the head. When the cache is full, the tail node is removed from both the list and the map.
Visual:
graph TD
A["get(key)"] --> B{"key in map?"}
B -->|No| C["return -1"]
B -->|Yes| D["move node to head"]
D --> E["return value"]
F["put(key, value)"] --> G{"key in map?"}
G -->|Yes| H["update value, move to head"]
G -->|No| I{"at capacity?"}
I -->|Yes| J["remove tail node and its map entry"]
I -->|No| K["insert new node at head"]
J --> K
Code:
class Node {
constructor(key, value) {
this.key = key;
this.value = value;
this.prev = null;
this.next = null;
}
}
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
this.head = new Node(0, 0);
this.tail = new Node(0, 0);
// Dummy head and tail keep the list edges
// from needing special cases on insert and remove.
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key) {
if (!this.cache.has(key)) return -1;
const node = this.cache.get(key);
this._remove(node);
this._addToHead(node);
return node.value;
}
put(key, value) {
if (this.cache.has(key)) {
const node = this.cache.get(key);
node.value = value;
this._remove(node);
this._addToHead(node);
} else {
if (this.cache.size >= this.capacity) {
const tail = this._removeTail();
// The map and the list must agree. Evicting
// from the list without the map leaks the key.
this.cache.delete(tail.key);
}
const node = new Node(key, value);
this._addToHead(node);
this.cache.set(key, node);
}
}
_remove(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
_addToHead(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
}
_removeTail() {
const node = this.tail.prev;
this._remove(node);
return node;
}
}The invariant that holds everything together is that the map and the list always describe the same entries. When a node moves, no map change is needed, because the map points at the node rather than at a position. When a node is evicted, its map entry goes with it. get and put are each
These seven problems cover the full range of hash table work. Start with the membership check in Contains Duplicate, add the complement search in Two Sum, and build up to the combined structure in LRU Cache. By the end, you should recognize when a problem is asking for a set and when it needs a full map.