A hash table (also called a hash map) is a data structure that maps keys to values in O(1) average time. It is one of the most important data structures in computer science — used inside Python dictionaries, JavaScript objects, Java HashMap, and virtually every database index.
Understanding how a hash table works — and why it can degrade to O(n) — is essential for coding interviews and everyday engineering.
Quick reference
| Operation | Average case | Worst case |
|---|---|---|
| Insert | O(1) | O(n) |
| Lookup | O(1) | O(n) |
| Delete | O(1) | O(n) |
| Space | O(n) | O(n) |
Worst-case O(n) happens when many keys hash to the same slot (collisions). A good hash function and a reasonable load factor keep it rare.
How a hash table works
A hash table is backed by an array of fixed size (the bucket array). To store a key-value pair:
- Hash the key — run the key through a hash function to get an integer index.
- Reduce to array size — take that integer modulo the array length:
index = hash(key) % capacity. - Store at that index — place the value (or a bucket node) at
buckets[index].
Key "name"
│
▼
hash("name") = 2871452974 (some large integer)
│
▼
2871452974 % 8 = 6 (if capacity = 8)
│
▼
buckets[6] = "Alice"
To look up "name" later, repeat the same two steps — no search required. That's why lookup is O(1).
What is a hash function?
A hash function converts a key (string, integer, object) into an integer. Good hash functions are:
| Property | Why it matters |
|---|---|
| Deterministic | Same key always produces same hash |
| Uniform distribution | Spreads keys evenly to avoid hotspots |
| Fast to compute | O(1) or O(k) where k = key length |
| Avalanche effect | Small key change → very different hash |
Simple integer hash for strings (djb2 algorithm):
def djb2(key: str) -> int:
h = 5381
for ch in key:
h = ((h << 5) + h) + ord(ch) # h * 33 + ord(ch)
return h & 0xFFFFFFFF # keep 32-bit unsigned
Real languages use battle-tested hash functions (SipHash in Python 3.4+, FNV in Go) that also defend against hash flooding attacks (malicious inputs designed to cause maximum collisions).
Collisions: the core challenge
Two different keys can produce the same index. This is a collision. Two main strategies handle it:
Strategy 1: Separate chaining
Each bucket holds a linked list (or dynamic array) of all key-value pairs that hashed to that slot.
buckets:
0 → [("cat", 3)]
1 → [("dog", 7) → ("log", 2)] ← collision
2 → []
3 → [("rat", 5)]
- Insert: O(1) amortised — prepend to the list.
- Lookup: O(1) average — traverse one short list.
- Worst case: all n keys in one bucket → O(n) per operation.
Strategy 2: Open addressing
All entries live inside the array itself — no external lists. When a collision occurs, probe for the next empty slot.
Linear probing: try (index + 1) % cap, (index + 2) % cap, …
insert "dog" → hash → slot 3 occupied → try slot 4 → empty ✓
Quadratic probing: try offsets +1², +2², +3², … (reduces primary clustering).
Double hashing: use a second hash function to compute the step size.
| Separate chaining | Open addressing | |
|---|---|---|
| Memory | Extra pointer per entry | Array only |
| Cache performance | Poor (pointer chasing) | Better (contiguous) |
| Deletion | Easy (remove from list) | Needs tombstone markers |
| Load factor limit | Can exceed 1.0 | Must stay below 1.0 |
| Common in | Java HashMap, Python dict | Linear probing: many caches, Robin Hood hash |
Load factor and rehashing
The load factor λ = n / capacity (number of entries ÷ array size).
| Load factor | Behaviour |
|---|---|
| λ < 0.5 | Few collisions, O(1) guaranteed |
| 0.5 ≤ λ < 0.75 | Acceptable — Python/Java target |
| λ ≥ 0.75 | Many collisions, performance degrades |
| λ = 1.0 (open addressing) | Table full — inserts fail |
When λ exceeds the threshold, the table rehashes:
- Allocate a new array (typically 2× the size).
- Reinsert every existing entry into the new array.
- Old array is garbage collected.
Rehashing is O(n) but happens infrequently — amortised over all inserts, each insert is still O(1).
Implementations in 4 languages
Python
Python's built-in dict is a hash table. Build a minimal one from scratch:
class HashMap:
def __init__(self, capacity: int = 16):
self._cap = capacity
self._buckets: list[list] = [[] for _ in range(capacity)]
self._size = 0
def _index(self, key: str) -> int:
return hash(key) % self._cap
def put(self, key: str, value) -> None:
idx = self._index(key)
for pair in self._buckets[idx]:
if pair[0] == key:
pair[1] = value
return
self._buckets[idx].append([key, value])
self._size += 1
if self._size / self._cap > 0.75:
self._rehash()
def get(self, key: str):
for pair in self._buckets[self._index(key)]:
if pair[0] == key:
return pair[1]
raise KeyError(key)
def delete(self, key: str) -> None:
idx = self._index(key)
bucket = self._buckets[idx]
for i, pair in enumerate(bucket):
if pair[0] == key:
bucket.pop(i)
self._size -= 1
return
raise KeyError(key)
def _rehash(self) -> None:
old = self._buckets
self._cap *= 2
self._buckets = [[] for _ in range(self._cap)]
self._size = 0
for bucket in old:
for key, value in bucket:
self.put(key, value)
# Usage
m = HashMap()
m.put("name", "Alice")
m.put("age", 30)
print(m.get("name")) # Alice
m.delete("age")
JavaScript
JavaScript objects are hash maps. Build a minimal one:
class HashMap {
constructor(capacity = 16) {
this._cap = capacity;
this._buckets = Array.from({ length: capacity }, () => []);
this._size = 0;
}
_hash(key) {
let h = 0;
for (const ch of key) h = (Math.imul(31, h) + ch.charCodeAt(0)) | 0;
return Math.abs(h) % this._cap;
}
put(key, value) {
const idx = this._hash(key);
for (const pair of this._buckets[idx]) {
if (pair[0] === key) { pair[1] = value; return; }
}
this._buckets[idx].push([key, value]);
this._size++;
if (this._size / this._cap > 0.75) this._rehash();
}
get(key) {
for (const [k, v] of this._buckets[this._hash(key)]) {
if (k === key) return v;
}
return undefined;
}
delete(key) {
const idx = this._hash(key);
const b = this._buckets[idx];
const i = b.findIndex(([k]) => k === key);
if (i !== -1) { b.splice(i, 1); this._size--; }
}
_rehash() {
const old = this._buckets;
this._cap *= 2;
this._buckets = Array.from({ length: this._cap }, () => []);
this._size = 0;
for (const bucket of old)
for (const [k, v] of bucket) this.put(k, v);
}
}
// Usage
const m = new Map(); // built-in
m.set("name", "Alice");
console.log(m.get("name")); // Alice
m.delete("name");
Java
import java.util.HashMap;
import java.util.Map;
public class HashMapDemo {
public static void main(String[] args) {
Map<String, Integer> scores = new HashMap<>();
// Insert
scores.put("Alice", 95);
scores.put("Bob", 87);
scores.put("Carol", 92);
// Lookup
System.out.println(scores.get("Alice")); // 95
System.out.println(scores.getOrDefault("Dave", 0)); // 0
// Update
scores.put("Bob", 90);
// Delete
scores.remove("Carol");
// Iterate
for (Map.Entry<String, Integer> e : scores.entrySet()) {
System.out.printf("%s → %d%n", e.getKey(), e.getValue());
}
// Compute if absent
scores.computeIfAbsent("Eve", k -> 88);
// Merge
scores.merge("Alice", 5, Integer::sum); // Alice = 100
}
}
Java's HashMap uses separate chaining (linked list that converts to a balanced BST when a bucket exceeds 8 entries — since Java 8).
Go
Go's built-in map is a hash table:
package main
import "fmt"
func main() {
// Create
scores := make(map[string]int)
// Insert
scores["Alice"] = 95
scores["Bob"] = 87
// Lookup with existence check
if v, ok := scores["Alice"]; ok {
fmt.Println("Alice:", v) // 95
}
// Update
scores["Bob"] = 90
// Delete
delete(scores, "Bob")
// Iterate (order not guaranteed)
for k, v := range scores {
fmt.Printf("%s → %d\n", k, v)
}
// Zero value for missing key
fmt.Println(scores["Carol"]) // 0
}
Go's map uses a variant of open addressing with hash seed randomisation per program run (to prevent hash flooding).
Hash table vs array vs linked list
| Array | Linked list | Hash table | |
|---|---|---|---|
| Access by index | O(1) | O(n) | N/A |
| Access by key | O(n) linear scan | O(n) | O(1) average |
| Insert at end | O(1) amortised | O(1) with tail ptr | O(1) average |
| Insert at front | O(n) | O(1) | O(1) average |
| Delete | O(n) | O(n) | O(1) average |
| Memory | Contiguous | Fragmented | Overhead for buckets |
| Order preserved | Yes | Yes | No (use LinkedHashMap) |
| Duplicates | Yes | Yes | Keys unique |
Hash table vs binary search tree (BST)
| Hash table | Balanced BST | |
|---|---|---|
| Average lookup | O(1) | O(log n) |
| Worst-case lookup | O(n) | O(log n) |
| Ordered iteration | No | Yes |
| Range queries | No | Yes |
| Memory | More (bucket overhead) | Less per node |
| Use when | Key-value lookup | Sorted data, range queries |
Use a BST (TreeMap in Java, std::map in C++) when you need keys in sorted order or range queries. Use a hash table for everything else.
6 classic interview problems
1. Two sum (LeetCode #1)
def two_sum(nums: list[int], target: int) -> list[int]:
seen: dict[int, int] = {} # value → index
for i, n in enumerate(nums):
complement = target - n
if complement in seen:
return [seen[complement], i]
seen[n] = i
return []
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
2. First non-repeating character
from collections import Counter
def first_unique(s: str) -> str:
freq = Counter(s)
for ch in s:
if freq[ch] == 1:
return ch
return ""
print(first_unique("leetcode")) # "l"
3. Group anagrams
from collections import defaultdict
def group_anagrams(words: list[str]) -> list[list[str]]:
groups: dict[str, list[str]] = defaultdict(list)
for w in words:
key = "".join(sorted(w))
groups[key].append(w)
return list(groups.values())
print(group_anagrams(["eat","tea","tan","ate","nat","bat"]))
# [['eat','tea','ate'], ['tan','nat'], ['bat']]
4. Longest consecutive sequence
def longest_consecutive(nums: list[int]) -> int:
num_set = set(nums)
best = 0
for n in num_set:
if n - 1 not in num_set: # start of a sequence
length = 1
while n + length in num_set:
length += 1
best = max(best, length)
return best
print(longest_consecutive([100,4,200,1,3,2])) # 4
5. Subarray sum equals k
from collections import defaultdict
def subarray_sum(nums: list[int], k: int) -> int:
count = 0
prefix = 0
seen: dict[int, int] = defaultdict(int)
seen[0] = 1
for n in nums:
prefix += n
count += seen[prefix - k]
seen[prefix] += 1
return count
6. LRU cache (HashMap + doubly linked list)
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity: int):
self._cap = capacity
self._cache: OrderedDict[int, int] = OrderedDict()
def get(self, key: int) -> int:
if key not in self._cache:
return -1
self._cache.move_to_end(key)
return self._cache[key]
def put(self, key: int, value: int) -> None:
if key in self._cache:
self._cache.move_to_end(key)
self._cache[key] = value
if len(self._cache) > self._cap:
self._cache.popitem(last=False)
Real-world uses
| Use case | Example |
|---|---|
| Language built-ins | Python dict, JS Map/{}, Java HashMap, Go map |
| Database indexes | Hash index for exact-match queries |
| Caching | Memcached, Redis — key-value lookup |
| Symbol tables | Compilers map variable names to memory addresses |
| Counting / frequency | Word count, character frequency |
| Set membership | Deduplication, bloom filters |
| Routing tables | IP routing, HTTP router path lookup |
| Cryptography | SHA-256 (hash function, not hash table — different concept) |
Common mistakes
| Mistake | Why it's wrong | Fix |
|---|---|---|
| Assuming O(1) is always guaranteed | Worst-case is O(n) with bad hash/high load | Use standard library implementations |
| Using mutable objects as keys | Hash changes after mutation → key lost | Use immutable keys (strings, ints, tuples) |
| Ignoring load factor | Too high → many collisions; too low → wastes memory | Let the library manage it |
| Confusing hash table with hash function | Hash function is just one component | Know both concepts separately |
| Modifying a map while iterating | Undefined behaviour in most languages | Collect keys first, then delete |
| Not handling missing keys | KeyError / undefined / null panic |
Use .get(key, default) or existence check |
Using == instead of .equals() in Java |
Compares references for objects | Always override hashCode() + equals() |
Forgetting to override hashCode in Java |
Two "equal" objects hash differently → lost entries | Override both together |
Hash table vs related concepts
| Term | What it is |
|---|---|
| Hash table | Data structure (array + hash function + collision resolution) |
| Hash map | Same thing; "map" emphasises key → value semantics |
| Hash set | Hash table that stores only keys (no values) |
| Hash function | Algorithm that converts a key to an integer |
| Cryptographic hash | One-way hash (SHA-256) — different purpose: integrity, not lookup |
| Dictionary | Python's name for its built-in hash map |
| Object (JS) | JavaScript's hash map for string/symbol keys |
| BST / TreeMap | Ordered map backed by a balanced tree — O(log n) but sorted |
FAQ
Q: Why does Python dict maintain insertion order?
Since Python 3.7, dict is guaranteed to preserve insertion order. CPython achieves this by keeping a compact index array separate from a dense array of (hash, key, value) entries added in insertion order.
Q: Can a hash table have duplicate keys?
No — by definition, keys are unique. Inserting with an existing key overwrites the value. For multiple values per key, store a list as the value: map[key] = map.get(key, []) + [value].
Q: What makes a "bad" hash function?
One that maps many different keys to the same bucket. For example, hash(key) = 0 for all keys turns every lookup into an O(n) linear scan. A good hash function distributes keys uniformly across all buckets.
Q: Is dict in Python thread-safe?
Individual operations (d[k], d[k] = v) are atomic in CPython due to the GIL, but compound operations like "check then set" are not. For multi-threaded code, use threading.Lock or concurrent.futures.
Q: When should I use a TreeMap instead of a HashMap?
When you need keys in sorted order (e.g., find all keys in a range, iterate in alphabetical order). TreeMap gives O(log n) per operation but supports floorKey, ceilingKey, subMap, and sorted iteration. HashMap gives O(1) but has no ordering.
Q: What is Robin Hood hashing?
An open-addressing variant that reduces variance in probe lengths. When inserting, if the new key has probed further than the key already in a slot ("richer" slot), they swap — the "poor" key continues probing. This keeps the maximum probe length low and improves cache performance.