A hash function maps an input from a large domain to a smaller, fixed-size range. Formally, h : U → { 0 , 1 , … , m − 1 } h: U \to \{0, 1, \ldots, m-1\} h : U → { 0 , 1 , … , m − 1 } where U U U is the universe of possible keys and m m m is the table Size. The quality of a hash function determines the performance of every data structure built on top Of it.
Property Definition Why It Matters Deterministic Same input always produces same output Lookups must find the same bucket as inserts Uniform Each output is equally likely: P ( h ( x ) = i ) = 1 / m P(h(x) = i) = 1/m P ( h ( x ) = i ) = 1/ m for all i i i Minimises collisions Avalanche Flipping any input bit changes each output bit with probability ≈ 0.5 \approx 0.5 ≈ 0.5 Small input changes produce unpredictable output changes Efficient Computable in O ( k ) O(k) O ( k ) where k k k is the key length Hash computation should not dominate lookup cost Reversible (For non-cryptographic use) Given a hash value, finding a preimage should not be easy Prevents intentional collision attacks
The strict avalanche criterion requires that for any single-bit change in the input, each output bit Flips with probability exactly 0.5. This is a necessary condition for the hash function to be “random-looking.” A weaker but still useful property is that flipping any input bit changes the Output value significantly (not just one output bit).
A perfectly uniform hash function distributes n n n keys across m m m buckets so that each bucket Contains approximately n / m n/m n / m keys. In practice, we measure uniformity by hashing a large sample of Inputs and checking that the chi-squared statistic of the bucket distribution is close to what we Would expect from a truly random distribution.
def uniformity_test ( hash_func , keys , num_buckets ):
Test hash function uniformity with chi-squared statistic.
Returns (chi_squared, p_value) — high p_value means good uniformity.
buckets = [ 0 ] * num_buckets
buckets[hash_func(key) % num_buckets] += 1
expected = len (keys) / num_buckets
chi_sq = sum ((b - expected) ** 2 / expected for b in buckets)
# Degrees of freedom = num_buckets - 1
# For a good hash, chi_sq should be close to degrees of freedom
h ( k ) = ⌊ m ⋅ ( k ⋅ A m o d 1 ) ⌋ h(k) = \lfloor m \cdot (k \cdot A \bmod 1) \rfloor h ( k ) = ⌊ m ⋅ ( k ⋅ A mod 1 )⌋
Where A A A is a constant in ( 0 , 1 ) (0, 1) ( 0 , 1 ) and m m m is the table size. Knuth recommends A = ( 5 − 1 ) / 2 ≈ 0.6180339887 A = (\sqrt{5} - 1) / 2 \approx 0.6180339887 A = ( 5 − 1 ) /2 ≈ 0.6180339887 . This avoids the problem of poor distribution when the Table size and key values share common factors.
def multiplicative_hash ( k , m , A =0x 9E3779B9 ):
Multiplicative hash using a fixed-point approximation of golden ratio.
A = 2^32 / golden_ratio, common choice in practice.
return (k >> ( 32 - int (m).bit_length())) % m
For general-purpose hashing of integers, bit-mixing functions are preferred. These scramble the bits Of the input so that small changes in the input produce large changes in the output.
Fast integer hash function. Excellent avalanche properties.
Used as the default in Java"s SplittableRandom.
x = (x + 0x 9E3779B97F4A7C15 ) & 0x FFFFFFFFFFFFFFFF
x = (x ^ (x >> 30 )) * 0x BF58476D1CE4E5B9 & 0x FFFFFFFFFFFFFFFF
x = (x ^ (x >> 27 )) * 0x 94D049BB133111EB & 0x FFFFFFFFFFFFFFFF
h ( s ) = ( ∑ i = 0 k − 1 s [ i ] ⋅ p k − 1 − i ) m o d m h(s) = \left(\sum_{i=0}^{k-1} s[i] \cdot p^{k-1-i}\right) \bmod m h ( s ) = ( ∑ i = 0 k − 1 s [ i ] ⋅ p k − 1 − i ) mod m
Where p p p is a prime (commonly 31, 37, or 257) and m m m is 2 64 2^{64} 2 64 (using unsigned integer Overflow). This is the basis for Java’s String.hashCode() and many other implementations.
def polynomial_hash ( s , p = 31 , mod = ( 1 << 64 )):
Polynomial rolling hash for strings.
Java uses p=31, mod=2^31-1.
h = (h * p + ord (c)) % mod
This is adequate for hash tables but unsuitable for cryptographic purposes. The choice of 31 is Historical and largely arbitrary — any odd prime works reasonably well.Fowler-Noll-Vo is a non-cryptographic hash popular in systems programming. FNV-1a XORs the input Byte with the hash before multiplying, which gives slightly better avalanche than FNV-1 (which Multiplies first).
Used in many systems: DNS, BIND, Apache, etc.
FNV_OFFSET = 14695981039346656037
FNV_PRIME = 1099511628211
h = (h * FNV_PRIME ) & 0x FFFFFFFFFFFFFFFF
When the universe of keys U U U is small, we can use an array of size ∣ U ∣ |U| ∣ U ∣ where index k k k stores the Element with key k k k . This gives O ( 1 ) O(1) O ( 1 ) worst-case insert, delete, and lookup, but wastes memory When ∣ U ∣ |U| ∣ U ∣ is much larger than the actual number of keys n n n .
Operation Time Space Insert O ( 1 ) O(1) O ( 1 ) O ( ∥ U ∥ ) O(\|U\|) O ( ∥ U ∥ ) Delete O ( 1 ) O(1) O ( 1 ) O ( ∥ U ∥ ) O(\|U\|) O ( ∥ U ∥ ) Lookup O ( 1 ) O(1) O ( 1 ) O ( ∥ U ∥ ) O(\|U\|) O ( ∥ U ∥ )
A hash table uses a hash function to map keys to indices in an array of size m m m ( m ≈ n m \approx n m ≈ n ). The space is O ( n ) O(n) O ( n ) instead of O ( ∣ U ∣ ) O(|U|) O ( ∣ U ∣ ) At the cost of handling collisions (when Two keys hash to the same index).
Hash table with separate chaining.
Time (amortised): O(1) insert, delete, lookup
def __init__ ( self , capacity = 16 , load_factor = 0.75 ):
self .load_factor = load_factor
self .buckets = [[] for _ in range (capacity)]
return hash (key) % self .capacity
def insert ( self , key , value ):
for i, (k, v) in enumerate ( self .buckets[idx]):
self .buckets[idx][i] = (key, value)
self .buckets[idx].append((key, value))
if self .size / self .capacity > self .load_factor:
self ._resize( self .capacity * 2 )
for k, v in self .buckets[idx]:
for i, (k, v) in enumerate ( self .buckets[idx]):
def _resize ( self , new_capacity ):
old_buckets = self .buckets
self .capacity = new_capacity
self .buckets = [[] for _ in range (new_capacity)]
for bucket in old_buckets:
for key, value in bucket:
Each bucket is a linked list (or dynamic array) of all key-value pairs that hash to that bucket. On A lookup, compute the hash, then scan the chain for the key.
The load factor is α = n / m \alpha = n / m α = n / m where n n n is the number of elements and m m m is the table size.
Statistic Value Expected chain length α \alpha α Unsuccessful search (average) O ( α ) O(\alpha) O ( α ) comparisonsSuccessful search (average) O ( 1 + α / 2 ) O(1 + \alpha / 2) O ( 1 + α /2 ) comparisonsWith chaining and α < 1 \alpha \lt{1} α < 1 O ( 1 ) O(1) O ( 1 ) amortised per operation
$\alpha \le 0.75$ (the default for Java `HashMap` and Python `dict`). When $\alpha$ exceeds the Threshold, resize the table and rehash all elements.In practice, most modern hash table implementations use dynamic arrays (small ones) instead of Linked lists for chains. This is because:
Cache locality : small arrays are contiguous in memoryMemory overhead : no per-element pointer overheadMemory allocator overhead : one allocation per bucket instead of one per elementWhen chains get long, Java converts them to balanced trees (since Java 8). Python uses a single Contiguous array for all entries (a compact hash table).
All elements are stored directly in the hash table array (no separate data structures). When a Collision occurs, we probe for the next available slot using a deterministic probe sequence.
h ( k , i ) = ( h ′ ( k ) + i ) m o d m h(k, i) = (h'(k) + i) \bmod m h ( k , i ) = ( h ′ ( k ) + i ) mod m
The simplest open addressing scheme: on collision at index h ′ ( k ) h'(k) h ′ ( k ) Try h ′ ( k ) + 1 h'(k)+1 h ′ ( k ) + 1 , h ′ ( k ) + 2 h'(k)+2 h ′ ( k ) + 2 Etc.
class LinearProbingHashTable :
Open addressing with linear probing.
Time (amortised): O(1) insert, delete, lookup
Space: O(m) where m >= n / load_factor
def __init__ ( self , capacity = 16 , load_factor = 0.5 ):
self .load_factor = load_factor
self .keys = [ None ] * capacity
self .values = [ None ] * capacity
return hash (key) % self .capacity
"""Generate probe sequence for key."""
idx = (idx + 1 ) % self .capacity
def insert ( self , key , value ):
if self .size >= self .load_factor * self .capacity:
self ._resize( self .capacity * 2 )
for idx in self ._probe(key):
if self .keys[idx] is None or self .keys[idx] is self . DELETED :
if self .keys[idx] == key:
for idx in self ._probe(key):
if self .keys[idx] is None :
if self .keys[idx] == key:
for idx in self ._probe(key):
if self .keys[idx] is None :
if self .keys[idx] == key:
self .keys[idx] = self . DELETED
Primary clustering : linear probing suffers from primary clustering — when a cluster of occupied Slots forms, new keys that hash into or near the cluster extend it. The expected number of probes For an unsuccessful search with linear probing is approximately 1 2 ( 1 + 1 ( 1 − α ) 2 ) \frac{1}{2}(1 + \frac{1}{(1-\alpha)^2}) 2 1 ( 1 + ( 1 − α ) 2 1 ) .
h ( k , i ) = ( h ′ ( k ) + c 1 ⋅ i + c 2 ⋅ i 2 ) m o d m h(k, i) = (h'(k) + c_1 \cdot i + c_2 \cdot i^2) \bmod m h ( k , i ) = ( h ′ ( k ) + c 1 ⋅ i + c 2 ⋅ i 2 ) mod m
Reduces primary clustering by probing at increasing distances. However, it can suffer from secondary clustering — keys that hash to the same initial slot follow the same probe sequence.
Guarantee of finding an empty slot : if the table size m m m is prime and the load factor is less Than 0.5, quadratic probing with c 1 = c 2 = 1 / 2 c_1 = c_2 = 1/2 c 1 = c 2 = 1/2 will always find an empty slot.
h ( k , i ) = ( h 1 ( k ) + i ⋅ h 2 ( k ) ) m o d m h(k, i) = (h_1(k) + i \cdot h_2(k)) \bmod m h ( k , i ) = ( h 1 ( k ) + i ⋅ h 2 ( k )) mod m
Uses a second hash function h 2 ( k ) h_2(k) h 2 ( k ) to determine the probe step size. This eliminates both primary And secondary clustering. The table size m m m and the step size h 2 ( k ) h_2(k) h 2 ( k ) should be relatively prime For the probe sequence to visit all slots.
class DoubleHashingHashTable :
def __init__ ( self , capacity = 16 ):
self .keys = [ None ] * capacity
self .values = [ None ] * capacity
return hash (key) % self .capacity
return 1 + ( hash (key) >> 16 ) % ( self .capacity - 1 )
for i in range ( self .capacity):
yield (h1 + i * h2) % self .capacity
def insert ( self , key , value ):
if self .size >= self .capacity * 0.75 :
for idx in self ._probe(key):
if self .keys[idx] is None or self .keys[idx] is self . DELETED :
if self .keys[idx] == key:
for idx in self ._probe(key):
if self .keys[idx] is None :
if self .keys[idx] == key:
for idx in self ._probe(key):
if self .keys[idx] is None :
if self .keys[idx] == key:
self .keys[idx] = self . DELETED
Probe Method Clustering Expected Probes (unsuccessful) Cache Performance Linear Primary 1 2 ( 1 + 1 ( 1 − α ) 2 ) \frac{1}{2}(1 + \frac{1}{(1-\alpha)^2}) 2 1 ( 1 + ( 1 − α ) 2 1 ) Best Quadratic Secondary 1 1 − α \frac{1}{1-\alpha} 1 − α 1 Good Double hashing None 1 1 − α \frac{1}{1-\alpha} 1 − α 1 Moderate
Robin Hood hashing reduces the variance of probe lengths. When inserting, if the new element has Probed more times than the element at the current slot (i.e., the new element is “poorer”), swap Them. The new element continues probing from the swapped position.
The key insight: instead of minimising the average probe length, Robin Hood minimises the variance, Which means worst-case lookups are much faster. The probe length of an element never exceeds O ( log n ) O(\log n) O ( log n ) with high probability.
def robin_hood_insert ( table , key , value , hashes ):
Insert into Robin Hood hash table.
Keeps track of probe distance for each element.
if table.keys[idx] is None :
table.values[idx] = value
table.probe_dists[idx] = probe_dist
if table.probe_dists[idx] < probe_dist:
table.keys[idx], key = key, table.keys[idx]
table.values[idx], value = value, table.values[idx]
table.probe_dists[idx], probe_dist = probe_dist, table.probe_dists[idx]
idx = (idx + 1 ) % table.capacity
Cuckoo hashing uses two hash functions and two arrays (or one array split into two halves). Each key Is stored at either h 1 ( k ) h_1(k) h 1 ( k ) or h 2 ( k ) h_2(k) h 2 ( k ) . On collision, the existing element is “kicked out” to its Alternative position. If a cycle is detected, the table is rebuilt with new hash functions.
Properties:
Property Value Lookup O ( 1 ) O(1) O ( 1 ) worst-case (2 probes)Insert (expected) O ( 1 ) O(1) O ( 1 ) amortisedInsert (worst case) O ( n ) O(n) O ( n ) during rehashSpace overhead Maximum load factor ≈ 0.5 \approx 0.5 ≈ 0.5 Cache performance Moderate (two memory accesses)
Cuckoo hashing with two hash tables.
Lookup is O(1) worst-case (max 2 probes).
def __init__ ( self , capacity = 16 ):
self .table1_keys = [ None ] * capacity
self .table1_values = [ None ] * capacity
self .table2_keys = [ None ] * capacity
self .table2_values = [ None ] * capacity
self .max_displacements = 500
return hash (key) % self .capacity
return ( hash (key) >> 16 ) % self .capacity
if self .table1_keys[idx1] == key:
return self .table1_values[idx1]
if self .table2_keys[idx2] == key:
return self .table2_values[idx2]
def insert ( self , key , value ):
for _ in range ( self .max_displacements):
if self .table1_keys[idx1] is None :
self .table1_keys[idx1] = key
self .table1_values[idx1] = value
key, self .table1_keys[idx1] = self .table1_keys[idx1], key
value, self .table1_values[idx1] = self .table1_values[idx1], value
if self .table2_keys[idx2] is None :
self .table2_keys[idx2] = key
self .table2_values[idx2] = value
key, self .table2_keys[idx2] = self .table2_keys[idx2], key
value, self .table2_values[idx2] = self .table2_values[idx2], value
return self .insert(key, value)
old_keys1 = [(k, v) for k, v in zip ( self .table1_keys, self .table1_values) if k is not None ]
old_keys2 = [(k, v) for k, v in zip ( self .table2_keys, self .table2_values) if k is not None ]
self .table1_keys = [ None ] * self .capacity
self .table1_values = [ None ] * self .capacity
self .table2_keys = [ None ] * self .capacity
self .table2_values = [ None ] * self .capacity
for k, v in old_keys1 + old_keys2:
In distributed systems, when you add or remove a server from a hash ring, you want to minimise the Number of keys that need to be remapped. Traditional modulo hashing (h ( k e y ) m o d n h(key) \bmod n h ( k ey ) mod n ) requires Remapping nearly all keys when n n n changes.
Consistent hashing places both keys and servers on a circle (ring) in the same hash space [ 0 , 2 m ) [0, 2^m) [ 0 , 2 m ) . Each key is assigned to the first server encountered when moving clockwise around the Ring.
class ConsistentHashRing :
Consistent hashing for distributed key assignment.
Adding/removing a node affects O(k/n) keys on average.
Time: O(log n) per lookup
def __init__ ( self , num_replicas = 150 ):
self .num_replicas = num_replicas
return hash (key) % ( 2 ** 32 )
def add_node ( self , node_id ):
for i in range ( self .num_replicas):
replica_key = f " { node_id } :replica: { i } "
h = self ._hash(replica_key)
bisect.insort( self .ring, h)
self .node_map[h] = node_id
def remove_node ( self , node_id ):
for i in range ( self .num_replicas):
replica_key = f " { node_id } :replica: { i } "
h = self ._hash(replica_key)
idx = bisect.bisect_left( self .ring, h)
if idx < len ( self .ring) and self .ring[idx] == h:
idx = bisect.bisect( self .ring, h)
if idx == len ( self .ring):
return self .node_map[ self .ring[idx]]
Without virtual nodes, a small number of physical servers leads to uneven distribution (each server Occupies a large arc of the ring). Virtual nodes (replicas) solve this: each physical server is Placed on the ring multiple times ( 100-200 virtual nodes per physical server). This Smooths out the distribution and reduces the variance in key assignment.
Physical servers Virtual nodes per server Keys per server (std dev / mean) 10 1 ≈ 0.39 \approx 0.39 ≈ 0.39 10 100 ≈ 0.04 \approx 0.04 ≈ 0.04 10 1000 ≈ 0.01 \approx 0.01 ≈ 0.01
When adding or removing a node, only the keys in the affected arc are remapped. With n n n nodes and k k k keys, the expected number of keys remapped when one node is added or removed is k / n k/n k / n — Regardless of the total number of nodes. This is far better than modulo hashing, which remaps k ⋅ ( 1 − 1 / ( n + 1 ) ) ≈ k k \cdot (1 - 1/(n+1)) \approx k k ⋅ ( 1 − 1/ ( n + 1 )) ≈ k keys when going from n n n to n + 1 n+1 n + 1 nodes.
Databases. The standard number of virtual nodes is 150, which gives less than 10% imbalance with High probability.A bloom filter is a space-efficient probabilistic data structure for membership testing. It can tell You whether an element is definitely not in the set or possibly in the set (with a Configurable false positive rate).
A bloom filter is a bit array of m m m bits, initially all 0, together with k k k independent hash Functions h 1 , h 2 , … , h k h_1, h_2, \ldots, h_k h 1 , h 2 , … , h k .
Operation Algorithm Time Add Set bits at positions h 1 ( x ) , h 2 ( x ) , … , h k ( x ) h_1(x), h_2(x), \ldots, h_k(x) h 1 ( x ) , h 2 ( x ) , … , h k ( x ) O ( k ) O(k) O ( k ) Query Check if all bits at h 1 ( x ) , h 2 ( x ) , … , h k ( x ) h_1(x), h_2(x), \ldots, h_k(x) h 1 ( x ) , h 2 ( x ) , … , h k ( x ) are set O ( k ) O(k) O ( k ) False positive prob ( 1 − e − k n / m ) k (1 - e^{-kn/m})^k ( 1 − e − k n / m ) k N/A
Bloom filter for probabilistic membership testing.
Space: O(m) bits where m = -(n * ln(p)) / (ln(2)^2)
Time: O(k) per operation where k = (m/n) * ln(2)
def __init__ ( self , expected_items , false_positive_rate = 0.01 ):
self .expected_items = expected_items
self .fpr = false_positive_rate
self .size = self ._optimal_size(expected_items, false_positive_rate)
self .num_hashes = self ._optimal_hashes( self .size, expected_items)
self .bit_array = 0 # using Python int as bit array
return int ( - n * (p.ln() if hasattr (p, ' ln ' ) else __import__ ( ' math ' ).log(p)) / ( __import__ ( ' math ' ).log( 2 ) ** 2 ))
def _optimal_hashes ( m , n ):
return max ( 1 , int ((m / n) * __import__ ( ' math ' ).log( 2 )))
for i in range ( self .num_hashes):
h = mmh3.hash( str (item), i) % self .size
self .bit_array |= ( 1 << h)
def might_contain ( self , item ):
for i in range ( self .num_hashes):
h = mmh3.hash( str (item), i) % self .size
if not ( self .bit_array & ( 1 << h)):
Given n n n expected items and desired false positive rate p p p :
m = − n ln p ( ln 2 ) 2 k = m n ln 2 m = -\frac{n \ln p}{(\ln 2)^2} \quad k = \frac{m}{n} \ln 2 m = − ( l n 2 ) 2 n l n p k = n m ln 2
False positive rate Bits per element Hash functions 1% 9.6 7 0.1% 14.4 10 0.01% 19.2 14
Affecting other elements. If you need deletion, use a counting bloom filter (each position stores a Counter instead of a single bit) or a cuckoo filter.Spam filtering : check if an email’s hash is in a known-spam bloom filterDatabase queries : Cassandra uses bloom filters to avoid unnecessary disk readsNetwork routing : routers use bloom filters to summarise routing tablesCache filtering : web browsers use bloom filters for malicious URL detectionDeduplication : check if content has been seen beforeThe count-min sketch is a probabilistic data structure for frequency estimation. Like a bloom Filter, it uses hash functions but stores counters instead of bits.
d d d hash functions map to w w w counters each, giving a d × w d \times w d × w matrix of counters. Each hash Function h i h_i h i maps an element to a row and column.
Operation Algorithm Time Error Increment For each row i i i : count[i][h_i(x)] += 1 O ( d ) O(d) O ( d ) N/A Estimate Return \min_i \mathrm{count[i][h_i(x)] O ( d ) O(d) O ( d ) \le \mathrm{true count Space d × w d \times w d × w countersN/A N/A
The estimate is always an overestimate : f ^ ( x ) ≥ f ( x ) \hat{f}(x) \ge f(x) f ^ ( x ) ≥ f ( x ) with high probability. The error Is bounded by ϵ ⋅ N \epsilon \cdot N ϵ ⋅ N where N N N is the total count and ϵ = e / w \epsilon = e / w ϵ = e / w .
Count-min sketch for frequency estimation.
Error: estimate <= true_count + (total_count * e / w) with probability >= 1 - delta
def __init__ ( self , epsilon = 0.01 , delta = 0.01 ):
self .w = int ( __import__ ( ' math ' ).e / epsilon)
self .d = int ( __import__ ( ' math ' ).log( 1 / delta))
self .counts = [[ 0 ] * self .w for _ in range ( self .d)]
h = hashlib.md5( f " { i } : { x } " .encode()).hexdigest()
return int (h, 16 ) % self .w
def add ( self , x , count = 1 ):
self .counts[i][ self ._hash(x, i)] += count
return min ( self .counts[i][ self ._hash(x, i)] for i in range ( self .d))
def estimate_range ( self , x ):
lower = max ( 0 , self .estimate(x) - self .total * __import__ ( ' math ' ).e / self .w)
HyperLogLog is a probabilistic algorithm for estimating the cardinality (number of distinct Elements) of a set using very little memory — 12 KB for an error rate of about 0.8%.
Hash each element to a binary string Use the first b b b bits to determine which of 2 b 2^b 2 b registers to update For each register, track the position of the leftmost 1-bit in the remaining bits The estimate is based on the harmonic mean of the register values n ^ = α m ⋅ m 2 ⋅ ( ∑ j = 1 m 2 − M [ j ] ) − 1 \hat{n} = \alpha_{m} \cdot m^2 \cdot \left(\sum_{j=1}^{m} 2^{-M[j]}\right)^{-1} n ^ = α m ⋅ m 2 ⋅ ( ∑ j = 1 m 2 − M [ j ] ) − 1
Where m = 2 b m = 2^b m = 2 b is the number of registers, M [ j ] M[j] M [ j ] is the maximum position of the leftmost 1-bit Seen in register j j j And α m \alpha_m α m is a bias correction constant.
HyperLogLog for cardinality estimation.
Space: O(2^b) registers (in standard practice b=12, so 4096 registers = 12 KB)
def __init__ ( self , b = 12 ):
self .registers = [ 0 ] * self .m
self .alpha = self ._alpha( self .m)
return 0.7213 / ( 1 + 1.079 / m)
h = int (hashlib.md5( str (item).encode()).hexdigest(), 16 )
self .registers[idx] = max ( self .registers[idx], self ._rho(w))
while w & 1 == 0 and rank < 64 :
estimate = self .alpha * self .m ** 2 / sum ( 2.0 ** ( - r) for r in self .registers)
if estimate <= 2.5 * self .m:
zeros = self .registers.count( 0 )
estimate = self .m * math.log( self .m / zeros)
Registers (m m m ) Memory Standard error 64 64 B ≈ 13 % \approx 13\% ≈ 13% 1024 1 KB ≈ 3.3 % \approx 3.3\% ≈ 3.3% 4096 4 KB ≈ 1.6 % \approx 1.6\% ≈ 1.6% 16384 16 KB ≈ 0.8 % \approx 0.8\% ≈ 0.8%
Property Non-Cryptographic Cryptographic Speed Very fast (GB/s) Slower (hundreds of MB/s) Preimage resistance No Yes — given h ( x ) h(x) h ( x ) Hard to find x x x Second preimage No Yes — given x x x Hard to find y ≠ x y \ne x y = x with h ( y ) = h ( x ) h(y) = h(x) h ( y ) = h ( x ) Collision resistance Weak Yes — hard to find x , y x, y x , y with h ( x ) = h ( y ) h(x) = h(y) h ( x ) = h ( y ) Examples FNV, MurmurHash, xxHash, CityHash SHA-256, SHA-3, BLAKE3 Use case Hash tables, fingerprints Passwords, signatures, TLS
Are necessary only when an adversary can choose inputs (e.g., hash DoS attacks). Python switched From a simple hash to SipHash (a cryptographic hash) in Python 3.4+ specifically to prevent hash Flooding attacks.Python’s dict is a highly optimised hash table using open addressing with a compact Representation:
Compact hash table (since Python 3.6): stores entries in a dense array separate from the sparse hash index array. This improves memory locality for iteration.Hash randomisation : PYTHONHASHSEED randomises hash values to prevent hash DoS attacks.Growth factor : starts at 8, grows by approximately 3 × 3\times 3 × on the first resize, then 2 × 2\times 2 × subsequently.Load factor : maintains 2 / 3 2/3 2/3 load factor (never exceeds this).Deleted entries : uses a special sentinel value, with periodic cleanup during resize.Separate chaining with linked lists, converting to red-black trees when a chain exceeds 8 elements (since Java 8).Load factor : 0.75 by default. Threshold for tree conversion: 8. Tree-to-list conversion: 6.Initial capacity : 16 by default. The capacity is always a power of 2.Hash mixing : applies a secondary hash function to the hashCode() result to spread higher bits into lower bits (XOR-shift).Separate chaining (). The standard does not mandate a specific collision resolution strategy.Load factor : 1.0 by default (max_load_factor()).Bucket count : power-of-two in libstdc++ (GCC), but not required by the standard.Rehash policy : rehash(n) sets the bucket count to at least n``reserve(n) sets it to accommodate n elements without rehashing.When the load factor exceeds the threshold, the table must be resized:
Allocate a new array of size m ′ > m m' \gt m m ′ > m Compute h ′ ( k ) h'(k) h ′ ( k ) for each existing key using the new table size Insert all key-value pairs into the new array def resize ( old_table , new_capacity ):
Resize and rehash. O(n) time where n = number of elements.
Amortised O(1) per insert because we resize geometrically.
new_table = HashTable(new_capacity)
for bucket in old_table.buckets:
for key, value in bucket:
new_table.insert(key, value)
Why geometric resizing gives amortised O ( 1 ) O(1) O ( 1 ) : if the table grows by factor c c c when it reaches Load factor α \alpha α The cost of resizing is O ( n ) O(n) O ( n ) but it only happens every O ( n ) O(n) O ( n ) insertions. Over a sequence of n n n insertions, the total resize cost is O ( n ) + O ( n / c ) + O ( n / c 2 ) + … = O ( c n ) O(n) + O(n/c) + O(n/c^2) + \ldots = O(cn) O ( n ) + O ( n / c ) + O ( n / c 2 ) + … = O ( c n ) Giving O ( c ) O(c) O ( c ) amortised per insertion — a constant.
If you mutate an object after using it as a hash key, its hash value changes and you can no longer Find it in the table. In Python, using a list as a dict key raises TypeError. Using a custom Object with a hash based on mutable fields silently breaks lookups. Always use immutable keys or Freeze objects before hashing.
An adversary who can control the keys inserted into a hash table can choose keys that all hash to The same bucket, degrading performance from O ( 1 ) O(1) O ( 1 ) to O ( n ) O(n) O ( n ) per operation. Defences: hash Randomisation (Python’s PYTHONHASHSEED), SipHash (Python 3.4+), or switching to balanced trees for Long chains (Java 8+).
In Python, if you override __eq__ without overriding __hash__The class becomes unhashable. If You override __hash__ without overriding __eq__Objects that compare equal may hash to Different values. Both must be consistent: if a == bThen hash(a) == hash(b).
When implementing hash functions in languages with fixed-width integers, multiplication can Overflow. In C/C++, signed integer overflow is undefined behaviour; use unsigned integers. In Java, Integer overflow wraps around (which is fine for non-cryptographic hashes). In Python, integers have Arbitrary precision, so overflow is not an issue.
If your keys have a pattern (e.g., sequential integers, IPv4 addresses in a subnet), a naive hash Function like modulo may map them to a small number of buckets. Always use a hash function with good Avalanche properties, or test your hash function against your actual data distribution.
When deleting from an open-addressing table, you cannot clear the slot — doing so breaks the Probe chain for elements that were inserted after the deleted element. You must use a “tombstone” Marker (as shown in the linear probing implementation above) and periodically clean up tombstones During resize.
Bloom filters are probabilistic. If your application cannot tolerate any false positives (e.g., Security-critical access control), do not use a bloom filter. If you can tolerate them, size the Filter correctly and monitor the actual false positive rate in production.
Use a bloom filter for membership testing (set membership), count-min sketch for frequency Estimation, and HyperLogLog for cardinality estimation. Each is optimised for a different query type And cannot substitute for another.
This topic covers the core concepts of hashing and hash tables, including underlying theory, practical implementation, and key applications.
Key concepts include:
Big O notation and complexity analysis searching algorithms (binary, linear) sorting algorithms (bubble, merge, quick) graph algorithms (Dijkstra, BFS, DFS) dynamic programming Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Linked Lists, Stacks, and Queues : Alternative linear data structures that complement hash tables for different use cases.Sorting Algorithms : Covers comparison-based and non-comparison sorting, which can be combined with hash-based techniques.Dynamic Programming : Explores overlapping subproblems and optimal substructure, concepts related to hash-based memoisation.Hash tables solve the fundamental problem of mapping keys to values with O(1) average-case lookup, insert, and delete. The idea is simple: a hash function converts a key into an array index, and you store the value at that index. The challenge is collisions — two different keys hashing to the same index. Separate chaining (each bucket holds a list) and open addressing (probe for the next empty slot) are the two main strategies. The load factor (elements/buckets) controls performance: keep it below 0.75 and operations stay O(1) amortized. When the load factor gets too high, you resize the table (in standard practice doubling it) and rehash everything — this is O(n) but happens rarely enough that the amortized cost per insert is still O(1).
Probabilistic hash-based structures trade accuracy for space efficiency. A bloom filter uses a bit array and multiple hash functions to test set membership with configurable false positive rates — it can say “definitely not in the set” or “probably in the set” but never “definitely in the set.” Count-min sketch estimates frequencies by maintaining multiple counter arrays, always overestimating but never by more than a predictable amount. HyperLogLog estimates the number of distinct elements in a stream using just kilobytes of memory by tracking the position of the leftmost 1-bit in hash values. These structures are invaluable in distributed systems where exact answers require too much memory or network communication.
Consistent hashing solves the distributed systems problem of mapping keys to servers with minimal redistribution when servers are added or removed. Instead of modulo hashing (which remaps nearly all keys when the server count changes), consistent hashing places both keys and servers on a ring. Each key maps to the nearest server clockwise. Virtual nodes (multiple positions per physical server) ensure even distribution. This is how DynamoDB, Cassandra, and content delivery networks distribute data across thousands of servers while minimizing the disruption when scaling up or down.