Hash Function
Converts a key into an array index. Should be fast, consistent, and spread keys out evenly to avoid collisions.
Imagine you have a huge box of files and you need to find one specific file instantly, without flipping through every page. That is exactly what hashing lets you do in programming. It is one of the most useful tricks for making your code fast, and once you understand it, you will start seeing it everywhere.
Okay, simple version: hashing is a way to find data almost instantly, instead of searching for it one item at a time.
Normally, if you wanted to check if 42 exists in a list like this:
[15, 23, 42, 7, 91]You would have to check each number one by one until you find it. That takes time, and the bigger the list, the longer it takes.
With hashing, instead of searching, you calculate exactly where something should be. You ask a small helper called a hash function to give you the exact spot, and you go straight there.
index = hash(key)For example:
hash(42) = 2So you jump directly to index 2. No searching, no scanning. Just straight to the answer.
A hash table is simply an array (a list of slots) where data is stored using the index that the hash function gives us.
Index: 0 1 2 3 4Data: - 23 42 - 15Here, the number 23 lives at index 1 because the hash function said so. The number 42 lives at index 2. Every piece of data has its own calculated home.
This is the magic that lets hash tables do lookups, insertions, and deletions in roughly constant time, which in plain English means “it stays fast no matter how much data you add.”
A hash function is the small piece of logic that takes a key (like a number or a word) and turns it into an index.
h(key) -> a number between 0 and (table_size - 1)For a hash function to actually be useful, it needs to follow a few simple rules:
42 today, you should get the same index tomorrow.The division method
This is the simplest and most common approach. You just divide and take the remainder.
h(key) = key % table_sizeExample:
table_size = 10key = 23h(23) = 23 % 10 = 3The multiplication method
h(key) = floor(table_size * (key * A % 1))Here, A is usually a constant like 0.618. This spreads keys out more evenly than simple division in some cases.
The mid-square method
You square the key, then grab the middle digits of the result.
key = 1212 squared = 144middle digit = 4String hashing (used for words and text)
Numbers are easy, but what about words? You convert each character to a number and combine them with a formula like this:
hash = sum of (character_value * p^position) % table_sizeThis is roughly how Python dictionaries handle text keys behind the scenes, with extra optimizations layered on top.
Sometimes two completely different keys end up pointing to the exact same index. This is called a collision.
h(10) = 2h(20) = 2Both 10 and 20 want to live at index 2. Only one of them can actually be there, so we need a plan for what happens next.
There are two main families of solutions for this:
Let us go through each one.
With open addressing, every key still lives inside the main table. If a spot is taken, the key politely looks for the next available seat.
The simplest version: if your spot is taken, just check the next one, and the next, and so on.
(h(key) + i) % table_sizeHere i starts at 0 and increases by 1 each time you hit an occupied slot.
Walking through an example
Let us insert 10, 20, and 30 into a table of size 7.
h(10) = 3 -> slot 3 is empty, place it thereh(20) = 3 -> collision! try slot 4 -> empty, place it thereh(30) = 3 -> collision! slot 4 taken too, try slot 5 -> empty, place it thereFinal table:
Index: 0 1 2 3 4 5 6Data: - - - 10 20 30 -How it looks in code
class HashTable: def __init__(self, size): self.size = size self.table = [None] * size
def insert(self, key): index = key % self.size i = 0 while self.table[index] is not None: i += 1 index = (key % self.size + i) % self.size self.table[index] = keyThe downside: Primary Clustering
Once a few keys land next to each other, they form a little group:
[10, 20, 30, 40]Now any new key that collides with this group has to walk past the entire group before finding a free spot. This pile-up effect is called primary clustering, and it slowly makes your hash table feel like a regular slow search again, closer to O(n) than O(1).
To avoid the clustering problem, instead of moving one step at a time, you jump using squares.
(h(key) + i^2) % table_sizeExample
collision at index 3try 3 + 1^2 = 4try 3 + 2^2 = 7 (wrapped using % table_size)In code
def insert(self, key): h = key % self.size for i in range(self.size): index = (h + i * i) % self.size if self.table[index] is None: self.table[index] = key returnA new problem: Secondary Clustering
Quadratic probing fixes primary clustering, but creates a smaller version of the same issue. Keys that start at the same index will always follow the exact same jumping pattern, even if there is free space sitting right in between them that they never check.
h(10) = 3h(20) = 3Both follow the exact same i^2 jump sequenceThis is called secondary clustering. It is less severe than primary clustering, but it is still not perfect.
This is the cleverest of the three, and the one most people consider the best open addressing technique.
Instead of using a fixed pattern like “+1” or “+i squared,” you use a second hash function to decide how big each jump should be.
index = (h1(key) + i * h2(key)) % table_sizeExample setup
h1(key) = key % sizeh2(key) = 1 + (key % (size - 1))In code
for i in range(size): index = (h1(key) + i * h2(key)) % size if table[index] is None: # insert here breakBecause the jump size itself depends on the key, different keys end up following completely different paths, even if they started at the same spot. This gets rid of both primary and secondary clustering, which is why double hashing is considered the strongest open addressing method.
Instead of forcing every key to find its own private slot, chaining lets multiple keys live happily at the same index by storing them in a small list.
Index 0 -> 10 -> 20 -> 30Index 1 -> 5 -> 15Think of each index as a small parking lot, and the list as cars parked one behind another. If two keys hash to the same index, no problem, they just queue up.
Inserting
index = hash(key)table[index].append(key)In code
class HashTable: def __init__(self, size): self.size = size self.table = [[] for _ in range(size)]
def insert(self, key): index = key % self.size self.table[index].append(key)Searching
index = hash(key)look through the small list at that indexHow fast is it?
The load factor tells you how crowded your hash table is.
Load Factor = number of elements stored / total table sizeExample:
10 elements stored in a table of size 20Load Factor = 10 / 20 = 0.5The fuller your table gets, the more often collisions happen, and the slower your hash table becomes. A nearly empty table is fast. An overcrowded table starts behaving like a regular slow list.
As a rough guideline:
0.71.0 since multiple keys per slot is expectedWhen your hash table gets too full (load factor crosses the safe threshold), it is time to upgrade to a bigger table. This process is called rehashing.
Here is what happens step by step:
Create a brand new, bigger table - usually double the size of the old one.
Go through every existing key - one at a time, from the old table.
Recompute its hash for the new table size - since the table size changed, the index changes too.
Insert it into the new table - place it at its newly calculated index.
In code
def rehash(self): old_table = self.table self.size *= 2 self.table = [[] for _ in range(self.size)] for bucket in old_table: for key in bucket: self.insert(key)Yes, rehashing touches every single element, so it takes O(n) time when it happens. But here is the good part: it does not happen often. Most of the time, insertions stay at O(1), and rehashing only kicks in occasionally. When you average it out over many insertions, it still works out to O(1) per insertion. This is called amortized constant time.
Python’s built-in dict is basically a hash table that has been heavily optimized in C. A few interesting facts about it:
So every time you write my_dict[key] = value in Python, all of this machinery is quietly working underneath.
Both approaches solve the same problem in different ways, and each comes with trade-offs.
Good things:
Not so good things:
Good things:
Not so good things:
| Operation | Average Case | Worst Case |
|---|---|---|
| Insert | O(1) | O(n) |
| Search | O(1) | O(n) |
| Delete | O(1) | O(n) |
The worst case only shows up when your hash function is poorly chosen, or the table is badly overloaded. With a decent hash function and reasonable load factor, you will almost always get that fast O(1) behavior.
HashMap uses chaining, and even upgrades buckets to a balanced tree structure once they get too crowded (this is called treeification).Use hashing when:
Avoid hashing when:
Hash Function
Converts a key into an array index. Should be fast, consistent, and spread keys out evenly to avoid collisions.
Collision
When two different keys land on the same index. Completely normal, but needs a clear strategy to handle.
Open Addressing
Find another free slot inside the same table when a collision happens. Includes linear probing, quadratic probing, and double hashing.
Chaining
Let multiple keys share the same index by storing them in a small list. Simple and avoids clustering entirely.
Load Factor
Elements stored divided by table size. Higher load factor means more collisions and slower performance.
Rehashing
Growing the table and reinserting everything once it gets too full. Expensive once in a while, cheap on average.