Design HashSet


Problem Statement

Design a HashSet without using any built-in hash table libraries (set, unordered_set or even bitset)

Implement MyHashSet class:

Constraints:

Let nn be the input size and kk be the number of unique keys in the input

1. Brute

Space and Time Complexity:

We could maintain a sorted array and binary search over it in O(logn)O(logn) time but the shifting of elements during add, remove operations would still take be O(n)O(n) time at worst

2. Boolean Array

Since we know that each input lies in the range [0,106][0, 10^6], we can use a fixed sized array of that many booleans. Being true means element equal to that index exists and false means it doesn’t. Note that vector<bool> does not guarantee individual element access so we’ll be using bitset<N> and its functions

class MyHashSet {
bitset<1'000'001> state;
public:
MyHashSet() {}
bool contains(int key) {
return state.test(key);
}
void add(int key) {
state.set(key);
}
void remove(int key) {
state.reset(key);
}
};

Note that LeetCode does not allow bitset to be used for this question to be accepted as a solution. So you could use vector<char> with 0/1 character values or vector<int> with 0/1 integer values (vector<short> too)

Space and Time Complexity:


3. Buckets of LinkedLists

Hashing and LinkedList buckets

Some important considerations:

C++ Implementation for HashSet via Buckets of LinkedLists
Buckets of LinkedLists
class MyHashSet {
private:
struct Node {
int val;
Node* next;
Node (int _val, Node* _next = nullptr) : val(_val), next(_next) {}
};
const int M = 10'007; // 10_007 or 1_000_003 are primes closest to constraints
inline int getHash (const int x) { return x % M; }
vector<Node*> buckets;
public:
// Init M buckets with each bucket ptr to null
MyHashSet () : buckets(M, nullptr) {}
bool contains (int key) {
int hashedKey = getHash(key);
Node* itr = buckets[hashedKey];
if (!itr) { // Empty Bucket
return false; // No items would be found
}
while (itr) {
if (itr->val == key) return true; // Key FOUND
itr = itr->next; // Advance pointer ahead
}
return false; // NOT FOUND ANYWHERE
}
/* Insert new item at start of bucket for fast O(1) insertion */
void add (int key) {
int hashedKey = getHash(key);
if (!buckets[hashedKey]) { // Bucket Empty
buckets[hashedKey] = new Node(key); // Insert at start
return;
}
// Traverse bucket items to see if already exists
Node* itr = buckets[hashedKey];
while (itr) {
if (itr->val == key) return; // Key Exists. SKIP
itr = itr->next; // Advance pointer ahead
}
// Create new item and insert it at start of bucket
Node* newEntry = new Node(key, buckets[hashedKey]);
buckets[hashedKey] = newEntry;
}
void remove (int key) {
int hashedKey = getHash(key);
if (!buckets[hashedKey]) { // Bucket Empty. SKIP
return;
}
if (buckets[hashedKey]->val == key) { // Key present as first item
Node* aheadPart = buckets[hashedKey]->next; // Track what comes after targeted deletion node
delete buckets[hashedKey];
buckets[hashedKey] = aheadPart; // Update node before target node to point to next part
return; // Deletion complete. EXIT
}
// Traverse bucket items maintaining two pointers
Node *prev = buckets[hashedKey], *curr = buckets[hashedKey]->next;
while (curr) {
if (curr->val == key) {
Node* aheadPart = curr->next; // Track what comes after targeted deletion node
delete curr;
prev->next = aheadPart; // Update node before target node to point to next part
return; // Deletion complete. EXIT
}
// Advance pointers ahead
prev = curr;
curr = curr->next;
}
}
/* Deallocate Heap memory allocated through "new" */
~MyHashSet () {
// Iterate over each bucket ptr
for (Node* itr : buckets) {
// Start deleting nodes from the front of bucket till nullptr remains
while (itr) {
Node* aheadPart = itr->next;
delete itr;
itr = aheadPart;
}
}
}
};

Space and Time Complexity:

Load Factor and Re-Hashing

The load factor measures how densely populated the hash table is. It is defined as the ratio of the number of unique keys stored to the number of available buckets:

α=kM\alpha = \frac{k}{M}

A higher load factor generally means longer bucket chains and more work during lookups, insertions, and removals. A lower load factor generally reduces collisions but requires more memory for the bucket array.

Rehashing involves allocating a new bucket array, typically with more buckets, and redistributing the existing keys into their appropriate buckets. This is necessary when the table becomes too densely populated according to its chosen load-factor threshold. Rehashing is an expensive operation as the entire existing table is reconstructed, so it should be done only when necessary. If the table grows geometrically (like doubling the number of buckets), the cost of rehashing can be amortized across insertions, allowing insertion to remain amortized O(1)O(1) under typical hashing assumptions.

For separate chaining, the load factor can exceed (1), since multiple keys can be stored in the same bucket. The appropriate threshold depends on the implementation and its performance requirements.

4. Buckets of BSTs

Instead of storing the elements in each bucket as nodes of a LinkedList, we can organize them as nodes of a Binary Search Tree (BST). Hashing still determines which bucket an incoming key maps to.

The sorted nature of BST nodes in each of the buckets allows for fast lookups via binary search. If bb is the average number of nodes in a bucket, then all the contains, add, remove operations would take O(logb)O(logb) on average and O(logk)O(logk) at worst.

The space complexity is O(M+k)O(M+k) just like in the LinkedList approach


Variant: Design HashMap

LeetCode link: Design a Hashmap

The boolean array approach won’t work here as we need to store the value associated with the key too.

C++ Implementation for HashMap via Buckets of LinkedLists
Hashmap via Buckets of LinkedLists
class MyHashMap {
struct Node {
int id;
int val;
Node* next;
Node (int _id, int _val, Node* _next = nullptr) : id(_id), val(_val), next(_next) {}
};
static const int M = 10'007; // Choose prime number as size to minimize collisions
inline int getHash (const int x) const { return x % M; }
vector<Node*> buckets;
public:
MyHashMap () : buckets(M, nullptr) {}
int get (int key) {
int hashedKey = getHash(key);
if (!buckets[hashedKey]) return -1; // Bucket Empty. NOT EXISTS
Node* itr = buckets[hashedKey];
// Traverse over bucket items
while (itr) {
if (itr->id == key) return itr->val; // FOUND. Return VALUE
itr = itr->next;
}
return -1; // NOT FOUND
}
void put (int key, int value) {
int hashedKey = getHash(key);
if (!buckets[hashedKey]) { // Bucket Empty
buckets[hashedKey] = new Node(key, value);
return;
}
Node* itr = buckets[hashedKey];
while (itr) {
if (itr->id == key) { // Key already exists. Update Value
itr->val = value;
return;
}
itr = itr->next;
}
// Key nowhere found after traversing whole bucket. Insert at Start
Node* aheadPart = buckets[hashedKey];
buckets[hashedKey] = new Node(key, value, aheadPart);
}
void remove (int key) {
int hashedKey = getHash(key);
if (!buckets[hashedKey]) return; // Bucket empty
if (buckets[hashedKey]->id == key) { // First elem is to be removed
Node* aheadPart = buckets[hashedKey]->next;
delete buckets[hashedKey];
buckets[hashedKey] = aheadPart;
return; // Deletion complete. Exit
}
Node *prev = buckets[hashedKey], *curr = buckets[hashedKey]->next;
while (curr) {
if (curr->id == key) {
Node* aheadPart = curr->next;
delete curr;
prev->next = aheadPart;
return; // Deletion complete. Exit
}
prev = curr;
curr = curr->next;
}
}
};

Explore more