Hash Maps, Sets, and Their Ordered Versions
The idea
A hash map turns a key into an array index by running it through a hash function, then stores the value at that slot. No searching, no comparing — the key computes its own address.
key "cat" → hash("cat") = 8391027 → 8391027 % 8 = 3
↓
buckets: [ _ ][ _ ][ _ ][ cat:1 ][ _ ][ _ ][ _ ][ _ ]
0 1 2 3 4 5 6 7
A set is the same structure storing only keys, with the value dropped. Every complexity and every caveat below applies identically to both; a set is a map whose values are all True.
Complexity, and what "average" is hiding
| Operation | Average | Worst case |
|---|---|---|
| insert | O(1) | O(n) |
lookup / in |
O(1) | O(n) |
| delete | O(1) | O(n) |
| iterate all | O(n) | O(n) |
| find min / max | O(n) | O(n) |
| space | O(n) | O(n) |
The O(1) is average, not guaranteed, and it rests on two assumptions: the hash function spreads keys evenly across buckets, and the table has enough buckets that most contain one entry. Break either and a lookup degrades toward scanning a whole bucket.
Note the O(n) on min/max. A hash table has no ordering whatsoever — keys land wherever their hash sends them — so "smallest key" means checking every one. If a problem needs both fast lookup and order, that is what the ordered variants at the bottom of this file are for.
Collisions
Two different keys can hash to the same bucket. This is unavoidable: the key space is unbounded and the bucket count is not. Two standard resolutions.
Chaining. Each bucket holds a list of entries. On collision, append; on lookup, scan the short list comparing keys for real equality.
bucket 3: [ cat:1 ] → [ act:7 ] → [ tac:9 ]
Open addressing. Keep one entry per bucket; on collision, probe forward for the next free slot. Lookup probes the same sequence until it finds the key or an empty slot. CPython's dict uses this.
insert "act", also hashing to 3:
slot 3 taken → try 4 → free → store there
[ _ ][ _ ][ _ ][ cat:1 ][ act:7 ][ _ ]
Either way, the cost of an operation is the length of the collision chain or probe sequence. With a good hash function that averages a small constant.
The worst case is all n keys in one bucket, which makes every lookup an O(n) scan and turns an O(n) algorithm into O(n²). In practice this happens only with adversarial input (deliberately crafted colliding keys — a real denial-of-service vector, which is why Python randomises string hashing per process) or a badly written custom hashCode. It is not something an interview input will trigger, but stating the worst case when asked is the difference between knowing the structure and reciting it.
Load factor and resizing
The load factor is entries / buckets. As it rises, collisions rise with it. Implementations keep it under a threshold — roughly 0.66 for CPython, 0.75 for Java — by resizing: allocate a table two to four times larger and reinsert every entry.
Reinsertion is required because the bucket index is hash % capacity, and changing the capacity changes every index. That makes a resize O(n).
Resizes happen after 1, 2, 4, 8, … doublings of content, so the copying across n inserts sums to less than 2n, and the per-insert cost stays O(1) amortised — the same argument as array doubling in 2d_stacks.md. The practical consequence: a single insert can occasionally be slow, which matters for latency-sensitive code and never for interview complexity. If the final size is known, presizing the table skips the rehashing entirely.
What can be a key
A key must be hashable, which in practice means immutable. The reason is mechanical: the object's hash determines which bucket it sits in, so mutating a key after insertion leaves it filed under an address that no longer matches it. It becomes unfindable — still consuming memory, never returned by a lookup.
d = {}
d[(1, 2)] = "ok" # tuple — immutable, hashable
d[[1, 2]] = "boom" # TypeError: unhashable type: 'list'
d[frozenset({1, 2})] # frozenset is the hashable set
Two rules that must hold together for any custom key type:
- Equal objects must have equal hashes. Otherwise equal keys land in different buckets and the map holds duplicates.
- Unequal objects may share a hash. That is just a collision, handled by the equality check.
In Python, defining __eq__ without __hash__ makes a class unhashable by default — the language forcing rule 1 rather than letting you violate it silently. Java's contract is the same: override equals, override hashCode.
A common trick follows from this: to key on a multiset, use a canonical immutable form. Grouping anagrams uses the sorted string or a 26-length tuple of counts as the key.
What they're actually for
The recurring move is trading space for time: replace a scan with a lookup, turning O(n²) into O(n).
Seen-before. Two Sum, Contains Duplicate, Longest Consecutive Sequence. One pass, checking membership of something already computed.
seen = {}
for i, x in enumerate(nums):
if target - x in seen: # O(1) instead of scanning the prefix
return [seen[target - x], i]
seen[x] = i
Counting. Frequency maps for Valid Anagram, Top K Frequent, Group Anagrams. collections.Counter is a dict subclass with counting built in.
Grouping. defaultdict(list) keyed by some canonical form — the sorted string for anagrams, (row//3, col//3) for Sudoku boxes.
Memoization. Every top-down DP cache is a hash map from state tuple to answer (see 12a_dynamic_programming.md).
Visited tracking. Graph traversal, cycle detection, backtracking (see 11a_graphs_bfs_dfs.md).
Sliding window state. A count map of the current window, updated as it moves rather than recomputed.
Adjacency lists. defaultdict(list) is the usual graph representation.
Sets specifically: membership testing, deduplication, and intersection/union/difference in O(min(n, m)) rather than nested loops.
a & b # intersection
a | b # union
a - b # difference
Ordered versions
"Ordered" means two genuinely different things, implemented differently, with different complexity. Confusing them is the main source of wrong answers here.
Insertion-ordered — still a hash table
Keys come back in the order they were first inserted. This is not sorting, and it costs nothing asymptotically: the implementation is a hash table plus a doubly linked list threading the entries in insertion order, or (in CPython) a compact array of entries with the hash table storing indices into it.
hash table (indices) entries array (insertion order)
[ _ ][ 2 ][ 0 ][ _ ][ 1 ] 0: ("cat", 1)
1: ("dog", 2)
2: ("fox", 3)
Lookups still go through the hash table, so they stay O(1). Iteration walks the entries array, which is also why iterating a modern Python dict is faster and more cache-friendly than iterating a chained one.
- Python:
dictpreserves insertion order as a language guarantee since 3.7.collections.OrderedDictaddsmove_to_end()andpopitem(last=False), which is why it is still the natural base for an LRU cache. - Java:
LinkedHashMap/LinkedHashSet. ItsaccessOrderconstructor flag reorders on access rather than insertion, giving an LRU cache with an overridableremoveEldestEntry. - C++: no standard equivalent; combine
unordered_mapwith alistmanually.
An LRU cache is exactly this structure: a hash map for O(1) lookup, plus a linked list for O(1) eviction of the least recently used end. Both halves are needed — the map alone cannot tell you what is oldest, and the list alone cannot find a key.
Sorted — not a hash table at all
Keys come back in comparison order. This cannot be done with hashing, because a hash deliberately destroys the relationship between a key's value and its location. A sorted map is a balanced binary search tree (red-black, typically), so every operation walks the tree height.
| Operation | Hash map | Sorted map |
|---|---|---|
| insert / lookup / delete | O(1) avg | O(log n) |
| min / max | O(n) | O(1)–O(log n) |
| predecessor / successor | not supported | O(log n) |
range query [lo, hi] |
O(n) | O(log n + k) |
| iterate in sorted order | O(n log n) — must sort | O(n) |
- C++:
std::map/std::setare the sorted, tree-backed ones;std::unordered_map/unordered_setare the hash-backed ones. The shorter name being the slower structure catches people out. - Java:
TreeMap/TreeSet, withfloorKey,ceilingKey,higherKey,subMap,headMap. - Python: nothing in the standard library. Use
sortedcontainers(SortedList,SortedDict) where available, or a heap when only the extreme is needed, orbisecton a list — which gives O(log n) search but O(n) insertion, since the elements shift.
Reach for a sorted map only when the extra operations are actually required: "the smallest key greater than x", "everything in this range", "process in sorted order as items arrive". Paying O(log n) for plain lookups otherwise is a straight loss.
Which to use
| Need | Structure |
|---|---|
| membership, counting, grouping | hash map / hash set |
| the above, plus stable output order | insertion-ordered map |
| LRU eviction | hash map + doubly linked list |
| smallest/largest only | heap (2g_heaps_and_priority_queues.md) |
| predecessor, successor, range queries | sorted map / balanced BST |
| all keys sorted, built once | hash map, then sort at the end |
That last row is worth noting: if order is needed only for the final output, a hash map plus one O(n log n) sort at the end usually beats maintaining a tree throughout.
Pitfalls
- Claiming O(1) worst case. It is average. Say so when asked.
- Mutating a key after insertion. The entry becomes unreachable, with no error raised.
defaultdictcreating entries on read. Merely checkingd[k]inserts a default value, which silently changes the map's size and its iteration.- Modifying a map while iterating it.
RuntimeErrorin Python,ConcurrentModificationExceptionin Java, undefined behaviour in C++. Collect keys first, then mutate. - Relying on
setiteration order. Python'sdictguarantees insertion order;setdoes not guarantee anything. - Assuming a sorted map is a hash map.
std::mapis O(log n), not O(1); a loop doing a million lookups will feel the difference. - Hashing a mutable object as a key. Freeze it first —
tuple(list),frozenset(set),str(sorted(...)).