Design HashSet — Implementing Set Semantics Without Built-ins
Advertisement
Problem Statement
Design a HashSet without using any built-in hash set libraries.
Implement the MyHashSet class:
MyHashSet()initialises the object with an empty set.void add(int key)inserts the valuekeyinto the HashSet.bool contains(int key)returns whetherkeyis in the HashSet.void remove(int key)removes the valuekeyfrom the HashSet. Ifkeydoes not exist, do nothing.
Constraints:
0 <= key <= 10^6- At most
10^4calls will be made toadd,remove, andcontains.
Examples:
MyHashSet hashSet = new MyHashSet();
hashSet.add(1); // set = {1}
hashSet.add(2); // set = {1, 2}
hashSet.contains(1); // returns true
hashSet.contains(3); // returns false
hashSet.add(2); // set unchanged = {1, 2}
hashSet.remove(2); // set = {1}
hashSet.contains(2); // returns falseWhy This Problem Matters
Design HashSet is a companion problem to Design HashMap, and interviewers at Google and Amazon often ask both in sequence to see if candidates understand when to choose a simpler data structure. A HashSet has no values — just keys — which opens the door to a significantly more space-efficient implementation: the bit array.
For this problem's constraints (keys 0 to 10^6), a bit array of size 10^6 + 1 uses only about 125 KB of memory and supports O(1) add, remove, and contains with zero hash collisions. This is a lesson in problem-specific optimisation that interviewers love.
At Microsoft, this problem is used to assess candidates' awareness of trade-offs: "Which is better for this problem — a bit array or a hash table with chaining?" The correct answer depends on the density of the key space. For dense integer ranges (as here), the bit array wins. For sparse or non-integer keys, chaining wins.
Understanding the HashSet implementation also clarifies how languages implement set operations: Python's frozenset, Java's HashSet, and JavaScript's Set all have O(1) average-case operations through hashing, but with memory and load-factor trade-offs that you can now discuss intelligently.
The Core Insight
Approach 1 — Bit Array (best for this problem):
Since keys are bounded integers (0 to 10^6), allocate a boolean array of size 10^6 + 1. Each index corresponds to a key. add(key) sets arr[key] = true. remove(key) sets arr[key] = false. contains(key) returns arr[key].
This is O(1) exact time with no collisions. The trade-off is fixed memory usage of ~10^6 booleans (about 1 MB in Python, 125 KB as a true bit array).
Approach 2 — Chaining (general-purpose):
For general keys, use the same chaining approach as Design HashMap but without the value component. Each bucket holds a list of keys.
The bit array approach is strictly better for this problem's constraints and should be your default answer. Explaining the chaining approach as the general-purpose alternative shows breadth.
Visual Dry Run
Bit array approach (array of size 7 for illustration, real size = 10^6 + 1):
Initial state: [F, F, F, F, F, F, F] (all False)
| Operation | Index | Array after |
|---|---|---|
| add(1) | 1 | [F, T, F, F, F, F, F] |
| add(2) | 2 | [F, T, T, F, F, F, F] |
| contains(1) | 1 | arr[1] = True → true |
| contains(3) | 3 | arr[3] = False → false |
| add(2) | 2 | (already True, no change) |
| remove(2) | 2 | [F, T, F, F, F, F, F] |
| contains(2) | 2 | arr[2] = False → false |
Solution (Optimal)
Approach 1 — Bit Array (optimal for bounded integer keys):
class MyHashSet:
def __init__(self):
# Boolean array for keys 0 to 10^6
self.data = [False] * 1_000_001
def add(self, key: int) -> None:
self.data[key] = True
def remove(self, key: int) -> None:
self.data[key] = False
def contains(self, key: int) -> bool:
return self.data[key]class MyHashSet {
constructor() {
// Boolean array — Uint8Array for memory efficiency
this.data = new Uint8Array(1_000_001);
}
add(key) {
this.data[key] = 1;
}
remove(key) {
this.data[key] = 0;
}
contains(key) {
return this.data[key] === 1;
}
}Approach 2 — Chaining (general-purpose, works for any key type):
class MyHashSet:
def __init__(self):
self.size = 1009 # Prime to reduce clustering
self.buckets = [[] for _ in range(self.size)]
def _hash(self, key: int) -> int:
return key % self.size
def add(self, key: int) -> None:
bucket = self.buckets[self._hash(key)]
if key not in bucket:
bucket.append(key)
def remove(self, key: int) -> None:
h = self._hash(key)
self.buckets[h] = [k for k in self.buckets[h] if k != key]
def contains(self, key: int) -> bool:
return key in self.buckets[self._hash(key)]class MyHashSet {
constructor() {
this.size = 1009;
this.buckets = Array.from({ length: this.size }, () => []);
}
_hash(key) {
return key % this.size;
}
add(key) {
const bucket = this.buckets[this._hash(key)];
if (!bucket.includes(key)) bucket.push(key);
}
remove(key) {
const h = this._hash(key);
this.buckets[h] = this.buckets[h].filter(k => k !== key);
}
contains(key) {
return this.buckets[this._hash(key)].includes(key);
}
}Complexity Analysis:
Bit array approach:
- Time: O(1) exact — direct array index access.
- Space: O(max_key) — proportional to key range, not number of elements.
Chaining approach:
- Time: O(n/b) average per operation.
- Space: O(n + b).
Common Mistakes
- Not checking for duplicates before
add: If you append without checking,containswith a linear scan would still work, but the bucket grows larger than necessary. Always check first. - Using
key in listfor the check: For Python lists,inis O(n). For small buckets this is fine, but for large sets use a set of keys per bucket (which is what real hash sets do). - Memory over-allocation with bit array: Allocating
[False] * 10^6in Python is about 8 MB because Python booleans are full Python objects. Usingbytearray(10^6 + 1)orarray.array('b', ...)is more space-efficient. - Off-by-one in bit array size: Keys go up to 10^6, so the array needs size 10^6 + 1 (indices 0 through 10^6 inclusive).
- Thread safety: In concurrent environments, multiple threads calling
addandremovesimultaneously can corrupt the set. Use locking in production.
Follow-up Questions
- When would chaining be better than a bit array? (When keys are strings, objects, or large sparse integers where the bit array would use too much memory.)
- How would you implement a HashSet for string keys? (Use a hash function like polynomial rolling hash to map strings to integers, then apply chaining.)
- What is the load factor and why does it matter? (Load factor = n/b. When it exceeds ~0.75, performance degrades and rehashing is needed.)
- How would you implement set union, intersection, and difference efficiently? (Union: iterate one set, add each element to the other. Intersection/difference: iterate one, check membership in the other.)
- How is Python's
setimplemented internally? (Open addressing with compact arrays; resizes when load factor exceeds 2/3.) - What is a Bloom filter and how does it relate to HashSet? (A space-efficient probabilistic set that never has false negatives but may have false positives — used in databases and distributed systems.)
Key Takeaways
- LC 705 Design HashSet is the value-less twin of LC 706 — store keys only, no
putvalue orgetreturn. - For dense small-integer keys, a bit array gives O(1) ops with 1 bit per key — most space-efficient option.
- For general keys, use buckets-plus-chaining (or a list of lists) with prime bucket count and modular hashing.
addis idempotent (skip if exists),removeis a no-op when missing,containsreturns boolean — match Java/PythonSetsemantics.- Always pick a prime modulus (769 is the canonical choice) to reduce clustering for adversarial keys.
- A Bloom filter is the probabilistic relative — never returns false negatives but accepts a tunable false-positive rate for huge memory savings.
- Open addressing (Python
set) and chaining (JavaHashSet) are equivalent in average performance; choice depends on cache locality goals.
Related Problems
- [LC 705] Design HashSet — this exact problem.
- [LC 706] Design HashMap — companion problem, adds values to each key.
- [LC 217] Contains Duplicate — uses a set to detect duplicates in O(n).
- [LC 349] Intersection of Two Arrays — uses sets for O(n+m) intersection.
- [LC 202] Happy Number — uses a set to detect cycles.
- [LC 128] Longest Consecutive Sequence — uses a set for O(1) membership queries.
Advertisement