Hashing - DSA | Chapter: Open Addressing I: Linear Probing and Primary Clustering
This chapter explores the transition from Separate Chaining to Open Addressing as a collision resolution strategy in Hash Tables. We analyze the worst-case limitations of Separate Chaining, examine how modern languages optimize bucket structures, define the significance of the Load Factor, and walk through the mechanics, mathematical formulation, and performance pitfalls (specifically Primary Clustering) of Linear Probing.
1. The Limitations of Separate Chaining
In Separate Chaining, every index of the hash table points to a secondary data structure (typically a singly linked list) that stores all keys mapping to that same index. While simple to implement, this approach has a critical performance vulnerability in its worst-case scenario.
The Worst-Case Search Time
If we insert elements into a hash table, and our hash function maps all elements to the exact same index (for example, if all keys end with the digit 3 and our hash function is ), they will all be appended to the same linked list.
In this scenario:
- Insertion: Remains if we insert at the head of the linked list.
- Search/Deletion: Degenerates to because we must traverse the entire linked list sequentially to locate or remove a target element.
This degradation defeats the primary purpose of using a hash table, which is to achieve near average-time complexity for search, insertion, and deletion.
2. Optimizing Chaining with Self-Balancing Trees (AVL Trees)
To mitigate the worst-case search time of linked-list chaining, we can replace the linked list at each bucket with a self-balancing binary search tree, such as an AVL Tree or a Red-Black Tree.
Impact on Complexity
By using a self-balancing tree:
- The worst-case search, insertion, and deletion time within a highly congested bucket is reduced from to , where is the number of elements in that bucket.
Real-World Language Implementations
- Java (
java.util.HashMap): Java utilizes this optimization. When the number of elements in a single bucket exceeds a specific threshold (known as theTREEIFY_THRESHOLD, which is defaults to 8), the linked list is automatically converted ("treeified") into a Red-Black Tree. If the number of elements in the bucket decreases below a certain threshold (known as theUNTREEIFY_THRESHOLD, which is 6), it converts back to a linked list to save memory. - C++ (
std::unordered_map): Standard C++ libraries do not currently implement this treeification optimization. They continue to rely on singly linked lists for bucket chaining, meaning worst-case lookups can still degenerate to if a poor hash function is used.
3. Understanding the Load Factor ()
The Load Factor () is a fundamental metric used to measure how full a hash table is. It serves as a health indicator for the table and dictates when the table should be resized (rehashed).
Mathematical Definition
Where:
- = Total number of elements currently stored in the hash table.
- = Size of the hash table (total number of available slots/buckets).
Practical Example
Consider a hash table of size . If we insert elements into this table:
A load factor of indicates that the table is full.
Note: In Separate Chaining, the load factor can exceed because multiple elements can be chained at each index. However, in Open Addressing, because every slot can hold at most one element, the load factor must always satisfy .
4. Introduction to Open Addressing & Linear Probing
When separate chaining is undesirable due to the memory overhead of pointers or poor cache locality, Open Addressing is used. In Open Addressing, all elements are stored directly within the hash table array itself. No external structures (like linked lists or trees) are allowed.
The Core Rule of Open Addressing
Every slot in the hash table can hold at most one element. If a collision occurs at a calculated index, we must search ("probe") for another empty slot within the array according to a systematic sequence.
Linear Probing
Linear Probing is the simplest probing scheme in Open Addressing. When a collision occurs at index , we sequentially scan the subsequent indices one by one (wrapping around to the beginning of the array if we reach the end) until we find an empty slot.
5. Mathematical Formulation of Linear Probing
To formalize this sequential search, we define a probing hash function that takes two arguments: the key and the probe attempt number (starting at ).
The Probing Function
Where:
- is the key being inserted or searched.
- is the probe index/attempt number ().
- is the base hash function (e.g., ).
- is the size of the hash table.
How the Probes Resolve
- First Attempt (): We check the natural hash location:
- Second Attempt (): If a collision occurs, we check the next immediate slot:
- Third Attempt (): If that is also occupied, we check:
- This linear progression continues until an empty slot is found or the entire table has been probed.
6. Step-by-Step Execution Trace
Let us trace the insertion of a dataset using Linear Probing.
Setup
- Table Size (): (Indices to )
- Base Hash Function:
- Dataset to Insert:
[98, 22, 34, 54, 64, 74]
Step-by-Step Insertion Flow
Empty Table (Size 10):
[ - , - , - , - , - , - , - , - , - , - ]
0 1 2 3 4 5 6 7 8 9
1. Insert 98
- Probe : . Slot is empty.
- Action: Place
98at index .
[ - , - , - , - , - , - , - , - , 98 , - ]
0 1 2 3 4 5 6 7 8 9
2. Insert 22
- Probe : . Slot is empty.
- Action: Place
22at index .
[ - , - , 22 , - , - , - , - , - , 98 , - ]
0 1 2 3 4 5 6 7 8 9
3. Insert 34
- Probe : . Slot is empty.
- Action: Place
34at index .
[ - , - , 22 , - , 34 , - , - , - , 98 , - ]
0 1 2 3 4 5 6 7 8 9
4. Insert 54
- Probe : . Collision! (Slot is occupied by
34). - Probe : . Slot is empty.
- Action: Place
54at index .
[ - , - , 22 , - , 34 , 54 , - , - , 98 , - ]
0 1 2 3 4 5 6 7 8 9
5. Insert 64
- Probe : . Collision! (Slot is occupied by
34). - Probe : . Collision! (Slot is occupied by
54). - Probe : . Slot is empty.
- Action: Place
64at index .
[ - , - , 22 , - , 34 , 54 , 64 , - , 98 , - ]
0 1 2 3 4 5 6 7 8 9
6. Insert 74
- Probe : . Collision! (Slot is occupied).
- Probe : . Collision! (Slot is occupied).
- Probe : . Collision! (Slot is occupied).
- Probe : . Slot is empty.
- Action: Place
74at index .
Final Table State:
[ - , - , 22 , - , 34 , 54 , 64 , 74 , 98 , - ]
0 1 2 3 4 5 6 7 8 9
7. The Phenomenon of Primary Clustering
While Linear Probing is simple and highly cache-friendly, it suffers from a severe performance bottleneck known as Primary Clustering.
What is Primary Clustering?
Primary Clustering is the tendency for occupied slots in a linearly probed hash table to form long, contiguous blocks (clusters) over time.
[ - , - , 22 , - , [ 34 , 54 , 64 , 74 ] , 98 , - ]
0 1 2 3 CLUSTERING 8 9
Why Does It Happen?
When keys hash to nearby or identical values, they compete for the same empty slots. As a cluster grows larger, the probability that a new key will hash into that cluster increases.
For instance, in our final table state above:
- Any new key hashing to index or will be forced to scan past the entire cluster to find the next available slot (which is index or ).
- This causes the lookup and insertion times to degrade from toward as the table fills up.
8. Complete C++ Implementation of Linear Probing
Below is a complete, production-grade C++ implementation of a Hash Table using Linear Probing. It handles insertions, searches, and deletions.
Crucial Implementation Detail: When deleting an element in a linearly probed hash table, we cannot simply set the slot to "empty" (
nullptror-1). Doing so would break the probe chain for subsequent elements that were inserted after a collision. Instead, we must use a special marker called a Tombstone (or dummy node) to indicate that an element was deleted but the probe chain should continue.
#include <iostream>
#include <vector>
class LinearProbingHashTable {
private:
enum SlotState { EMPTY, OCCUPIED, DELETED };
struct HashNode {
int key;
int value;
SlotState state;
HashNode() : key(-1), value(-1), state(EMPTY) {}
};
std::vector<HashNode> table;
int capacity;
int size;
int hashFunction(int key) const {
return std::abs(key) % capacity;
}
public:
explicit LinearProbingHashTable(int cap) : capacity(cap), size(0) {
table.resize(capacity);
}
// Insert key-value pair
bool insert(int key, int value) {
if (size >= capacity) {
std::cerr << "Hash Table is full!" << std::endl;
return false;
}
int hashIndex = hashFunction(key);
int i = 0;
// Find the first available slot (either EMPTY or DELETED)
while (table[(hashIndex + i) % capacity].state == OCCUPIED) {
// If key already exists, update its value
if (table[(hashIndex + i) % capacity].key == key) {
table[(hashIndex + i) % capacity].value = value;
return true;
}
i++;
if (i == capacity) return false; // Table fully searched
}
int targetIndex = (hashIndex + i) % capacity;
table[targetIndex].key = key;
table[targetIndex].value = value;
table[targetIndex].state = OCCUPIED;
size++;
return true;
}
// Search for a key
bool search(int key, int& value) const {
int hashIndex = hashFunction(key);
int i = 0;
while (table[(hashIndex + i) % capacity].state != EMPTY) {
if (table[(hashIndex + i) % capacity].state == OCCUPIED &&
table[(hashIndex + i) % capacity].key == key) {
value = table[(hashIndex + i) % capacity].value;
return true;
}
i++;
if (i == capacity) break; // Traversed the entire table
}
return false; // Key not found
}
// Delete a key using Tombstone mechanism (DELETED state)
bool remove(int key) {
int hashIndex = hashFunction(key);
int i = 0;
while (table[(hashIndex + i) % capacity].state != EMPTY) {
if (table[(hashIndex + i) % capacity].state == OCCUPIED &&
table[(hashIndex + i) % capacity].key == key) {
table[(hashIndex + i) % capacity].state = DELETED;
size--;
return true;
}
i++;
if (i == capacity) break;
}
return false; // Key not found
}
// Display the current state of the table
void display() const {
for (int i = 0; i < capacity; i++) {
if (table[i].state == OCCUPIED) {
std::cout << "Index " << i << ": [" << table[i].key << " -> " << table[i].value << "]\n";
} else if (table[i].state == DELETED) {
std::cout << "Index " << i << ": [DELETED (Tombstone)]\n";
} else {
std::cout << "Index " << i << ": [EMPTY]\n";
}
}
}
};
int main() {
LinearProbingHashTable ht(10);
ht.insert(98, 1001);
ht.insert(22, 1002);
ht.insert(34, 1003);
ht.insert(54, 1004);
ht.insert(64, 1005);
ht.insert(74, 1006);
std::cout << "--- Initial Hash Table State ---" << std::endl;
ht.display();
std::cout << "\n--- Deleting Key 54 ---" << std::endl;
ht.remove(54);
ht.display();
std::cout << "\n--- Searching for Key 64 ---" << std::endl;
int val;
if (ht.search(64, val)) {
std::cout << "Found Key 64 with Value: " << val << std::endl;
} else {
std::cout << "Key 64 not found!" << std::endl;
}
return 0;
}
9. Comparison: Separate Chaining vs. Linear Probing
10. Summary and Key Takeaways
- Chaining Worst-Case: Separate Chaining has a worst-case lookup complexity of if all keys hash to the same index.
- Treeification: Modern languages like Java optimize Separate Chaining by converting long linked lists into self-balancing Red-Black trees when a bucket's size exceeds a threshold, reducing the worst-case lookup to . C++ standard libraries do not currently implement this.
- Load Factor: Defined as . It represents the density of the hash table and is used to trigger rehashing operations.
- Open Addressing: Stores all elements directly within the hash table array, eliminating pointer overhead and maximizing CPU cache efficiency.
- Linear Probing Formula: Uses the probe sequence to find the next available slot sequentially.
- Primary Clustering: The major drawback of Linear Probing, where contiguous blocks of occupied slots build up, causing search and insertion times to degrade toward .
- Tombstone Deletion: Deleting an element in Linear Probing requires marking the slot as
DELETED(rather thanEMPTY) to prevent breaking the probe chain for elements inserted further down the sequence.