Hashing is a fundamental concept in computer science and data structures, widely used for efficient data retrieval. A hash function maps input data of arbitrary size to fixed-size values, typically integers or strings. However, due to the pigeonhole principle, it's inevitable that different inputs can produce the same hash value, known as a hash collision. This tutorial will explore various techniques to handle hash collisions effectively.
A hash collision occurs when two distinct keys generate the same hash code. In a perfect world, a hash function would map each unique input to a unique output, but this is practically impossible due to the finite range of possible hash values and the potentially infinite number of inputs.
Chained hashing is one of the most common methods to handle collisions. Each bucket in the hash table contains a linked list (or any other data structure like a balanced tree) that stores all keys hashing to the same index.
Here's a simple implementation of chained hashing using Python:
class Node:
def __init__(self, key):
self.key = key
self.next = None
class ChainedHashTable:
def __init__(self, size=10):
self.size = size
self.table = [None] * self.size
def _hash(self, key):
return hash(key) % self.size
def insert(self, key):
index = self._hash(key)
if not self.table[index]:
self.table[index] = Node(key)
else:
current = self.table[index]
while current.next:
current = current.next
current.next = Node(key)
def search(self, key):
index = self._hash(key)
current = self.table[index]
while current:
if current.key == key:
return True
current = current.next
return False
def delete(self, key):
index = self._hash(key)
current = self.table[index]
previous = None
while current:
if current.key == key:
if previous:
previous.next = current.next
else:
self.table[index] = current.next
return True
previous = current
current = current.next
return False
# Example usage
hash_table = ChainedHashTable()
hash_table.insert(10)
hash_table.insert(20)
print(hash_table.search(10)) # Output: True
print(hash_table.delete(10))
print(hash_table.search(10)) # Output: False
Open addressing is another method to handle collisions where the algorithm searches for the next available slot in the hash table using a probing sequence. Common probing techniques include linear probing, quadratic probing, and double hashing.
In linear probing, if a collision occurs at index i, the algorithm checks the next consecutive indices (i+1) % size, (i+2) % size, and so on, until an empty slot is found.
class OpenAddressHashTable:
def __init__(self, size=10):
self.size = size
self.table = [None] * self.size
def _hash(self, key):
return hash(key) % self.size
def insert(self, key):
index = self._hash(key)
original_index = index
while self.table[index] is not None:
if self.table[index] == key: # Key already exists
return False
index = (index + 1) % self.size
if index == original_index: # Table is full
return False
self.table[index] = key
return True
def search(self, key):
index = self._hash(key)
original_index = index
while self.table[index] is not None:
if self.table[index] == key:
return True
index = (index + 1) % self.size
if index == original_index:
break
return False
def delete(self, key):
index = self._hash(key)
original_index = index
while self.table[index] is not None:
if self.table[index] == key:
self.table[index] = None # Mark as deleted or use a special value
return True
index = (index + 1) % self.size
if index == original_index:
break
return False
# Example usage
hash_table = OpenAddressHashTable()
hash_table.insert(10)
hash_table.insert(20)
print(hash_table.search(10)) # Output: True
print(hash_table.delete(10))
print(hash_table.search(10)) # Output: False
Quadratic probing uses a quadratic function to determine the next slot, reducing clustering compared to linear probing.
class QuadraticProbingHashTable:
def __init__(self, size=10):
self.size = size
self.table = [None] * self.size
def _hash(self, key):
return hash(key) % self.size
def insert(self, key):
index = self._hash(key)
original_index = index
i = 0
while self.table[index] is not None:
if self.table[index] == key: # Key already exists
return False
i += 1
index = (original_index + i * i) % self.size
if index == original_index: # Table is full
return False
self.table[index] = key
return True
def search(self, key):
index = self._hash(key)
original_index = index
i = 0
while self.table[index] is not None:
if self.table[index] == key:
return True
i += 1
index = (original_index + i * i) % self.size
if index == original_index:
break
return False
def delete(self, key):
index = self._hash(key)
original_index = index
i = 0
while self.table[index] is not None:
if self.table[index] == key:
self.table[index] = None # Mark as deleted or use a special value
return True
i += 1
index = (original_index + i * i) % self.size
if index == original_index:
break
return False
# Example usage
hash_table = QuadraticProbingHashTable()
hash_table.insert(10)
hash_table.insert(20)
print(hash_table.search(10)) # Output: True
print(hash_table.delete(10))
print(hash_table.search(10)) # Output: False
Double hashing uses two hash functions to determine the next slot, providing a more uniform distribution compared to linear and quadratic probing.
class DoubleHashingHashTable:
def __init__(self, size=10):
self.size = size
self.table = [None] * self.size
def _hash1(self, key):
return hash(key) % self.size
def _hash2(self, key):
return 1 + (hash(key) % (self.size - 1))
def insert(self, key):
index = self._hash1(key)
original_index = index
step = self._hash2(key)
while self.table[index] is not None:
if self.table[index] == key: # Key already exists
return False
index = (index + step) % self.size
if index == original_index: # Table is full
return False
self.table[index] = key
return True
def search(self, key):
index = self._hash1(key)
original_index = index
step = self._hash2(key)
while self.table[index] is not None:
if self.table[index] == key:
return True
index = (index + step) % self.size
if index == original_index:
break
return False
def delete(self, key):
index = self._hash1(key)
original_index = index
step = self._hash2(key)
while self.table[index] is not None:
if self.table[index] == key:
self.table[index] = None # Mark as deleted or use a special value
return True
index = (index + step) % self.size
if index == original_index:
break
return False
# Example usage
hash_table = DoubleHashingHashTable()
hash_table.insert(10)
hash_table.insert(20)
print(hash_table.search(10)) # Output: True
print(hash_table.delete(10))
print(hash_table.search(10)) # Output: False
Hash collision handling is crucial for maintaining efficient hash table operations. Both chained hashing and open addressing have their strengths and weaknesses, and the choice between them depends on specific requirements such as memory usage, performance considerations, and implementation complexity. By understanding these techniques and best practices, you can effectively manage hash collisions and optimize your data structures for better performance.